-
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
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 |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
|
|
||
| import { | ||
| ILabStatus, | ||
| ILayoutRestorer, | ||
| IRouter, | ||
| ITreePathUpdater, | ||
| JupyterFrontEnd, | ||
|
|
@@ -29,6 +30,8 @@ import { DocumentWidget } from '@jupyterlab/docregistry'; | |
|
|
||
| import { IMainMenu } from '@jupyterlab/mainmenu'; | ||
|
|
||
| import { NotebookPanel } from '@jupyterlab/notebook'; | ||
|
|
||
| import { | ||
| ILatexTypesetter, | ||
| IMarkdownParser, | ||
|
|
@@ -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, | ||
| NotebookLayoutRestorer, | ||
| } 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,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'; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -176,6 +194,54 @@ 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 NotebookLayoutRestorer({ | ||
| connector: state, | ||
| first, | ||
| registry, | ||
| }); | ||
|
|
||
| // Restore the layout when the main widget is loaded. | ||
| void notebookShell.mainWidgetLoaded.then(() => { | ||
| // Whether to actually restore the layout or not (not for the tree view). | ||
| const restoreLayout = | ||
| notebookShell.currentWidget instanceof NotebookPanel; | ||
|
|
||
| // Call the restorer even if the layout must not be restored, to resolve the | ||
| // promise. | ||
| void notebookShell.restoreLayout(restorer, restoreLayout).then(() => { | ||
| if (restoreLayout) { | ||
| notebookShell.layoutModified.connect(() => { | ||
| void restorer.save(notebookShell.saveLayout()); | ||
| }); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| return restorer; | ||
| }, | ||
| autoStart: true, | ||
| provides: ILayoutRestorer, | ||
| }; | ||
|
|
||
| /** | ||
| * The logo plugin. | ||
| */ | ||
|
|
@@ -491,7 +557,7 @@ const shell: JupyterFrontEndPlugin<INotebookShell> = { | |
| const customLayout = settings.composite['layout'] as any; | ||
|
|
||
| // Restore the layout. | ||
| void notebookShell.restoreLayout(customLayout); | ||
| void notebookShell.restoreLayoutConf(customLayout); | ||
| }) | ||
| .catch((reason) => { | ||
| console.error('Fail to load settings for the layout restorer.'); | ||
|
|
@@ -532,6 +598,171 @@ const splash: JupyterFrontEndPlugin<ISplashScreen> = { | |
| }, | ||
| }; | ||
|
|
||
| /** | ||
| * The default state database for storing application state. | ||
| * | ||
| * #### Notes | ||
| * If this extension is loaded with a window resolver, it will automatically add | ||
| * state management commands, URL support for `reset`, and workspace auto-saving. | ||
| * Otherwise, it will return a simple in-memory state database. | ||
| */ | ||
| const state: JupyterFrontEndPlugin<IStateDB> = { | ||
| id: '@jupyter-notebook/application-extension:state', | ||
| description: 'Provides the application state. It is stored per workspaces.', | ||
| autoStart: true, | ||
| provides: IStateDB, | ||
| requires: [IRouter, ITranslator], | ||
| activate: ( | ||
| app: JupyterFrontEnd, | ||
| router: IRouter, | ||
| translator: ITranslator | ||
| ) => { | ||
| const trans = translator.load('jupyterlab'); | ||
|
|
||
| let resolved = false; | ||
| const { commands, serviceManager } = app; | ||
| const { workspaces } = serviceManager; | ||
| const workspace = 'nb-default'; | ||
| const transform = new PromiseDelegate<StateDB.DataTransform>(); | ||
| const db = new StateDB({ transform: transform.promise }); | ||
| const save = new Debouncer(async () => { | ||
| const id = workspace; | ||
| const metadata = { id }; | ||
| const data = await db.toJSON(); | ||
| await workspaces.save(id, { data, metadata }); | ||
| }); | ||
|
|
||
| // Any time the local state database changes, save the workspace. | ||
| db.changed.connect(() => void save.invoke(), db); | ||
|
|
||
| commands.addCommand(CommandIDs.loadState, { | ||
| label: trans.__('Load state for the current workspace.'), | ||
| describedBy: { | ||
| args: { | ||
| type: 'object', | ||
| properties: { | ||
| hash: { | ||
| type: 'string', | ||
| description: trans.__('The URL hash'), | ||
| }, | ||
| path: { | ||
| type: 'string', | ||
| description: trans.__('The URL path'), | ||
| }, | ||
| search: { | ||
| type: 'string', | ||
| description: trans.__( | ||
| 'The URL search string containing query parameters' | ||
| ), | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| execute: async (args) => { | ||
| // Since the command can be executed an arbitrary number of times, make | ||
| // sure it is safe to call multiple times. | ||
| if (resolved) { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const saved = await workspaces.fetch(workspace); | ||
|
|
||
| // If this command is called after a reset, the state database | ||
| // will already be resolved. | ||
| if (!resolved) { | ||
| resolved = true; | ||
| transform.resolve({ type: 'overwrite', contents: saved.data }); | ||
| } | ||
| } catch { | ||
| console.warn(`Fetching workspace "${workspace}" failed.`); | ||
|
|
||
| // If the workspace does not exist, cancel the data transformation | ||
| // and save a workspace with the current user state data. | ||
| if (!resolved) { | ||
| resolved = true; | ||
| transform.resolve({ type: 'cancel', contents: null }); | ||
| } | ||
| } | ||
|
|
||
| // After the state database has finished loading, save it. | ||
| await save.invoke(); | ||
| }, | ||
| }); | ||
|
|
||
| commands.addCommand(CommandIDs.resetOnLoad, { | ||
|
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; | ||
| }, | ||
| }); | ||
|
|
||
| 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 +1420,7 @@ const zen: JupyterFrontEndPlugin<void> = { | |
| const plugins: JupyterFrontEndPlugin<any>[] = [ | ||
| dirty, | ||
| info, | ||
| layoutRestorer, | ||
| logo, | ||
| menus, | ||
| menuSpacer, | ||
|
|
@@ -1201,6 +1433,7 @@ const plugins: JupyterFrontEndPlugin<any>[] = [ | |
| sidePanelVisibility, | ||
| shortcuts, | ||
| splash, | ||
| state, | ||
| status, | ||
| tabTitle, | ||
| title, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { LayoutRestorer } from '@jupyterlab/application'; | ||
| import { WidgetTracker } from '@jupyterlab/apputils'; | ||
| import { IRestorer } from '@jupyterlab/statedb'; | ||
| import { Widget } from '@lumino/widgets'; | ||
|
|
||
| export class NotebookLayoutRestorer extends LayoutRestorer { | ||
| // Override the restore function, that adds widget tracker state to the restorer. | ||
| async restore( | ||
| tracker: WidgetTracker, | ||
| options: IRestorer.IOptions<Widget> | ||
| ): Promise<any> { | ||
| // no-op as we don't want to restore widgets, only the layout. | ||
| } | ||
| } |
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.