Ver Fonte

Merge pull request #1242 from afshin/state

Implement a way to save component state that persists after refresh.
Steven Silvester há 8 anos atrás
pai
commit
0140441479

+ 1 - 0
examples/lab/index.js

@@ -37,6 +37,7 @@ lab.registerPlugins([
   require('jupyterlab/lib/running/plugin').runningSessionsExtension,
   require('jupyterlab/lib/services/plugin').servicesProvider,
   require('jupyterlab/lib/shortcuts/plugin').shortcutsExtension,
+  require('jupyterlab/lib/statedb/plugin').stateProvider,
   require('jupyterlab/lib/terminal/plugin').terminalExtension
 ]);
 

+ 1 - 0
jupyterlab/extensions.js

@@ -27,5 +27,6 @@ module.exports = [
   require('../lib/running/plugin').runningSessionsExtension,
   require('../lib/services/plugin').servicesProvider,
   require('../lib/shortcuts/plugin').shortcutsExtension,
+  require('../lib/statedb/plugin').stateProvider,
   require('../lib/terminal/plugin').terminalExtension
 ];

+ 27 - 0
src/application/index.ts

@@ -1,6 +1,10 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
+import {
+  utils
+} from '@jupyterlab/services';
+
 import {
   Application
 } from 'phosphor/lib/ui/application';
@@ -22,10 +26,33 @@ type JupyterLabPlugin<T> = Application.IPlugin<JupyterLab, T>;
  */
 export
 class JupyterLab extends Application<ApplicationShell> {
+  /**
+   * A promise that resolves when the JupyterLab application is started.
+   */
+  get started(): Promise<void> {
+    return this._startedDelegate.promise;
+  }
+
+  /**
+   * Start the JupyterLab application.
+   */
+  start(options: Application.IStartOptions = {}): Promise<void> {
+    if (this._startedFlag) {
+      return Promise.resolve(void 0);
+    }
+    this._startedFlag = true;
+    return super.start(options).then(() => {
+      this._startedDelegate.resolve(void 0);
+    });
+  }
+
   /**
    * Create the application shell for the JupyterLab application.
    */
   protected createShell(): ApplicationShell {
     return new ApplicationShell();
   }
+
+  private _startedDelegate = new utils.PromiseDelegate<void>();
+  private _startedFlag = false;
 }

+ 23 - 9
src/console/plugin.ts

@@ -5,10 +5,6 @@ import {
   ContentsManager, Kernel, Session, utils
 } from '@jupyterlab/services';
 
-import {
-  find
-} from 'phosphor/lib/algorithm/searching';
-
 import {
   JSONObject
 } from 'phosphor/lib/algorithm/json';
@@ -57,6 +53,10 @@ import {
   IServiceManager
 } from '../services';
 
+import {
+  IStateDB
+} from '../statedb';
+
 import {
   IConsoleTracker, ConsolePanel, ConsoleContent
 } from './index';
@@ -76,7 +76,8 @@ const consoleTrackerProvider: JupyterLabPlugin<IConsoleTracker> = {
     IInspector,
     ICommandPalette,
     IPathTracker,
-    ConsoleContent.IRenderer
+    ConsoleContent.IRenderer,
+    IStateDB
   ],
   activate: activateConsole,
   autoStart: true
@@ -98,6 +99,11 @@ const CONSOLE_ICON_CLASS = 'jp-ImageCodeConsole';
  */
 const CONSOLE_REGEX = /^console-(\d)+-[0-9a-f]+$/;
 
+/**
+ * The console plugin state namespace.
+ */
+const NAMESPACE = 'consoles';
+
 /**
  * The console panel instance tracker.
  */
