From 37c0316f3767a4d816e7c4f8459f7ddff9e72507 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 17 Oct 2025 20:12:44 +0200 Subject: [PATCH 01/18] Add the restorer --- packages/application-extension/src/index.ts | 70 ++++++- packages/application/src/panelhandler.ts | 48 ++++- packages/application/src/shell.ts | 213 +++++++++++++++++--- packages/docmanager-extension/src/index.ts | 5 +- 4 files changed, 310 insertions(+), 26 deletions(-) diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 1367cf161a..306175ed07 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -3,11 +3,13 @@ import { ILabStatus, + ILayoutRestorer, IRouter, ITreePathUpdater, JupyterFrontEnd, JupyterFrontEndPlugin, JupyterLab, + LayoutRestorer, } from '@jupyterlab/application'; import { @@ -16,7 +18,9 @@ import { ISanitizer, ISplashScreen, IToolbarWidgetRegistry, + IWindowResolver, showErrorMessage, + WindowResolver, } from '@jupyterlab/apputils'; import { ConsolePanel } from '@jupyterlab/console'; @@ -40,6 +44,8 @@ import { import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { IStateDB } from '@jupyterlab/statedb'; + import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { @@ -176,6 +182,46 @@ const info: JupyterFrontEndPlugin = { }, }; +/** + * The default layout restorer provider. + */ +const layoutRestorer: JupyterFrontEndPlugin = { + 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) { + console.log('No layout for this view'); + return null; + } + + const first = app.started; + const registry = app.commands; + + const restorer = new LayoutRestorer({ + connector: state, + first, + registry, + }); + + // Restore the layout. + void notebookShell.restoreLayout(restorer, {}).then(() => { + notebookShell.layoutModified.connect(() => { + void restorer.save(notebookShell.saveLayout()); + }); + }); + + return restorer; + }, + autoStart: true, + provides: ILayoutRestorer, +}; + /** * The logo plugin. */ @@ -465,6 +511,26 @@ const rendermime: JupyterFrontEndPlugin = { }, }; +/** + * The default window name resolver provider. + */ +const resolver: JupyterFrontEndPlugin = { + id: '@jupyter-notebook/apputils-extension:resolver', + description: 'Provides the window name resolver.', + autoStart: true, + provides: IWindowResolver, + requires: [JupyterFrontEnd.IPaths, IRouter], + activate: async ( + app: JupyterFrontEnd, + paths: JupyterFrontEnd.IPaths, + router: IRouter + ) => { + const solver = new WindowResolver(); + (solver as any)._name = 'nb-default'; + return solver; + }, +}; + /** * The default Jupyter Notebook application shell. */ @@ -491,7 +557,7 @@ const shell: JupyterFrontEndPlugin = { 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.'); @@ -1189,6 +1255,7 @@ const zen: JupyterFrontEndPlugin = { const plugins: JupyterFrontEndPlugin[] = [ dirty, info, + layoutRestorer, logo, menus, menuSpacer, @@ -1197,6 +1264,7 @@ const plugins: JupyterFrontEndPlugin[] = [ pathOpener, paths, rendermime, + resolver, shell, sidePanelVisibility, shortcuts, diff --git a/packages/application/src/panelhandler.ts b/packages/application/src/panelhandler.ts index 525b62bb04..11c23faf56 100644 --- a/packages/application/src/panelhandler.ts +++ b/packages/application/src/panelhandler.ts @@ -119,7 +119,7 @@ export class SidePanelHandler extends PanelHandler { * Whether the panel is visible */ get isVisible(): boolean { - return this._panel.isVisible; + return this._currentWidget?.isVisible || false; } /** @@ -150,6 +150,13 @@ export class SidePanelHandler extends PanelHandler { return this._widgetRemoved; } + /** + * A signal emitting when the panel closes. + */ + get closed(): ISignal { + return this._closed; + } + /** * Get the close button element. */ @@ -209,6 +216,7 @@ export class SidePanelHandler extends PanelHandler { collapse(): void { this._currentWidget?.hide(); this._currentWidget = null; + this._closed.emit(); } /** @@ -245,6 +253,28 @@ export class SidePanelHandler extends PanelHandler { this._refreshVisibility(); } + /** + * Dehydrate the panel layout. + */ + dehydrate(): SidePanel.ISideArea { + return { + visible: this.isVisible, + currentWidget: this.currentWidget, + }; + } + + /** + * Rehydrate the panel. + */ + rehydrate(data: SidePanel.ISideArea) { + if (data.currentWidget) { + this.activate(data.currentWidget.id); + } + if (data.visible) { + this.show(); + } + } + /** * Find the insertion index for a rank item. */ @@ -296,6 +326,7 @@ export class SidePanelHandler extends PanelHandler { private _closeButton: HTMLButtonElement; private _widgetAdded: Signal = new Signal(this); private _widgetRemoved: Signal = new Signal(this); + private _closed: Signal = new Signal(this); } /** @@ -306,6 +337,21 @@ export namespace SidePanel { * The areas of the sidebar panel */ export type Area = 'left' | 'right'; + + /** + * The restorable description of a sidebar in the user interface. + */ + export interface ISideArea { + /** + * The current widget that has side area focus. + */ + readonly currentWidget: Widget | null; + + /** + * A flag denoting whether the side tab bar is visible. + */ + readonly visible: boolean; + } } /** diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 65a7159c14..bcb1095a5f 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -1,12 +1,14 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { JupyterFrontEnd } from '@jupyterlab/application'; +import { JupyterFrontEnd, LayoutRestorer } from '@jupyterlab/application'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { TabPanelSvg } from '@jupyterlab/ui-components'; -import { find } from '@lumino/algorithm'; +import { ArrayExt, find } from '@lumino/algorithm'; import { JSONExt, PromiseDelegate, Token } from '@lumino/coreutils'; +import { Message, MessageLoop } from '@lumino/messaging'; import { ISignal, Signal } from '@lumino/signaling'; import { @@ -18,7 +20,6 @@ import { Widget, } from '@lumino/widgets'; import { PanelHandler, SidePanelHandler } from './panelhandler'; -import { TabPanelSvg } from '@jupyterlab/ui-components'; /** * The Jupyter Notebook application shell token. @@ -120,6 +121,10 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { leftHandler.hide(); rightHandler.hide(); + // Listen for the panel closed. + leftHandler.closed.connect(this._onLayoutModified); + rightHandler.closed.connect(this._onLayoutModified); + const middleLayout = new BoxLayout({ spacing: 0, direction: 'top-to-bottom', @@ -136,11 +141,12 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { middlePanel.addWidget(this._spacer_bottom); middlePanel.layout = middleLayout; - const vsplitPanel = new SplitPanel(); - vsplitPanel.id = 'jp-main-vsplit-panel'; - vsplitPanel.spacing = 1; - vsplitPanel.orientation = 'vertical'; - SplitPanel.setStretch(vsplitPanel, 1); + this._vsplitPanel = new Private.RestorableSplitPanel(); + this._vsplitPanel.id = 'jp-main-vsplit-panel'; + this._vsplitPanel.spacing = 1; + this._vsplitPanel.orientation = 'vertical'; + SplitPanel.setStretch(this._vsplitPanel, 1); + this._vsplitPanel.updated.connect(this._onLayoutModified); const downPanel = new TabPanelSvg({ tabsMovable: true, @@ -148,30 +154,30 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { this._downPanel = downPanel; this._downPanel.id = 'jp-down-stack'; - // TODO: Consider storing this as an attribute this._hsplitPanel if saving/restoring layout needed - const hsplitPanel = new SplitPanel(); - hsplitPanel.id = 'main-split-panel'; - hsplitPanel.spacing = 1; - BoxLayout.setStretch(hsplitPanel, 1); + this._hsplitPanel = new Private.RestorableSplitPanel(); + this._hsplitPanel.id = 'main-split-panel'; + this._hsplitPanel.spacing = 1; + BoxLayout.setStretch(this._hsplitPanel, 1); SplitPanel.setStretch(leftHandler.panel, 0); SplitPanel.setStretch(rightHandler.panel, 0); SplitPanel.setStretch(middlePanel, 1); - hsplitPanel.addWidget(leftHandler.panel); - hsplitPanel.addWidget(middlePanel); - hsplitPanel.addWidget(rightHandler.panel); + this._hsplitPanel.addWidget(leftHandler.panel); + this._hsplitPanel.addWidget(middlePanel); + this._hsplitPanel.addWidget(rightHandler.panel); // Use relative sizing to set the width of the side panels. // This will still respect the min-size of children widget in the stacked // panel. - hsplitPanel.setRelativeSizes([1, 2.5, 1]); + this._hsplitPanel.setRelativeSizes([1, 2.5, 1]); + this._hsplitPanel.updated.connect(this._onLayoutModified); - vsplitPanel.addWidget(hsplitPanel); - vsplitPanel.addWidget(downPanel); + this._vsplitPanel.addWidget(this._hsplitPanel); + this._vsplitPanel.addWidget(downPanel); rootLayout.spacing = 0; - rootLayout.addWidget(vsplitPanel); + rootLayout.addWidget(this._vsplitPanel); // initially hiding the down panel this._downPanel.hide(); @@ -255,10 +261,20 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { } /** - * Promise that resolves when the main widget is loaded + * A signal emitting when the layout changed. + */ + get layoutModified(): ISignal { + return this._layoutModified; + } + + /** + * Promise that resolves when the main widget is loaded and the layout restored. */ get restored(): Promise { - return this._mainWidgetLoaded.promise; + return Promise.all([ + this._mainWidgetLoaded.promise, + this._restored.promise, + ]).then((res) => undefined); } /** @@ -432,6 +448,7 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { expandLeft(id?: string): void { this._leftHandler.panel.show(); this._leftHandler.expand(id); // Show the current widget, if any + this._onLayoutModified(); } /** @@ -440,6 +457,7 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { collapseLeft(): void { this._leftHandler.collapse(); this._leftHandler.panel.hide(); + this._onLayoutModified(); } /** @@ -448,6 +466,7 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { expandRight(id?: string): void { this._rightHandler.panel.show(); this._rightHandler.expand(id); // Show the current widget, if any + this._onLayoutModified(); } /** @@ -456,17 +475,129 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { collapseRight(): void { this._rightHandler.collapse(); this._rightHandler.panel.hide(); + this._onLayoutModified(); } /** * Restore the layout state and configuration for the application shell. */ - async restoreLayout( + async restoreLayoutConf( configuration: INotebookShell.IUserLayout ): Promise { this._userLayout = configuration; } + /** + * Restore the layout state and configuration for the application shell. + * + * #### Notes + * This should only be called once. + */ + async restoreLayout( + layoutRestorer: LayoutRestorer, + configuration: { + [m: string]: INotebookShell.IUserLayout; + } = {} + ): Promise { + // Get the layout from the restorer + const layout = await layoutRestorer.fetch(); + + // Reset the layout + const { downArea, leftArea, relativeSizes, rightArea } = layout; + + // Rehydrate the down area + if (downArea) { + const { currentWidget, size, widgets } = downArea; + + const widgetIds = widgets?.map((widget) => widget.id) ?? []; + // Remove absent widgets + this._downPanel.tabBar.titles + .filter((title) => !widgetIds.includes(title.owner.id)) + .map((title) => title.owner.close()); + // Add new widgets + const titleIds = this._downPanel.tabBar.titles.map( + (title) => title.owner.id + ); + widgets + ?.filter((widget) => !titleIds.includes(widget.id)) + .map((widget) => this._downPanel.addWidget(widget)); + // Reorder tabs + while ( + !ArrayExt.shallowEqual( + widgetIds, + this._downPanel.tabBar.titles.map((title) => title.owner.id) + ) + ) { + this._downPanel.tabBar.titles.forEach((title, index) => { + const position = widgetIds.findIndex((id) => title.owner.id === id); + if (position >= 0 && position !== index) { + this._downPanel.tabBar.insertTab(position, title); + } + }); + } + + if (currentWidget) { + const index = this._downPanel.stackedPanel.widgets.findIndex( + (widget) => widget.id === currentWidget.id + ); + if (index) { + this._downPanel.currentIndex = index; + this._downPanel.currentWidget?.activate(); + } + } + + if (size && size > 0.0) { + this._vsplitPanel.setRelativeSizes([1.0 - size, size]); + } else { + // Close all tabs and hide the panel + this._downPanel.stackedPanel.widgets.forEach((widget) => + widget.close() + ); + this._downPanel.hide(); + } + } + + // Rehydrate the left area. + if (leftArea) { + this._leftHandler.rehydrate(leftArea); + } + + // Rehydrate the right area. + if (rightArea) { + this._rightHandler.rehydrate(rightArea); + } + + // Restore the relative sizes. + if (relativeSizes) { + this._hsplitPanel.setRelativeSizes(relativeSizes); + } + + // Make sure all messages in the queue are finished before notifying + // any extensions that are waiting for the promise that guarantees the + // application state has been restored. + MessageLoop.flush(); + this._restored.resolve(); + } + + /** + * Save the dehydrated state of the application shell. + */ + saveLayout(): any { + // If the application is in single document mode, use the cached layout if + // available. Otherwise, default to querying the dock panel for layout. + const layout = { + downArea: { + currentWidget: this._downPanel.currentWidget, + widgets: Array.from(this._downPanel.stackedPanel.widgets), + size: this._vsplitPanel.relativeSizes()[1], + }, + leftArea: this._leftHandler.dehydrate(), + rightArea: this._rightHandler.dehydrate(), + relativeSizes: this._hsplitPanel.relativeSizes(), + }; + return layout; + } + /** * Handle a change on the down panel widgets */ @@ -474,8 +605,13 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { if (this._downPanel.stackedPanel.widgets.length === 0) { this._downPanel.hide(); } + this._onLayoutModified(); } + private _onLayoutModified = () => { + this._layoutModified.emit(); + }; + private _topWrapper: Panel; private _topHandler: PanelHandler; private _menuWrapper: Panel; @@ -486,13 +622,17 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { private _spacer_bottom: Widget; private _skipLinkWidgetHandler: Private.SkipLinkWidgetHandler; private _main: Panel; + private _hsplitPanel: Private.RestorableSplitPanel; + private _vsplitPanel: Private.RestorableSplitPanel; private _downPanel: TabPanel; private _translator: ITranslator = nullTranslator; private _currentChanged = new Signal>( this ); private _mainWidgetLoaded = new PromiseDelegate(); + private _restored = new PromiseDelegate(); private _userLayout: INotebookShell.IUserLayout; + private _layoutModified = new Signal(this); } export namespace Private { @@ -572,4 +712,31 @@ export namespace Private { private _skipLinkWidget: Widget; private _isDisposed = false; } + + export class RestorableSplitPanel extends SplitPanel { + /** + * Construct a new RestorableSplitPanel. + */ + constructor(options: SplitPanel.IOptions = {}) { + super(options); + this._updated = new Signal(this); + } + + /** + * A signal emitted when the split panel is updated. + */ + get updated(): ISignal { + return this._updated; + } + + /** + * Emit 'updated' signal on 'update' requests. + */ + protected onUpdateRequest(msg: Message): void { + super.onUpdateRequest(msg); + this._updated.emit(); + } + + private _updated: Signal; + } } diff --git a/packages/docmanager-extension/src/index.ts b/packages/docmanager-extension/src/index.ts index 5d51e2e4e0..a3fece2284 100644 --- a/packages/docmanager-extension/src/index.ts +++ b/packages/docmanager-extension/src/index.ts @@ -45,7 +45,10 @@ const opener: JupyterFrontEndPlugin = { options?: DocumentRegistry.IOpenOptions ) { const widgetName = options?.type ?? ''; - const ref = options?.ref; + + // Should we consider ref === null or ref === undefined as different the '_noref' ? + const ref = options?.ref ?? '_noref'; + // check if there is an setting override and if it would add the widget in the main area const userLayoutArea = notebookShell?.userLayout?.[widgetName]?.area; From 86fc3df0cccc16144c233f756d0210724b2de2de Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 20 Oct 2025 17:04:25 +0200 Subject: [PATCH 02/18] Do not use the restorer on tree view, and do not restore widgets from trackers --- packages/application-extension/src/index.ts | 41 ++++++++++++--------- packages/application/src/index.ts | 1 + packages/application/src/layoutrestorer.ts | 14 +++++++ packages/application/src/shell.ts | 17 +++++++-- packages/docmanager-extension/src/index.ts | 5 +-- 5 files changed, 53 insertions(+), 25 deletions(-) create mode 100644 packages/application/src/layoutrestorer.ts diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 306175ed07..0f7991f204 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -9,7 +9,6 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin, JupyterLab, - LayoutRestorer, } from '@jupyterlab/application'; import { @@ -20,7 +19,6 @@ import { IToolbarWidgetRegistry, IWindowResolver, showErrorMessage, - WindowResolver, } from '@jupyterlab/apputils'; import { ConsolePanel } from '@jupyterlab/console'; @@ -57,8 +55,11 @@ import { SidePanelPalette, INotebookPathOpener, defaultNotebookPathOpener, + NotebookLayoutRestorer, } from '@jupyter-notebook/application'; +import { NotebookTreeWidget } from '@jupyter-notebook/tree'; + import { jupyterIcon } from '@jupyter-notebook/ui-components'; import { PromiseDelegate } from '@lumino/coreutils'; @@ -196,23 +197,32 @@ const layoutRestorer: JupyterFrontEndPlugin = { notebookShell: INotebookShell | null ) => { if (!notebookShell) { - console.log('No layout for this view'); return null; } - const first = app.started; const registry = app.commands; - const restorer = new LayoutRestorer({ + const restorer = new NotebookLayoutRestorer({ connector: state, first, registry, }); - // Restore the layout. - void notebookShell.restoreLayout(restorer, {}).then(() => { - notebookShell.layoutModified.connect(() => { - void restorer.save(notebookShell.saveLayout()); + // 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 NotebookTreeWidget + ); + + // 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()); + }); + } }); }); @@ -519,15 +529,10 @@ const resolver: JupyterFrontEndPlugin = { description: 'Provides the window name resolver.', autoStart: true, provides: IWindowResolver, - requires: [JupyterFrontEnd.IPaths, IRouter], - activate: async ( - app: JupyterFrontEnd, - paths: JupyterFrontEnd.IPaths, - router: IRouter - ) => { - const solver = new WindowResolver(); - (solver as any)._name = 'nb-default'; - return solver; + activate: async (app: JupyterFrontEnd) => { + return { + name: 'nb-default', + }; }, }; diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index c726fb4561..6ed4af672f 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -3,6 +3,7 @@ export * from './app'; export * from './shell'; +export * from './layoutrestorer'; export * from './panelhandler'; export * from './pathopener'; export * from './tokens'; diff --git a/packages/application/src/layoutrestorer.ts b/packages/application/src/layoutrestorer.ts new file mode 100644 index 0000000000..88ce9c3135 --- /dev/null +++ b/packages/application/src/layoutrestorer.ts @@ -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 + ): Promise { + // no-op as we don't want to restore widgets, only the layout. + } +} diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index bcb1095a5f..68a3b16e79 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -267,6 +267,13 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { return this._layoutModified; } + /** + * Promise that resolves when the main widget is loaded. + */ + get mainWidgetLoaded(): Promise { + return this._mainWidgetLoaded.promise; + } + /** * Promise that resolves when the main widget is loaded and the layout restored. */ @@ -495,10 +502,14 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { */ async restoreLayout( layoutRestorer: LayoutRestorer, - configuration: { - [m: string]: INotebookShell.IUserLayout; - } = {} + restore: boolean ): Promise { + // If no restoration is expected for the current view, resolve the promise. + if (!restore) { + this._restored.resolve(); + return; + } + // Get the layout from the restorer const layout = await layoutRestorer.fetch(); diff --git a/packages/docmanager-extension/src/index.ts b/packages/docmanager-extension/src/index.ts index a3fece2284..5d51e2e4e0 100644 --- a/packages/docmanager-extension/src/index.ts +++ b/packages/docmanager-extension/src/index.ts @@ -45,10 +45,7 @@ const opener: JupyterFrontEndPlugin = { options?: DocumentRegistry.IOpenOptions ) { const widgetName = options?.type ?? ''; - - // Should we consider ref === null or ref === undefined as different the '_noref' ? - const ref = options?.ref ?? '_noref'; - + const ref = options?.ref; // check if there is an setting override and if it would add the widget in the main area const userLayoutArea = notebookShell?.userLayout?.[widgetName]?.area; From b3bf861fa9653b335c8aaa3071601e1cddf475a5 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 20 Oct 2025 18:36:50 +0200 Subject: [PATCH 03/18] Fix the dependencies --- packages/application/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/application/package.json b/packages/application/package.json index 9864cf2b68..1c835c203c 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -42,6 +42,7 @@ "watch": "tsc -b --watch" }, "dependencies": { + "@jupyter-notebook/tree": "^7.5.0-beta.1", "@jupyterlab/application": "~4.5.0-beta.1", "@jupyterlab/coreutils": "~6.5.0-beta.1", "@jupyterlab/docregistry": "~4.5.0-beta.1", diff --git a/yarn.lock b/yarn.lock index 60006dfddd..8034e2278e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2250,6 +2250,7 @@ __metadata: dependencies: "@babel/core": ^7.11.6 "@babel/preset-env": ^7.12.1 + "@jupyter-notebook/tree": ^7.5.0-beta.1 "@jupyterlab/application": ~4.5.0-beta.1 "@jupyterlab/coreutils": ~6.5.0-beta.1 "@jupyterlab/docregistry": ~4.5.0-beta.1 From ef62c4f1077a9755bb519dd267a9792178416e3b Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 21 Oct 2025 11:20:32 +0200 Subject: [PATCH 04/18] Fix ui-test --- ui-tests/test/mobile.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tests/test/mobile.spec.ts b/ui-tests/test/mobile.spec.ts index c4979f6795..2045bb3aa9 100644 --- a/ui-tests/test/mobile.spec.ts +++ b/ui-tests/test/mobile.spec.ts @@ -33,7 +33,7 @@ test.describe('Mobile', () => { }) => { await page.goto(`tree/${tmpPath}`); - await page.waitForSelector('#top-panel-wrapper', { state: 'hidden' }); + await page.locator(`.jp-BreadCrumbs-item[title="${tmpPath}"]`).waitFor(); expect(await page.screenshot()).toMatchSnapshot('tree.png', { maxDiffPixels: 300, From c1b056e3930efed62c9852be078c1704be6ffec0 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 21 Oct 2025 13:37:57 +0200 Subject: [PATCH 05/18] Provide IStateDB in notebook, to avoid the magic that set the browser tab's title --- app/package.json | 1 - packages/application-extension/package.json | 1 + packages/application-extension/src/index.ts | 186 +++++++++++++++++++- yarn.lock | 1 + 4 files changed, 187 insertions(+), 2 deletions(-) diff --git a/app/package.json b/app/package.json index eb55b9ebca..ec323f232e 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/packages/application-extension/package.json b/packages/application-extension/package.json index daf2dca19f..6a9e0b98ac 100644 --- a/packages/application-extension/package.json +++ b/packages/application-extension/package.json @@ -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": { diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 0f7991f204..20b65c124b 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -42,7 +42,7 @@ import { import { ISettingRegistry } from '@jupyterlab/settingregistry'; -import { IStateDB } from '@jupyterlab/statedb'; +import { IStateDB, StateDB } from '@jupyterlab/statedb'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; @@ -70,6 +70,8 @@ import { IDisposable, } from '@lumino/disposable'; +import { Debouncer } from '@lumino/polling'; + import { Menu, Widget } from '@lumino/widgets'; /** @@ -136,6 +138,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'; } /** @@ -603,6 +615,177 @@ const splash: JupyterFrontEndPlugin = { }, }; +/** + * 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 `clone` and `reset`, and workspace + * auto-saving. Otherwise, it will return a simple in-memory state database. + */ +const state: JupyterFrontEndPlugin = { + id: '@jupyter-notebook/application-extension:state', + description: 'Provides the application state. It is stored per workspaces.', + autoStart: true, + provides: IStateDB, + requires: [IRouter, ITranslator], + optional: [IWindowResolver], + activate: ( + app: JupyterFrontEnd, + router: IRouter, + translator: ITranslator, + resolver: IWindowResolver | null + ) => { + const trans = translator.load('jupyterlab'); + + if (resolver === null) { + return new StateDB(); + } + + let resolved = false; + const { commands, serviceManager } = app; + const { workspaces } = serviceManager; + const workspace = resolver.name; + const transform = new PromiseDelegate(); + 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, { + 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. */ @@ -1274,6 +1457,7 @@ const plugins: JupyterFrontEndPlugin[] = [ sidePanelVisibility, shortcuts, splash, + state, status, tabTitle, title, diff --git a/yarn.lock b/yarn.lock index 8034e2278e..b231be8cae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2238,6 +2238,7 @@ __metadata: "@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 rimraf: ^3.0.2 typescript: ~5.5.4 From 60f4dc599f8010828c07d4c573e32a8858bbdb96 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 21 Oct 2025 14:30:12 +0200 Subject: [PATCH 06/18] Do not activate widget during restoration if panel not visible --- packages/application-extension/src/index.ts | 4 ++-- packages/application/src/panelhandler.ts | 8 ++++---- packages/application/src/shell.ts | 6 ++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 20b65c124b..9d6b7bdfc4 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -620,8 +620,8 @@ const splash: JupyterFrontEndPlugin = { * * #### Notes * If this extension is loaded with a window resolver, it will automatically add - * state management commands, URL support for `clone` and `reset`, and workspace - * auto-saving. Otherwise, it will return a simple in-memory state database. + * state management commands, URL support for `reset`, and workspace auto-saving. + * Otherwise, it will return a simple in-memory state database. */ const state: JupyterFrontEndPlugin = { id: '@jupyter-notebook/application-extension:state', diff --git a/packages/application/src/panelhandler.ts b/packages/application/src/panelhandler.ts index 11c23faf56..1dd6f6729c 100644 --- a/packages/application/src/panelhandler.ts +++ b/packages/application/src/panelhandler.ts @@ -119,7 +119,7 @@ export class SidePanelHandler extends PanelHandler { * Whether the panel is visible */ get isVisible(): boolean { - return this._currentWidget?.isVisible || false; + return (this._currentWidget?.isVisible || false) && this._panel.isVisible; } /** @@ -267,10 +267,10 @@ export class SidePanelHandler extends PanelHandler { * Rehydrate the panel. */ rehydrate(data: SidePanel.ISideArea) { - if (data.currentWidget) { - this.activate(data.currentWidget.id); - } if (data.visible) { + if (data.currentWidget) { + this.activate(data.currentWidget.id); + } this.show(); } } diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 68a3b16e79..558026fe2d 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -248,16 +248,14 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { * Is the left sidebar visible? */ get leftCollapsed(): boolean { - return !(this._leftHandler.isVisible && this._leftHandler.panel.isVisible); + return !this._leftHandler.isVisible; } /** * Is the right sidebar visible? */ get rightCollapsed(): boolean { - return !( - this._rightHandler.isVisible && this._rightHandler.panel.isVisible - ); + return !this._rightHandler.isVisible; } /** From 40a6646e74caeda2a7125e57c1e8a20c32eafaf7 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 21 Oct 2025 14:53:02 +0200 Subject: [PATCH 07/18] Restore the top bar --- packages/application/src/shell.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 558026fe2d..36e6e017c0 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -512,7 +512,7 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { const layout = await layoutRestorer.fetch(); // Reset the layout - const { downArea, leftArea, relativeSizes, rightArea } = layout; + const { downArea, leftArea, relativeSizes, rightArea, topArea } = layout; // Rehydrate the down area if (downArea) { @@ -581,6 +581,14 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { this._hsplitPanel.setRelativeSizes(relativeSizes); } + // Restore the top area visibility. + if (topArea) { + const { simpleVisibility } = topArea; + if (simpleVisibility) { + this.top.show(); + } + } + // Make sure all messages in the queue are finished before notifying // any extensions that are waiting for the promise that guarantees the // application state has been restored. @@ -603,6 +611,9 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { leftArea: this._leftHandler.dehydrate(), rightArea: this._rightHandler.dehydrate(), relativeSizes: this._hsplitPanel.relativeSizes(), + topArea: { + simpleVisibility: this.top.isVisible + } }; return layout; } From eb8c08ec7a31008bd8908a5365d6cf24d258309f Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 21 Oct 2025 15:05:43 +0200 Subject: [PATCH 08/18] Restore only the Notebook panel --- packages/application-extension/package.json | 1 + packages/application-extension/src/index.ts | 9 ++++----- packages/application/package.json | 1 - yarn.lock | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/application-extension/package.json b/packages/application-extension/package.json index 6a9e0b98ac..741f91fb20 100644 --- a/packages/application-extension/package.json +++ b/packages/application-extension/package.json @@ -48,6 +48,7 @@ "@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", diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 9d6b7bdfc4..e7fdaf2910 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -31,6 +31,8 @@ import { DocumentWidget } from '@jupyterlab/docregistry'; import { IMainMenu } from '@jupyterlab/mainmenu'; +import { NotebookPanel } from '@jupyterlab/notebook'; + import { ILatexTypesetter, IMarkdownParser, @@ -58,8 +60,6 @@ import { NotebookLayoutRestorer, } from '@jupyter-notebook/application'; -import { NotebookTreeWidget } from '@jupyter-notebook/tree'; - import { jupyterIcon } from '@jupyter-notebook/ui-components'; import { PromiseDelegate } from '@lumino/coreutils'; @@ -223,9 +223,8 @@ const layoutRestorer: JupyterFrontEndPlugin = { // 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 NotebookTreeWidget - ); + const restoreLayout = + notebookShell.currentWidget instanceof NotebookPanel; // Call the restorer even if the layout must not be restored, to resolve the // promise. diff --git a/packages/application/package.json b/packages/application/package.json index 1c835c203c..9864cf2b68 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -42,7 +42,6 @@ "watch": "tsc -b --watch" }, "dependencies": { - "@jupyter-notebook/tree": "^7.5.0-beta.1", "@jupyterlab/application": "~4.5.0-beta.1", "@jupyterlab/coreutils": "~6.5.0-beta.1", "@jupyterlab/docregistry": "~4.5.0-beta.1", diff --git a/yarn.lock b/yarn.lock index b231be8cae..56965408e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2233,6 +2233,7 @@ __metadata: "@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 @@ -2251,7 +2252,6 @@ __metadata: dependencies: "@babel/core": ^7.11.6 "@babel/preset-env": ^7.12.1 - "@jupyter-notebook/tree": ^7.5.0-beta.1 "@jupyterlab/application": ~4.5.0-beta.1 "@jupyterlab/coreutils": ~6.5.0-beta.1 "@jupyterlab/docregistry": ~4.5.0-beta.1 From 23836090ceb00932ec40fd4645eac31e0f4e1fa6 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 21 Oct 2025 15:27:59 +0200 Subject: [PATCH 09/18] lint --- packages/application/src/shell.ts | 4 ++-- ui-tests/test/mobile.spec.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 36e6e017c0..ce77ae48a7 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -612,8 +612,8 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { rightArea: this._rightHandler.dehydrate(), relativeSizes: this._hsplitPanel.relativeSizes(), topArea: { - simpleVisibility: this.top.isVisible - } + simpleVisibility: this.top.isVisible, + }, }; return layout; } diff --git a/ui-tests/test/mobile.spec.ts b/ui-tests/test/mobile.spec.ts index 2045bb3aa9..a8670df092 100644 --- a/ui-tests/test/mobile.spec.ts +++ b/ui-tests/test/mobile.spec.ts @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { IJupyterLabPage, expect, galata } from '@jupyterlab/galata'; +import { expect, galata } from '@jupyterlab/galata'; import { test } from './fixtures'; From 073417b8280f9bda4830c6e8ea4717a4f91c6049 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 21 Oct 2025 15:44:22 +0200 Subject: [PATCH 10/18] Remove the IWindowResolver, it is not necessary now we provide the IStateDB --- packages/application-extension/src/index.ts | 25 +-------------------- 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index e7fdaf2910..9e403a8771 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -17,7 +17,6 @@ import { ISanitizer, ISplashScreen, IToolbarWidgetRegistry, - IWindowResolver, showErrorMessage, } from '@jupyterlab/apputils'; @@ -532,21 +531,6 @@ const rendermime: JupyterFrontEndPlugin = { }, }; -/** - * The default window name resolver provider. - */ -const resolver: JupyterFrontEndPlugin = { - id: '@jupyter-notebook/apputils-extension:resolver', - description: 'Provides the window name resolver.', - autoStart: true, - provides: IWindowResolver, - activate: async (app: JupyterFrontEnd) => { - return { - name: 'nb-default', - }; - }, -}; - /** * The default Jupyter Notebook application shell. */ @@ -628,23 +612,17 @@ const state: JupyterFrontEndPlugin = { autoStart: true, provides: IStateDB, requires: [IRouter, ITranslator], - optional: [IWindowResolver], activate: ( app: JupyterFrontEnd, router: IRouter, translator: ITranslator, - resolver: IWindowResolver | null ) => { const trans = translator.load('jupyterlab'); - if (resolver === null) { - return new StateDB(); - } - let resolved = false; const { commands, serviceManager } = app; const { workspaces } = serviceManager; - const workspace = resolver.name; + const workspace = 'nb-default'; const transform = new PromiseDelegate(); const db = new StateDB({ transform: transform.promise }); const save = new Debouncer(async () => { @@ -1451,7 +1429,6 @@ const plugins: JupyterFrontEndPlugin[] = [ pathOpener, paths, rendermime, - resolver, shell, sidePanelVisibility, shortcuts, From 757c0efe3a766227619ed249135ceb76663df719 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 21 Oct 2025 15:49:10 +0200 Subject: [PATCH 11/18] lint --- packages/application-extension/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index 9e403a8771..ef40382c4a 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -615,7 +615,7 @@ const state: JupyterFrontEndPlugin = { activate: ( app: JupyterFrontEnd, router: IRouter, - translator: ITranslator, + translator: ITranslator ) => { const trans = translator.load('jupyterlab'); From 25821b54acd0e28144b89f97e43f20196c27aa6f Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Fri, 24 Oct 2025 14:45:23 +0200 Subject: [PATCH 12/18] Address comments from PR review --- packages/application/src/shell.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index ce77ae48a7..2eca5fb90d 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -549,7 +549,7 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { const index = this._downPanel.stackedPanel.widgets.findIndex( (widget) => widget.id === currentWidget.id ); - if (index) { + if (index !== -1) { this._downPanel.currentIndex = index; this._downPanel.currentWidget?.activate(); } @@ -600,8 +600,6 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { * Save the dehydrated state of the application shell. */ saveLayout(): any { - // If the application is in single document mode, use the cached layout if - // available. Otherwise, default to querying the dock panel for layout. const layout = { downArea: { currentWidget: this._downPanel.currentWidget, From a5e4c8e3d5627c6c377d39e187793bb33e6af9f1 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 27 Oct 2025 12:55:26 +0100 Subject: [PATCH 13/18] Add a command in palette to reset the stateDB --- packages/application-extension/src/index.ts | 38 ++++++++++++++++++++- packages/application/src/shell.ts | 12 ++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index ef40382c4a..da166f429e 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -143,6 +143,11 @@ namespace CommandIDs { */ export const loadState = 'application:load-statedb'; + /** + * Reset application state. + */ + export const reset = 'application:reset-state'; + /** * Reset state when loading for the workspace. */ @@ -612,10 +617,12 @@ const state: JupyterFrontEndPlugin = { autoStart: true, provides: IStateDB, requires: [IRouter, ITranslator], + optional: [ICommandPalette], activate: ( app: JupyterFrontEnd, router: IRouter, - translator: ITranslator + translator: ITranslator, + palette: ICommandPalette | null ) => { const trans = translator.load('jupyterlab'); @@ -690,6 +697,31 @@ const state: JupyterFrontEndPlugin = { }, }); + 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, { label: trans.__('Reset state when loading for the workspace.'), describedBy: { @@ -747,6 +779,10 @@ const state: JupyterFrontEndPlugin = { }, }); + if (palette) { + palette.addItem({ category: 'state', command: CommandIDs.reset }); + } + router.register({ command: CommandIDs.loadState, pattern: /.?/, diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 2eca5fb90d..b717478cf1 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -569,24 +569,34 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { // Rehydrate the left area. if (leftArea) { this._leftHandler.rehydrate(leftArea); + } else { + this.collapseLeft(); } // Rehydrate the right area. if (rightArea) { this._rightHandler.rehydrate(rightArea); + } else { + this.collapseRight(); } // Restore the relative sizes. if (relativeSizes) { this._hsplitPanel.setRelativeSizes(relativeSizes); + } else { + this.collapseLeft(); + this.collapseRight(); + this._hsplitPanel.setRelativeSizes([0, 1, 0]); } // Restore the top area visibility. if (topArea) { const { simpleVisibility } = topArea; if (simpleVisibility) { - this.top.show(); + this.expandTop(); } + } else { + this.collapseTop(); } // Make sure all messages in the queue are finished before notifying From b92df6eed95c0aaf9e66d98fc31916dba0456be8 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 27 Oct 2025 13:07:23 +0100 Subject: [PATCH 14/18] Add layout interface --- packages/application/src/layoutrestorer.ts | 9 ++++++- packages/application/src/shell.ts | 31 ++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/application/src/layoutrestorer.ts b/packages/application/src/layoutrestorer.ts index 88ce9c3135..001086d36d 100644 --- a/packages/application/src/layoutrestorer.ts +++ b/packages/application/src/layoutrestorer.ts @@ -1,14 +1,21 @@ -import { LayoutRestorer } from '@jupyterlab/application'; +import { ILabShell, LayoutRestorer } from '@jupyterlab/application'; import { WidgetTracker } from '@jupyterlab/apputils'; import { IRestorer } from '@jupyterlab/statedb'; import { Widget } from '@lumino/widgets'; +import { INotebookShell } from './shell'; export class NotebookLayoutRestorer extends LayoutRestorer { // Override the restore function, that adds widget tracker state to the restorer. + // This is required to avoid trying to restore the main area widget, which leads to + // new page opening continuously. async restore( tracker: WidgetTracker, options: IRestorer.IOptions ): Promise { // no-op as we don't want to restore widgets, only the layout. } + + save(layout: INotebookShell.ILayout): Promise { + return super.save(layout as ILabShell.ILayout); + } } diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index b717478cf1..bd4e952427 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -19,7 +19,7 @@ import { TabPanel, Widget, } from '@lumino/widgets'; -import { PanelHandler, SidePanelHandler } from './panelhandler'; +import { PanelHandler, SidePanel, SidePanelHandler } from './panelhandler'; /** * The Jupyter Notebook application shell token. @@ -65,6 +65,33 @@ export namespace INotebookShell { */ [k: string]: IWidgetPosition; } + + /** + * The notebook shell layout interface. + */ + export interface ILayout { + downArea: IDownAreaLayout | null; + leftArea: SidePanel.ISideArea | null; + rightArea: SidePanel.ISideArea | null; + relativeSizes: number[] | null; + topArea: ITopAreaLayout | null; + } + + /** + * The down area layout interface. + */ + export interface IDownAreaLayout { + currentWidget: Widget | null; + widgets: Widget[] | null; + size: number | null; + } + + /** + * The top area layout interface. + */ + export interface ITopAreaLayout { + simpleVisibility: boolean | null; + } } /** @@ -609,7 +636,7 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { /** * Save the dehydrated state of the application shell. */ - saveLayout(): any { + saveLayout(): INotebookShell.ILayout { const layout = { downArea: { currentWidget: this._downPanel.currentWidget, From ef5a1dc37080934b7baf40b87749f031f4e67912 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 27 Oct 2025 14:31:39 +0100 Subject: [PATCH 15/18] Add a workspace file per view, to allow saving workspaces for each view without confusion --- packages/application-extension/package.json | 1 - packages/application-extension/src/index.ts | 18 +++++------------- packages/application/src/shell.ts | 11 +---------- yarn.lock | 1 - 4 files changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/application-extension/package.json b/packages/application-extension/package.json index 741f91fb20..6a9e0b98ac 100644 --- a/packages/application-extension/package.json +++ b/packages/application-extension/package.json @@ -48,7 +48,6 @@ "@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", diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index da166f429e..efcf6b488a 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -30,8 +30,6 @@ import { DocumentWidget } from '@jupyterlab/docregistry'; import { IMainMenu } from '@jupyterlab/mainmenu'; -import { NotebookPanel } from '@jupyterlab/notebook'; - import { ILatexTypesetter, IMarkdownParser, @@ -226,18 +224,12 @@ const layoutRestorer: JupyterFrontEndPlugin = { // 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()); - }); - } + void notebookShell.restoreLayout(restorer).then(() => { + notebookShell.layoutModified.connect(() => { + void restorer.save(notebookShell.saveLayout()); + }); }); }); @@ -629,7 +621,7 @@ const state: JupyterFrontEndPlugin = { let resolved = false; const { commands, serviceManager } = app; const { workspaces } = serviceManager; - const workspace = 'nb-default'; + const workspace = PageConfig.getOption('notebookPage'); const transform = new PromiseDelegate(); const db = new StateDB({ transform: transform.promise }); const save = new Debouncer(async () => { diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index bd4e952427..67c779d975 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -525,16 +525,7 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { * #### Notes * This should only be called once. */ - async restoreLayout( - layoutRestorer: LayoutRestorer, - restore: boolean - ): Promise { - // If no restoration is expected for the current view, resolve the promise. - if (!restore) { - this._restored.resolve(); - return; - } - + async restoreLayout(layoutRestorer: LayoutRestorer): Promise { // Get the layout from the restorer const layout = await layoutRestorer.fetch(); diff --git a/yarn.lock b/yarn.lock index 56965408e8..4355e37763 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2233,7 +2233,6 @@ __metadata: "@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 From 2ec66fe7d16e45360e939dbe54bcd2e1337c143e Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Mon, 27 Oct 2025 15:06:53 +0100 Subject: [PATCH 16/18] Do not set update relative size of HsplitPanel --- packages/application/src/shell.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 67c779d975..dd5b8f6d5c 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -604,7 +604,6 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { } else { this.collapseLeft(); this.collapseRight(); - this._hsplitPanel.setRelativeSizes([0, 1, 0]); } // Restore the top area visibility. From 1699d621260c5a174f87220fff8a9a682b249fb0 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 28 Oct 2025 16:10:14 +0100 Subject: [PATCH 17/18] Only hide the top wrapper, and not the spacer --- packages/application/src/shell.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index dd5b8f6d5c..6676b4c17d 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -610,10 +610,10 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { if (topArea) { const { simpleVisibility } = topArea; if (simpleVisibility) { - this.expandTop(); + this._topWrapper.setHidden(false); } } else { - this.collapseTop(); + this._topWrapper.setHidden(true); } // Make sure all messages in the queue are finished before notifying From 7137789b241b40740d0043ec48fccd007b665345 Mon Sep 17 00:00:00 2001 From: Nicolas Brichet Date: Tue, 28 Oct 2025 18:39:20 +0100 Subject: [PATCH 18/18] Avoid saving/restoring document widget --- packages/application-extension/src/index.ts | 10 ++++--- packages/application/src/index.ts | 2 +- packages/application/src/layoutrestorer.ts | 21 ------------- packages/application/src/statedb.ts | 33 +++++++++++++++++++++ 4 files changed, 40 insertions(+), 26 deletions(-) delete mode 100644 packages/application/src/layoutrestorer.ts create mode 100644 packages/application/src/statedb.ts diff --git a/packages/application-extension/src/index.ts b/packages/application-extension/src/index.ts index efcf6b488a..79adc1424d 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -2,6 +2,7 @@ // Distributed under the terms of the Modified BSD License. import { + ILabShell, ILabStatus, ILayoutRestorer, IRouter, @@ -9,6 +10,7 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin, JupyterLab, + LayoutRestorer, } from '@jupyterlab/application'; import { @@ -54,7 +56,7 @@ import { SidePanelPalette, INotebookPathOpener, defaultNotebookPathOpener, - NotebookLayoutRestorer, + NotebookStateDB, } from '@jupyter-notebook/application'; import { jupyterIcon } from '@jupyter-notebook/ui-components'; @@ -216,7 +218,7 @@ const layoutRestorer: JupyterFrontEndPlugin = { const first = app.started; const registry = app.commands; - const restorer = new NotebookLayoutRestorer({ + const restorer = new LayoutRestorer({ connector: state, first, registry, @@ -228,7 +230,7 @@ const layoutRestorer: JupyterFrontEndPlugin = { // promise. void notebookShell.restoreLayout(restorer).then(() => { notebookShell.layoutModified.connect(() => { - void restorer.save(notebookShell.saveLayout()); + void restorer.save(notebookShell.saveLayout() as ILabShell.ILayout); }); }); }); @@ -623,7 +625,7 @@ const state: JupyterFrontEndPlugin = { const { workspaces } = serviceManager; const workspace = PageConfig.getOption('notebookPage'); const transform = new PromiseDelegate(); - const db = new StateDB({ transform: transform.promise }); + const db = new NotebookStateDB({ transform: transform.promise }); const save = new Debouncer(async () => { const id = workspace; const metadata = { id }; diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 6ed4af672f..9c8e74bb74 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -3,7 +3,7 @@ export * from './app'; export * from './shell'; -export * from './layoutrestorer'; export * from './panelhandler'; export * from './pathopener'; +export * from './statedb'; export * from './tokens'; diff --git a/packages/application/src/layoutrestorer.ts b/packages/application/src/layoutrestorer.ts deleted file mode 100644 index 001086d36d..0000000000 --- a/packages/application/src/layoutrestorer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ILabShell, LayoutRestorer } from '@jupyterlab/application'; -import { WidgetTracker } from '@jupyterlab/apputils'; -import { IRestorer } from '@jupyterlab/statedb'; -import { Widget } from '@lumino/widgets'; -import { INotebookShell } from './shell'; - -export class NotebookLayoutRestorer extends LayoutRestorer { - // Override the restore function, that adds widget tracker state to the restorer. - // This is required to avoid trying to restore the main area widget, which leads to - // new page opening continuously. - async restore( - tracker: WidgetTracker, - options: IRestorer.IOptions - ): Promise { - // no-op as we don't want to restore widgets, only the layout. - } - - save(layout: INotebookShell.ILayout): Promise { - return super.save(layout as ILabShell.ILayout); - } -} diff --git a/packages/application/src/statedb.ts b/packages/application/src/statedb.ts new file mode 100644 index 0000000000..09257a1d33 --- /dev/null +++ b/packages/application/src/statedb.ts @@ -0,0 +1,33 @@ +import { StateDB } from '@jupyterlab/statedb'; +import { + ReadonlyPartialJSONObject, + ReadonlyPartialJSONValue, +} from '@lumino/coreutils'; + +/** + * The default concrete implementation of a state database. + */ +export class NotebookStateDB extends StateDB { + constructor(options: StateDB.IOptions = {}) { + super(options); + this._originalSave = super.save.bind(this); + } + + // Override the save method to avoid saving the document widget (in main area). + // NOTE: restoring a document widget open a new tab. + async save(id: string, value: ReadonlyPartialJSONValue): Promise { + const data = (value as ReadonlyPartialJSONObject)[ + 'data' + ] as ReadonlyPartialJSONObject; + + // If data.path and data.factory are defined, the widget is a document widget, that + // we don't want to save in the layout restoration. + if (data?.['path'] && data?.['factory']) { + return; + } else { + this._originalSave(id, value); + } + } + + private _originalSave: typeof StateDB.prototype.save; +}