- 
                Notifications
    You must be signed in to change notification settings 
- Fork 5.5k
Add the layout restorer #7747
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add the layout restorer #7747
Changes from all commits
37c0316
              86fc3df
              b3bf861
              ef62c4f
              c1b056e
              60f4dc5
              40a6646
              eb8c08e
              2383609
              073417b
              757c0ef
              25821b5
              a5e4c8e
              b92df6e
              ef5a1dc
              2ec66fe
              1699d62
              7137789
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
|  | @@ -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<JupyterLab.IInfo> = { | |
| }, | ||
| }; | ||
|  | ||
| /** | ||
| * The default layout restorer provider. | ||
| */ | ||
| const layoutRestorer: JupyterFrontEndPlugin<ILayoutRestorer | null> = { | ||
| 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<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.'); | ||
|  | @@ -532,6 +597,202 @@ const splash: JupyterFrontEndPlugin<ISplashScreen> = { | |
| }, | ||
| }; | ||
|  | ||
| /** | ||
| * The default state database for storing application state. | ||
| * | ||
| * #### Notes | ||
| * If this extension is loaded with a window resolver, it will automatically add | ||
| * state management commands, URL support for `reset`, and workspace auto-saving. | ||
| * Otherwise, it will return a simple in-memory state database. | ||
| */ | ||
| const state: JupyterFrontEndPlugin<IStateDB> = { | ||
| id: '@jupyter-notebook/application-extension:state', | ||
| description: 'Provides the application state. It is stored per workspaces.', | ||
| autoStart: true, | ||
| provides: IStateDB, | ||
| requires: [IRouter, ITranslator], | ||
| optional: [ICommandPalette], | ||
| activate: ( | ||
| app: JupyterFrontEnd, | ||
| router: IRouter, | ||
| translator: ITranslator, | ||
| palette: ICommandPalette | null | ||
| ) => { | ||
| const trans = translator.load('jupyterlab'); | ||
|  | ||
| let resolved = false; | ||
| const { commands, serviceManager } = app; | ||
| const { workspaces } = serviceManager; | ||
| const workspace = PageConfig.getOption('notebookPage'); | ||
| const transform = new PromiseDelegate<StateDB.DataTransform>(); | ||
| const db = new NotebookStateDB({ transform: transform.promise }); | ||
| const save = new Debouncer(async () => { | ||
| const id = workspace; | ||
| const metadata = { id }; | ||
| const data = await db.toJSON(); | ||
| await workspaces.save(id, { data, metadata }); | ||
| }); | ||
|  | ||
| // Any time the local state database changes, save the workspace. | ||
| db.changed.connect(() => void save.invoke(), db); | ||
|  | ||
| commands.addCommand(CommandIDs.loadState, { | ||
| label: trans.__('Load state for the current workspace.'), | ||
| describedBy: { | ||
| args: { | ||
| type: 'object', | ||
| properties: { | ||
| hash: { | ||
| type: 'string', | ||
| description: trans.__('The URL hash'), | ||
| }, | ||
| path: { | ||
| type: 'string', | ||
| description: trans.__('The URL path'), | ||
| }, | ||
| search: { | ||
| type: 'string', | ||
| description: trans.__( | ||
| 'The URL search string containing query parameters' | ||
| ), | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| execute: async (args) => { | ||
| // Since the command can be executed an arbitrary number of times, make | ||
| // sure it is safe to call multiple times. | ||
| if (resolved) { | ||
| return; | ||
| } | ||
|  | ||
| try { | ||
| const saved = await workspaces.fetch(workspace); | ||
|  | ||
| // If this command is called after a reset, the state database | ||
| // will already be resolved. | ||
| if (!resolved) { | ||
| resolved = true; | ||
| transform.resolve({ type: 'overwrite', contents: saved.data }); | ||
| } | ||
| } catch { | ||
| console.warn(`Fetching workspace "${workspace}" failed.`); | ||
|  | ||
| // If the workspace does not exist, cancel the data transformation | ||
| // and save a workspace with the current user state data. | ||
| if (!resolved) { | ||
| resolved = true; | ||
| transform.resolve({ type: 'cancel', contents: null }); | ||
| } | ||
| } | ||
|  | ||
| // After the state database has finished loading, save it. | ||
| await save.invoke(); | ||
| }, | ||
| }); | ||
|  | ||
| commands.addCommand(CommandIDs.reset, { | ||
| label: trans.__('Reset Application State'), | ||
| describedBy: { | ||
| args: { | ||
| type: 'object', | ||
| properties: { | ||
| reload: { | ||
| type: 'boolean', | ||
| description: trans.__( | ||
| 'Whether to reload the page after resetting' | ||
| ), | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| execute: async () => { | ||
| await db.clear(); | ||
| await save.invoke(); | ||
|  | ||
| // Save the current document and reload. | ||
| await commands.execute('docmanager:save'); | ||
| router.reload(); | ||
| }, | ||
| }); | ||
|  | ||
| commands.addCommand(CommandIDs.resetOnLoad, { | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, sounds good. It could be in the  There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually we should probably create another command ( | ||
| 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<void> = { | |
| const plugins: JupyterFrontEndPlugin<any>[] = [ | ||
| dirty, | ||
| info, | ||
| layoutRestorer, | ||
| logo, | ||
| menus, | ||
| menuSpacer, | ||
|  | @@ -1201,6 +1463,7 @@ const plugins: JupyterFrontEndPlugin<any>[] = [ | |
| sidePanelVisibility, | ||
| shortcuts, | ||
| splash, | ||
| state, | ||
| status, | ||
| tabTitle, | ||
| title, | ||
|  | ||
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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-defaultin thestateplugin, but we can probably have a dedicated one according to the URL, or the main widget in the shell.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
with