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 1367cf161a..79adc1424d 100644 --- a/packages/application-extension/src/index.ts +++ b/packages/application-extension/src/index.ts @@ -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 { @@ -40,6 +43,8 @@ import { import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { IStateDB, StateDB } from '@jupyterlab/statedb'; + import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { @@ -51,6 +56,7 @@ import { SidePanelPalette, INotebookPathOpener, defaultNotebookPathOpener, + NotebookStateDB, } from '@jupyter-notebook/application'; import { jupyterIcon } from '@jupyter-notebook/ui-components'; @@ -63,6 +69,8 @@ import { IDisposable, } from '@lumino/disposable'; +import { Debouncer } from '@lumino/polling'; + import { Menu, Widget } from '@lumino/widgets'; /** @@ -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'; } /** @@ -176,6 +199,48 @@ 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) { + 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. */ @@ -491,7 +556,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.'); @@ -532,6 +597,202 @@ 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 `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: [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(); + 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, { + 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. */ @@ -1189,6 +1450,7 @@ const zen: JupyterFrontEndPlugin = { const plugins: JupyterFrontEndPlugin[] = [ dirty, info, + layoutRestorer, logo, menus, menuSpacer, @@ -1201,6 +1463,7 @@ const plugins: JupyterFrontEndPlugin[] = [ sidePanelVisibility, shortcuts, splash, + state, status, tabTitle, title, diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index c726fb4561..9c8e74bb74 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -5,4 +5,5 @@ export * from './app'; export * from './shell'; export * from './panelhandler'; export * from './pathopener'; +export * from './statedb'; export * from './tokens'; diff --git a/packages/application/src/panelhandler.ts b/packages/application/src/panelhandler.ts index 525b62bb04..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._panel.isVisible; + return (this._currentWidget?.isVisible || false) && this._panel.isVisible; } /** @@ -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.visible) { + if (data.currentWidget) { + this.activate(data.currentWidget.id); + } + 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..6676b4c17d 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 { @@ -17,8 +19,7 @@ import { TabPanel, Widget, } from '@lumino/widgets'; -import { PanelHandler, SidePanelHandler } from './panelhandler'; -import { TabPanelSvg } from '@jupyterlab/ui-components'; +import { PanelHandler, SidePanel, SidePanelHandler } from './panelhandler'; /** * The Jupyter Notebook application shell token. @@ -64,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; + } } /** @@ -120,6 +148,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 +168,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 +181,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(); @@ -242,25 +275,40 @@ 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; } /** - * Promise that resolves when the main widget is loaded + * A signal emitting when the layout changed. */ - get restored(): Promise { + get layoutModified(): ISignal { + 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. + */ + get restored(): Promise { + return Promise.all([ + this._mainWidgetLoaded.promise, + this._restored.promise, + ]).then((res) => undefined); + } + /** * Getter and setter for the translator. */ @@ -432,6 +480,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 +489,7 @@ export class NotebookShell extends Widget implements JupyterFrontEnd.IShell { collapseLeft(): void { this._leftHandler.collapse(); this._leftHandler.panel.hide(); + this._onLayoutModified(); } /** @@ -448,6 +498,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 +507,142 @@ 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): Promise { + // Get the layout from the restorer + const layout = await layoutRestorer.fetch(); + + // Reset the layout + const { downArea, leftArea, relativeSizes, rightArea, topArea } = 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 !== -1) { + 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); + } 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(); + } + + // Restore the top area visibility. + if (topArea) { + const { simpleVisibility } = topArea; + if (simpleVisibility) { + this._topWrapper.setHidden(false); + } + } else { + this._topWrapper.setHidden(true); + } + + // 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(): INotebookShell.ILayout { + 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(), + topArea: { + simpleVisibility: this.top.isVisible, + }, + }; + return layout; + } + /** * Handle a change on the down panel widgets */ @@ -474,8 +650,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 +667,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 +757,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/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; +} diff --git a/ui-tests/test/mobile.spec.ts b/ui-tests/test/mobile.spec.ts index c4979f6795..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'; @@ -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, diff --git a/yarn.lock b/yarn.lock index 60006dfddd..4355e37763 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