@@ -118,7 +124,7 @@ interface ICreateConsoleArgs extends JSONObject {
 /**
  * Activate the console extension.
  */
-function activateConsole(app: JupyterLab, services: IServiceManager, rendermime: IRenderMime, mainMenu: IMainMenu, inspector: IInspector, palette: ICommandPalette, pathTracker: IPathTracker, renderer: ConsoleContent.IRenderer): IConsoleTracker {
+function activateConsole(app: JupyterLab, services: IServiceManager, rendermime: IRenderMime, mainMenu: IMainMenu, inspector: IInspector, palette: ICommandPalette, pathTracker: IPathTracker, renderer: ConsoleContent.IRenderer, state: IStateDB): IConsoleTracker {
   let manager = services.sessions;
 
   let { commands, keymap } = app;
@@ -328,9 +334,7 @@ function activateConsole(app: JupyterLab, services: IServiceManager, rendermime:
    */
   function createConsole(session: Session.ISession, name: string): void {
     let panel = new ConsolePanel({
-      session,
-      rendermime: rendermime.clone(),
-      renderer: renderer
+      session, rendermime: rendermime.clone(), renderer
     });
     let specs = manager.specs;
     let displayName = specs.kernelspecs[session.kernel.name].display_name;
@@ -366,8 +370,18 @@ function activateConsole(app: JupyterLab, services: IServiceManager, rendermime:
     inspector.source = panel.content.inspectionHandler;
     // Add the console panel to the tracker.
     tracker.add(panel);
+    // Add the console to the state database.
+    let key = `${NAMESPACE}:${session.id}`;
+    state.save(key, session.id);
+    // Remove the console from the state database on disposal.
+    panel.disposed.connect(() => { state.remove(key); });
   }
 
+  // Reload any consoles whose state has been stored.
+  Promise.all([state.fetchNamespace(NAMESPACE), app.started]).then(([ids]) => {
+    ids.forEach(id => { app.commands.execute('console:open', { id }); });
+  });
+
   command = 'console:switch-kernel';
   commands.addCommand(command, {
     label: 'Switch Kernel',

+ 31 - 3
src/notebook/plugin.ts

@@ -38,6 +38,10 @@ import {
   IServiceManager
 } from '../services';
 
+import {
+  IStateDB
+} from '../statedb';
+
 import {
   INotebookTracker, NotebookModelFactory, NotebookPanel, NotebookTracker,
   NotebookWidgetFactory, NotebookActions
@@ -54,6 +58,11 @@ const PORTRAIT_ICON_CLASS = 'jp-MainAreaPortraitIcon';
  */
 const NOTEBOOK_ICON_CLASS = 'jp-ImageNotebook';
 
+/**
+ * The notebook plugin state namespace.
+ */
+const NAMESPACE = 'notebooks';
+
 /**
  * The notebook instance tracker.
  */
@@ -121,7 +130,8 @@ const notebookTrackerProvider: JupyterLabPlugin<INotebookTracker> = {
     IMainMenu,
     ICommandPalette,
     IInspector,
-    NotebookPanel.IRenderer
+    NotebookPanel.IRenderer,
+    IStateDB
   ],
   activate: activateNotebookHandler,
   autoStart: true
@@ -131,7 +141,7 @@ const notebookTrackerProvider: JupyterLabPlugin<INotebookTracker> = {
 /**
  * Activate the notebook handler extension.
  */
-function activateNotebookHandler(app: JupyterLab, registry: IDocumentRegistry, services: IServiceManager, rendermime: IRenderMime, clipboard: IClipboard, mainMenu: IMainMenu, palette: ICommandPalette, inspector: IInspector, renderer: NotebookPanel.IRenderer): INotebookTracker {
+function activateNotebookHandler(app: JupyterLab, registry: IDocumentRegistry, services: IServiceManager, rendermime: IRenderMime, clipboard: IClipboard, mainMenu: IMainMenu, palette: ICommandPalette, inspector: IInspector, renderer: NotebookPanel.IRenderer, state: IStateDB): INotebookTracker {
   let widgetFactory = new NotebookWidgetFactory({
     name: 'Notebook',
     fileExtensions: ['.ipynb'],
@@ -180,7 +190,25 @@ function activateNotebookHandler(app: JupyterLab, registry: IDocumentRegistry, s
     inspector.source = widget.content.inspectionHandler;
     // Add the notebook panel to the tracker.
     tracker.add(widget);
-  });
+    // Add the notebook path to the state database.
+    let key = `${NAMESPACE}:${widget.context.path}`;
+    state.save(key, widget.context.path);
+    // Remove the notebook path from the state database on disposal.
+    widget.disposed.connect(() => { state.remove(key); });
+    // Keep track of path changes in the state database.
+    widget.context.pathChanged.connect((sender, path) => {
+      state.remove(key);
+      key = `${NAMESPACE}:${path}`;
+      state.save(key, path);
+    });
+  });
+
+  // Reload any notebooks whose state has been stored.
+  Promise.all([state.fetchNamespace(NAMESPACE), app.started])
+    .then(([paths]) => {
+      let open = 'file-operations:open';
+      paths.forEach(path => { app.commands.execute(open, { path }); });
+    });
 
   // Add main menu notebook menu.
   mainMenu.addMenu(createMenu(app), { rank: 20 });

+ 95 - 0
src/statedb/index.ts

@@ -0,0 +1,95 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  JSONValue
+} from 'phosphor/lib/algorithm/json';
+
+import {
+  Token
+} from 'phosphor/lib/core/token';
+
+
+/* tslint:disable */
+/**
+ * The default state database token.
+ */
+export
+const IStateDB = new Token<IStateDB>('jupyter.services.statedb');
+/* tslint:enable */
+
+
+/**
+ * The description of a state database.
+ */
+export
+interface IStateDB {
+  /**
+   * The maximum allowed length of the data after it has been serialized.
+   */
+  readonly maxLength: number;
+
+  /**
+   * Retrieve a saved bundle from the database.
+   *
+   * @param id - The identifier used to save retrieve a data bundle.
+   *
+   * @returns A promise that bears a data payload if available.
+   *
+   * #### Notes
+   * The `id` values of stored items in the state database are formatted:
+   * `'namespace:identifier'`, which is the same convention that command
+   * identifiers in JupyterLab use as well. While this is not a technical
+   * requirement for `fetch()`, `remove()`, and `save()`, it *is* necessary for
+   * using the `fetchNamespace()` method.
+   *
+   * The promise returned by this method may be rejected if an error occurs in
+   * retrieving the data. Non-existence of an `id` will succeed, however.
+   */
+  fetch(id: string): Promise<JSONValue>;
+
+  /**
+   * Retrieve all the saved bundles for a namespace.
+   *
+   * @param namespace - The namespace to retrieve.
+   *
+   * @returns A promise that bears a collection data payloads for a namespace.
+   *
+   * #### Notes
+   * Namespaces are entirely conventional entities. The `id` values of stored
+   * items in the state database are formatted: `'namespace:identifier'`, which
+   * is the same convention that command identifiers in JupyterLab use as well.
+   *
+   * If there are any errors in retrieving the data, they will be logged to the
+   * console in order to optimistically return any extant data without failing.
+   * This promise will always succeed.
+   */
+  fetchNamespace(namespace: string): Promise<JSONValue[]>;
+
+  /**
+   * Remove a value from the database.
+   *
+   * @param id - The identifier for the data being removed.
+   *
+   * @returns A promise that is rejected if remove fails and succeeds otherwise.
+   */
+  remove(id: string): Promise<void>;
+
+  /**
+   * Save a value in the database.
+   *
+   * @param id - The identifier for the data being saved.
+   *
+   * @param data - The data being saved.
+   *
+   * @returns A promise that is rejected if saving fails and succeeds otherwise.
+   *
+   * #### Notes
+   * The `id` values of stored items in the state database are formatted:
+   * `'namespace:identifier'`, which is the same convention that command
+   * identifiers in JupyterLab use as well. While this is not a technical
+   * requirement for `fetch()`, `remove()`, and `save()`, it *is* necessary for
+   * using the `fetchNamespace()` method.
+   */
+  save(id: string, data: JSONValue): Promise<void>;
+}

+ 136 - 0
src/statedb/plugin.ts

@@ -0,0 +1,136 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  JSONValue
+} from 'phosphor/lib/algorithm/json';
+
+import {
+  JupyterLabPlugin
+} from '../application';
+
+import {
+  IStateDB
+} from './index';
+
+
+/**
+ * The default state database for storing application state.
+ */
+export
+const stateProvider: JupyterLabPlugin<IStateDB> = {
+  id: 'jupyter.services.statedb',
+  activate: (): IStateDB => new StateDB(),
+  autoStart: true,
+  provides: IStateDB
+};
+
+
+/**
+ * The default concrete implementation of a state database.
+ */
+class StateDB implements IStateDB {
+  /**
+   * The maximum allowed length of the data after it has been serialized.
+   */
+  readonly maxLength = 2000;
+
+  /**
+   * Retrieve a saved bundle from the database.
+   *
+   * @param id - The identifier used to save retrieve a data bundle.
+   *
+   * @returns A promise that bears a data payload if available.
+   *
+   * #### Notes
+   * The `id` values of stored items in the state database are formatted:
+   * `'namespace:identifier'`, which is the same convention that command
+   * identifiers in JupyterLab use as well. While this is not a technical
+   * requirement for `fetch()`, `remove()`, and `save()`, it *is* necessary for
+   * using the `fetchNamespace()` method.
+   *
+   * The promise returned by this method may be rejected if an error occurs in
+   * retrieving the data. Non-existence of an `id` will succeed, however.
+   */
+  fetch(id: string): Promise<JSONValue> {
+    try {
+      return Promise.resolve(JSON.parse(window.localStorage.getItem(id)));
+    } catch (error) {
+      return Promise.reject(error);
+    }
+  }
+
+  /**
+   * Retrieve all the saved bundles for a namespace.
+   *
+   * @param namespace - The namespace to retrieve.
+   *
+   * @returns A promise that bears a collection data payloads for a namespace.
+   *
+   * #### Notes
+   * Namespaces are entirely conventional entities. The `id` values of stored
+   * items in the state database are formatted: `'namespace:identifier'`, which
+   * is the same convention that command identifiers in JupyterLab use as well.
+   *
+   * If there are any errors in retrieving the data, they will be logged to the
+   * console in order to optimistically return any extant data without failing.
+   * This promise will always succeed.
+   */
+  fetchNamespace(namespace: string): Promise<JSONValue[]> {
+    let data: JSONValue[] = [];
+    for (let i = 0, len = window.localStorage.length; i < len; i++) {
+      let key = window.localStorage.key(i);
+      if (key.indexOf(`${namespace}:`) === 0) {
+        try {
+          data.push(JSON.parse(window.localStorage.getItem(key)));
+        } catch (error) {
+          console.warn(error);
+        }
+      }
+    }
+    return Promise.resolve(data);
+  }
+
+  /**
+   * Remove a value from the database.
+   *
+   * @param id - The identifier for the data being removed.
+   *
+   * @returns A promise that is rejected if remove fails and succeeds otherwise.
+   */
+  remove(id: string): Promise<void> {
+    window.localStorage.removeItem(id);
+    return Promise.resolve(void 0);
+  }
+
+  /**
+   * Save a value in the database.
+   *
+   * @param id - The identifier for the data being saved.
+   *
+   * @param data - The data being saved.
+   *
+   * @returns A promise that is rejected if saving fails and succeeds otherwise.
+   *
+   * #### Notes
+   * The `id` values of stored items in the state database are formatted:
+   * `'namespace:identifier'`, which is the same convention that command
+   * identifiers in JupyterLab use as well. While this is not a technical
+   * requirement for `fetch()`, `remove()`, and `save()`, it *is* necessary for
+   * using the `fetchNamespace()` method.
+   */
+  save(id: string, data: JSONValue): Promise<void> {
+    try {
+      let serialized = JSON.stringify(data);
+      let length = serialized.length;
+      let max = this.maxLength;
+      if (length > max) {
+        throw new Error(`Serialized data (${length}) exceeds maximum (${max})`);
+      }
+      window.localStorage.setItem(id, serialized);
+      return Promise.resolve(void 0);
+    } catch (error) {
+      return Promise.reject(error);
+    }
+  }
+}

+ 31 - 3
src/terminal/plugin.ts

@@ -29,6 +29,10 @@ import {
   IServiceManager
 } from '../services';
 
+import {
+  IStateDB
+} from '../statedb';
+
 import {
   TerminalWidget
 } from './index';
@@ -44,6 +48,11 @@ const LANDSCAPE_ICON_CLASS = 'jp-MainAreaLandscapeIcon';
  */
 const TERMINAL_ICON_CLASS = 'jp-ImageTerminal';
 
+/**
+ * The terminal plugin state namespace.
+ */
+const NAMESPACE = 'terminals';
+
 /**
  * The terminal widget instance tracker.
  */
@@ -56,13 +65,13 @@ const tracker = new InstanceTracker<TerminalWidget>();
 export
 const terminalExtension: JupyterLabPlugin<void> = {
   id: 'jupyter.extensions.terminal',
-  requires: [IServiceManager, IMainMenu, ICommandPalette],
+  requires: [IServiceManager, IMainMenu, ICommandPalette, IStateDB],
   activate: activateTerminal,
   autoStart: true
 };
 
 
-function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu: IMainMenu, palette: ICommandPalette): void {
+function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu: IMainMenu, palette: ICommandPalette, state: IStateDB): void {
   let { commands, keymap } = app;
   let newTerminalId = 'terminal:create-new';
   let increaseTerminalFontSize = 'terminal:increase-font';
@@ -80,6 +89,7 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
     tracker.sync(args.newValue);
   });
 
+  // Add terminal commands.
   commands.addCommand(newTerminalId, {
     label: 'New Terminal',
     caption: 'Start a new terminal session',
@@ -97,9 +107,15 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
       } else {
         promise = services.terminals.startNew();
       }
-      promise.then(session => { term.session = session; });
+      promise.then(session => {
+        let key = `${NAMESPACE}:${session.name}`;
+        term.session = session;
+        state.save(key, session.name);
+        term.disposed.connect(() => { state.remove(key); });
+      });
     }
   });
+
   commands.addCommand(increaseTerminalFontSize, {
     label: 'Increase Terminal Font Size',
     execute: () => {
@@ -109,6 +125,7 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
       }
     }
   });
+
   commands.addCommand(decreaseTerminalFontSize, {
     label: 'Decrease Terminal Font Size',
     execute: () => {
@@ -118,6 +135,7 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
       }
     }
   });
+
   commands.addCommand(toggleTerminalTheme, {
     label: 'Toggle Terminal Theme',
     caption: 'Switch Terminal Background and Font Colors',
@@ -135,6 +153,7 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
       });
     }
   });
+
   commands.addCommand(openTerminalId, {
     execute: args => {
       let name = args['name'] as string;
@@ -149,6 +168,14 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
     }
   });
 
+  // Reload any terminals whose state has been stored.
+  Promise.all([state.fetchNamespace(NAMESPACE), app.started])
+    .then(([terms]) => {
+      let create = 'terminal:create-new';
+      terms.forEach(name => { app.commands.execute(create, { name }); });
+    });
+
+  // Add command palette items.
   let category = 'Terminal';
   [
     newTerminalId,
@@ -157,6 +184,7 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
     toggleTerminalTheme
   ].forEach(command => palette.addItem({ command, category }));
 
+  // Add menu items.
   let menu = new Menu({ commands, keymap });
   menu.title.label = 'Terminal';
   menu.addItem({ command: newTerminalId });