Преглед на файлове

Merge pull request #1266 from afshin/state

Update instance tracker to allow for automatic save/restore behavior.
Steven Silvester преди 8 години
родител
ревизия
3eaf99f20c

+ 158 - 0
src/common/instancetracker.ts

@@ -1,18 +1,35 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
+import {
+  JSONObject
+} from 'phosphor/lib/algorithm/json';
+
 import {
   IDisposable
 } from 'phosphor/lib/core/disposable';
 
+import {
+  AttachedProperty
+} from 'phosphor/lib/core/properties';
+
 import {
   clearSignalData, defineSignal, ISignal
 } from 'phosphor/lib/core/signaling';
 
+import {
+  CommandRegistry
+} from 'phosphor/lib/ui/commandregistry';
+
 import {
   Widget
 } from 'phosphor/lib/ui/widget';
 
+import {
+  IStateDB
+} from '../statedb';
+
+
 
 /**
  * An object that tracks widget instances.
@@ -42,6 +59,24 @@ interface IInstanceTracker<T extends Widget> {
  */
 export
 class InstanceTracker<T extends Widget> implements IInstanceTracker<T>, IDisposable {
+  /**
+   * Create a new instance tracker.
+   */
+  constructor(options: InstanceTracker.IOptions<T> = {}) {
+    this._restore = options.restore;
+    if (this._restore) {
+      let { command, namespace, registry, state, when } = this._restore;
+      let promises = [state.fetchNamespace(namespace)].concat(when);
+      Promise.all(promises).then(([saved]) => {
+        saved.forEach(args => {
+          // Execute the command and if it fails, delete the state restore data.
+          registry.execute(command, args.value)
+            .catch(() => { state.remove(args.id); });
+        });
+      });
+    }
+  }
+
   /**
    * A signal emitted when the current widget changes.
    *
@@ -73,8 +108,32 @@ class InstanceTracker<T extends Widget> implements IInstanceTracker<T>, IDisposa
       return;
     }
     this._widgets.add(widget);
+
+    // Handle widget state restoration.
+    if (this._restore) {
+      let { namespace, state } = this._restore;
+      let widgetName = this._restore.name(widget);
+
+      if (widgetName) {
+        let name = `${namespace}:${widgetName}`;
+        Private.nameProperty.set(widget, name);
+        state.save(name, this._restore.args(widget));
+      }
+    }
+
+    // Handle widget disposal.
     widget.disposed.connect(() => {
       this._widgets.delete(widget);
+      // If restore data was saved, delete it from the database.
+      if (this._restore) {
+        let { state } = this._restore;
+        let name = Private.nameProperty.get(widget);
+
+        if (name) {
+          state.remove(name);
+        }
+      }
+      // If this was the last widget being disposed, emit null.
       if (!this._widgets.size) {
         this._currentWidget = null;
         this.onCurrentChanged();
@@ -131,6 +190,31 @@ class InstanceTracker<T extends Widget> implements IInstanceTracker<T>, IDisposa
     return this._widgets.has(widget as any);
   }
 
+  /**
+   * Save the restore data for a given widget.
+   */
+  save(widget: T): void {
+    if (!this._restore || !this.has(widget)) {
+      return;
+    }
+
+    let { namespace, state } = this._restore;
+    let widgetName = this._restore.name(widget);
+    let oldName = Private.nameProperty.get(widget);
+    let newName = widgetName ? `${namespace}:${widgetName}` : null;
+
+    if (oldName && oldName !== newName) {
+      state.remove(oldName);
+    }
+
+    // Set the name property irrespective of whether the new name is null.
+    Private.nameProperty.set(widget, newName);
+
+    if (newName) {
+      state.save(newName, this._restore.args(widget));
+    }
+  }
+
   /**
    * Syncs the state of the tracker with a widget known to have focus.
    *
@@ -145,6 +229,7 @@ class InstanceTracker<T extends Widget> implements IInstanceTracker<T>, IDisposa
     if (this.isDisposed) {
       return;
     }
+
     if (current && this._widgets.has(current as any)) {
       // If no state change needs to occur, just bail.
       if (this._currentWidget === current) {
@@ -155,6 +240,7 @@ class InstanceTracker<T extends Widget> implements IInstanceTracker<T>, IDisposa
       this.currentChanged.emit(this._currentWidget);
       return this._currentWidget;
     }
+
     return null;
   }
 
@@ -170,9 +256,81 @@ class InstanceTracker<T extends Widget> implements IInstanceTracker<T>, IDisposa
   }
 
   private _currentWidget: T = null;
+  private _restore: InstanceTracker.IRestoreOptions<T> = null;
   private _widgets = new Set<T>();
 }
 
 
 // Define the signals for the `InstanceTracker` class.
 defineSignal(InstanceTracker.prototype, 'currentChanged');
+
+
+/**
+ * A namespace for `InstanceTracker` statics.
+ */
+export
+namespace InstanceTracker {
+  /**
+   * The state restoration configuration options.
+   */
+  export
+  interface IRestoreOptions<T extends Widget> {
+    /**
+     * The command to execute when restoring instances.
+     */
+    command: string;
+
+    /**
+     * A function that returns the args needed to restore an instance.
+     */
+    args: (widget: T) => JSONObject;
+
+    /**
+     * A function that returns a unique persistent name for this instance.
+     */
+    name: (widget: T) => string;
+
+    /**
+     * The namespace to occupy in the state database for restoration data.
+     */
+    namespace: string;
+
+    /**
+     * The command registry which holds the restore command.
+     */
+    registry: CommandRegistry;
+
+    /**
+     * The state database instance.
+     */
+    state: IStateDB;
+
+    /**
+     * The point after which it is safe to restore state.
+     */
+    when: Promise<any> | Array<Promise<any>>;
+  }
+
+  /**
+   * The instance tracker constructor options.
+   */
+  export
+  interface IOptions<T extends Widget> {
+    /**
+     * The optional state restoration options.
+     */
+    restore?: IRestoreOptions<T>;
+  }
+}
+
+
+/*
+ * A namespace for private data.
+ */
+namespace Private {
+  /**
+   * An attached property for a widget's ID in the state database.
+   */
+  export
+  const nameProperty = new AttachedProperty<Widget, string>({ name: 'name' });
+}

+ 14 - 25
src/console/plugin.ts

@@ -104,11 +104,6 @@ const CONSOLE_REGEX = /^console-(\d)+-[0-9a-f]+$/;
  */
 const NAMESPACE = 'consoles';
 
-/**
- * The console panel instance tracker.
- */
-const tracker = new InstanceTracker<ConsolePanel>();
-
 
 /**
  * The arguments used to create a console.
@@ -129,11 +124,21 @@ function activateConsole(app: JupyterLab, services: IServiceManager, rendermime:
 
   let { commands, keymap } = app;
   let category = 'Console';
-
+  let command: string;
   let menu = new Menu({ commands, keymap });
-  menu.title.label = 'Console';
 
-  let command: string;
+  // Create an instance tracker for all console panels.
+  const tracker = new InstanceTracker<ConsolePanel>({
+    restore: {
+      state,
+      command: 'console:create',
+      args: panel => ({ id: panel.content.session.id }),
+      name: panel => panel.content.session && panel.content.session.id,
+      namespace: 'consoles',
+      when: [app.started, manager.ready],
+      registry: app.commands
+    }
+  });
 
   // Sync tracker and set the source of the code inspector.
   app.shell.currentChanged.connect((sender, args) => {
@@ -144,7 +149,7 @@ function activateConsole(app: JupyterLab, services: IServiceManager, rendermime:
   });
 
   // Set the main menu title.
-  menu.title.label = 'Console';
+  menu.title.label = category;
 
   command = 'console:create-new';
   commands.addCommand(command, {
@@ -370,24 +375,8 @@ 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, { id: 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(([items]) => {
-      items.forEach(item => {
-        app.commands.execute('console:create', item.value).catch(() => {
-          // Remove console from the state database if session does not exist.
-          state.remove(`${NAMESPACE}:${item.id}`);
-        });
-      });
-  });
-
   command = 'console:switch-kernel';
   commands.addCommand(command, {
     label: 'Switch Kernel',

+ 24 - 24
src/csvwidget/plugin.ts

@@ -5,6 +5,10 @@ import {
   JupyterLab, JupyterLabPlugin
 } from '../application';
 
+import {
+  InstanceTracker
+} from '../common/instancetracker';
+
 import {
   IDocumentRegistry
 } from '../docregistry';
@@ -14,14 +18,14 @@ import {
 } from '../statedb';
 
 import {
-  CSVWidgetFactory
+  CSVWidget, CSVWidgetFactory
 } from './widget';
 
 
 /**
- * The state database namespace for CSV widgets.
+ * The name of the factory that creates CSV widgets.
  */
-const NAMESPACE = 'csvwidgets';
+const FACTORY = 'Table';
 
 
 /**
@@ -29,7 +33,7 @@ const NAMESPACE = 'csvwidgets';
  */
 export
 const csvHandlerExtension: JupyterLabPlugin<void> = {
-  id: 'jupyter.extensions.csvHandler',
+  id: 'jupyter.extensions.csv-handler',
   requires: [IDocumentRegistry, IStateDB],
   activate: activateCSVWidget,
   autoStart: true
@@ -41,31 +45,27 @@ const csvHandlerExtension: JupyterLabPlugin<void> = {
  */
 function activateCSVWidget(app: JupyterLab, registry: IDocumentRegistry, state: IStateDB): void {
   const factory = new CSVWidgetFactory({
-    name: 'Table',
+    name: FACTORY,
     fileExtensions: ['.csv'],
     defaultFor: ['.csv']
   });
+  const tracker = new InstanceTracker<CSVWidget>({
+    restore: {
+      state,
+      command: 'file-operations:open',
+      args: widget => ({ path: widget.context.path, factory: FACTORY }),
+      name: widget => widget.context.path,
+      namespace: 'csvwidgets',
+      when: app.started,
+      registry: app.commands
+    }
+  });
 
   registry.addWidgetFactory(factory);
-
   factory.widgetCreated.connect((sender, widget) => {
-    // Add the CSV path to the state database.
-    let key = `${NAMESPACE}:${widget.context.path}`;
-    state.save(key, { path: widget.context.path });
-    // Remove the CSV 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 });
-    });
+    // Track the widget.
+    tracker.add(widget);
+    // Notify the instance tracker if restore data needs to update.
+    widget.context.pathChanged.connect(() => { tracker.save(widget); });
   });
-
-  // Reload any CSV widgets whose state has been stored.
-  Promise.all([state.fetchNamespace(NAMESPACE), app.started])
-    .then(([items]) => {
-      let open = 'file-operations:open';
-      items.forEach(item => { app.commands.execute(open, item.value); });
-    });
 }

+ 117 - 134
src/editorwidget/plugin.ts

@@ -62,9 +62,9 @@ const PORTRAIT_ICON_CLASS = 'jp-MainAreaPortraitIcon';
 const EDITOR_ICON_CLASS = 'jp-ImageTextEditor';
 
 /**
- * The state database namespace for editor widgets.
+ * The name of the factory that creates editor widgets.
  */
-const NAMESPACE = 'editorwidgets';
+const FACTORY = 'Editor';
 
 /**
  * The map of command ids used by the editor.
@@ -80,11 +80,6 @@ const cmdIds = {
   runCode: 'editor:run-code'
 };
 
-/**
- * The editor widget instance tracker.
- */
-const tracker = new InstanceTracker<EditorWidget>();
-
 
 /**
  * The editor handler extension.
@@ -103,33 +98,133 @@ const editorHandlerProvider: JupyterLabPlugin<IEditorTracker> = {
  * Sets up the editor widget
  */
 function activateEditorHandler(app: JupyterLab, registry: IDocumentRegistry, mainMenu: IMainMenu, palette: ICommandPalette, state: IStateDB): IEditorTracker {
-  let widgetFactory = new EditorWidgetFactory({
-    name: 'Editor',
+  const factory = new EditorWidgetFactory({
+    name: FACTORY,
     fileExtensions: ['*'],
     defaultFor: ['*']
   });
+  const tracker = new InstanceTracker<EditorWidget>({
+    restore: {
+      state,
+      command: 'file-operations:open',
+      args: widget => ({ path: widget.context.path, factory: FACTORY }),
+      name: widget => widget.context.path,
+      namespace: 'editors',
+      when: app.started,
+      registry: app.commands
+    }
+  });
 
   // Sync tracker with currently focused widget.
   app.shell.currentChanged.connect((sender, args) => {
     tracker.sync(args.newValue);
   });
 
-  widgetFactory.widgetCreated.connect((sender, widget) => {
+  factory.widgetCreated.connect((sender, widget) => {
     widget.title.icon = `${PORTRAIT_ICON_CLASS} ${EDITOR_ICON_CLASS}`;
-    // Add the file path to the state database.
-    let key = `${NAMESPACE}:${widget.context.path}`;
-    state.save(key, { path: widget.context.path });
-    // Remove the file 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 });
-    });
+    // Notify the instance tracker if restore data needs to update.
+    widget.context.pathChanged.connect(() => { tracker.save(widget); });
     tracker.add(widget);
   });
-  registry.addWidgetFactory(widgetFactory);
+  registry.addWidgetFactory(factory);
+
+  /**
+   * An attached property for the session id associated with an editor widget.
+   */
+  const sessionIdProperty = new AttachedProperty<EditorWidget, string>({
+    name: 'sessionId'
+  });
+
+  /**
+   * Toggle editor line numbers
+   */
+  function toggleLineNums() {
+    if (tracker.currentWidget) {
+      let editor = tracker.currentWidget.editor;
+      editor.setOption('lineNumbers', !editor.getOption('lineNumbers'));
+    }
+  }
+
+  /**
+   * Toggle editor line wrap
+   */
+  function toggleLineWrap() {
+    if (tracker.currentWidget) {
+      let editor = tracker.currentWidget.editor;
+      editor.setOption('lineWrapping', !editor.getOption('lineWrapping'));
+    }
+  }
+
+  /**
+   * Toggle editor matching brackets
+   */
+  function toggleMatchBrackets() {
+    if (tracker.currentWidget) {
+      let editor = tracker.currentWidget.editor;
+      editor.setOption('matchBrackets', !editor.getOption('matchBrackets'));
+    }
+  }
+
+  /**
+   * Toggle the editor's vim mode
+   */
+  function toggleVim() {
+    tracker.forEach(widget => {
+      let keymap = widget.editor.getOption('keyMap') === 'vim' ? 'default'
+        : 'vim';
+      widget.editor.setOption('keyMap', keymap);
+    });
+  }
+
+  /**
+   * Close all currently open text editor files
+   */
+  function closeAllFiles() {
+    tracker.forEach(widget => { widget.close(); });
+  }
+
+  /**
+   * Create a menu for the editor.
+   */
+  function createMenu(app: JupyterLab): Menu {
+    let { commands, keymap } = app;
+    let settings = new Menu({ commands, keymap });
+    let theme = new Menu({ commands, keymap });
+    let menu = new Menu({ commands, keymap });
+
+    menu.title.label = 'Editor';
+    settings.title.label = 'Settings';
+    theme.title.label = 'Theme';
+
+    settings.addItem({ command: cmdIds.lineNumbers });
+    settings.addItem({ command: cmdIds.lineWrap });
+    settings.addItem({ command: cmdIds.matchBrackets });
+    settings.addItem({ command: cmdIds.vimMode });
+
+    commands.addCommand(cmdIds.changeTheme, {
+      label: args => args['theme'] as string,
+      execute: args => {
+        let name: string = args['theme'] as string || DEFAULT_CODEMIRROR_THEME;
+        tracker.forEach(widget => { widget.editor.setOption('theme', name); });
+      }
+    });
+
+    [
+     'jupyter', 'default', 'abcdef', 'base16-dark', 'base16-light',
+     'hopscotch', 'material', 'mbo', 'mdn-like', 'seti', 'the-matrix',
+     'xq-light', 'zenburn'
+    ].forEach(name => theme.addItem({
+      command: 'editor:change-theme',
+      args: { theme: name }
+    }));
+
+    menu.addItem({ command: cmdIds.closeAll });
+    menu.addItem({ type: 'separator' });
+    menu.addItem({ type: 'submenu', menu: settings });
+    menu.addItem({ type: 'submenu', menu: theme });
+
+    return menu;
+  }
 
   mainMenu.addMenu(createMenu(app), {rank: 30});
 
@@ -210,117 +305,5 @@ function activateEditorHandler(app: JupyterLab, registry: IDocumentRegistry, mai
     cmdIds.runCode,
   ].forEach(command => palette.addItem({ command, category: 'Editor' }));
 
-  // Reload any editor widgets whose state has been stored.
-  Promise.all([state.fetchNamespace(NAMESPACE), app.started])
-    .then(([items]) => {
-      let open = 'file-operations:open';
-      items.forEach(item => { app.commands.execute(open, item.value); });
-    });
-
   return tracker;
 }
-
-
-/**
- * An attached property for the session id associated with an editor widget.
- */
-const sessionIdProperty = new AttachedProperty<EditorWidget, string>({ name: 'sessionId' });
-
-
-/**
- * Toggle editor line numbers
- */
-function toggleLineNums() {
-  if (tracker.currentWidget) {
-    let editor = tracker.currentWidget.editor;
-    editor.setOption('lineNumbers', !editor.getOption('lineNumbers'));
-  }
-}
-
-
-/**
- * Toggle editor line wrap
- */
-function toggleLineWrap() {
-  if (tracker.currentWidget) {
-    let editor = tracker.currentWidget.editor;
-    editor.setOption('lineWrapping', !editor.getOption('lineWrapping'));
-  }
-}
-
-
-/**
- * Toggle editor matching brackets
- */
-function toggleMatchBrackets() {
-  if (tracker.currentWidget) {
-    let editor = tracker.currentWidget.editor;
-    editor.setOption('matchBrackets', !editor.getOption('matchBrackets'));
-  }
-}
-
-
-/**
- * Toggle the editor's vim mode
- */
-function toggleVim() {
-  tracker.forEach(widget => {
-    let keymap = widget.editor.getOption('keyMap') === 'vim' ? 'default'
-      : 'vim';
-    widget.editor.setOption('keyMap', keymap);
-  });
-}
-
-
-/**
- * Close all currently open text editor files
- */
-function closeAllFiles() {
-  tracker.forEach(widget => { widget.close(); });
-}
-
-
-/**
- * Create a menu for the editor.
- */
-function createMenu(app: JupyterLab): Menu {
-  let { commands, keymap } = app;
-  let settings = new Menu({ commands, keymap });
-  let theme = new Menu({ commands, keymap });
-  let menu = new Menu({ commands, keymap });
-
-  menu.title.label = 'Editor';
-  settings.title.label = 'Settings';
-  theme.title.label = 'Theme';
-
-  settings.addItem({ command: cmdIds.lineNumbers });
-  settings.addItem({ command: cmdIds.lineWrap });
-  settings.addItem({ command: cmdIds.matchBrackets });
-  settings.addItem({ command: cmdIds.vimMode });
-
-  commands.addCommand(cmdIds.changeTheme, {
-    label: args => {
-      return args['theme'] as string;
-    },
-    execute: args => {
-      let name: string = args['theme'] as string || DEFAULT_CODEMIRROR_THEME;
-      tracker.forEach(widget => { widget.editor.setOption('theme', name); });
-    }
-  });
-
-  [
-   'jupyter', 'default', 'abcdef', 'base16-dark', 'base16-light',
-   'hopscotch', 'material', 'mbo', 'mdn-like', 'seti', 'the-matrix',
-   'xq-light', 'zenburn'
-  ].forEach(name => theme.addItem({
-    command: 'editor:change-theme',
-    args: { theme: name }
-  }));
-
-  menu.addItem({ command: cmdIds.closeAll });
-  menu.addItem({ type: 'separator' });
-  menu.addItem({ type: 'submenu', menu: settings });
-  menu.addItem({ type: 'submenu', menu: theme });
-
-  return menu;
-}

+ 17 - 12
src/filebrowser/plugin.ts

@@ -129,24 +129,28 @@ function activateFileBrowser(app: JupyterLab, manager: IServiceManager, registry
     let disposables = creatorCmds[name] = new DisposableSet();
     let command = Private.commandForName(name);
     disposables.add(commands.addCommand(command, {
-      execute: () => {
-        fbWidget.createFrom(name);
-      },
+      execute: () => fbWidget.createFrom(name),
       label: `New ${name}`
     }));
     disposables.add(palette.addItem({ command, category }));
   };
 
-  // Save the state of the file browser in the state database.
-  fbModel.pathChanged.connect((sender, args) => {
-    state.save(`${NAMESPACE}:cwd`, { path: args.newValue });
-  });
-
   // Restore the state of the file browser on reload.
-  Promise.all([state.fetch(`${NAMESPACE}:cwd`), app.started]).then(([cwd]) => {
-    if (cwd) {
-      fbModel.cd((cwd as any).path);
+  let key = `${NAMESPACE}:cwd`;
+  Promise.all([state.fetch(key), app.started, manager.ready]).then(([cwd]) => {
+    if (!cwd) {
+      return;
     }
+
+    let path = cwd['path'] as string;
+    return manager.contents.get(path)
+      .then(() => { fbModel.cd(path); })
+      .catch(() => { state.remove(key); });
+  }).then(() => {
+    // Save the subsequent state of the file browser in the state database.
+    fbModel.pathChanged.connect((sender, args) => {
+      state.save(`${NAMESPACE}:cwd`, { path: args.newValue });
+    });
   });
 
   // Sync tracker with currently focused widget.
@@ -278,7 +282,8 @@ function addCommands(app: JupyterLab, fbWidget: FileBrowser, docManager: Documen
   commands.addCommand(cmdIds.open, {
     execute: args => {
       let path = args['path'] as string;
-      return fbWidget.openPath(path);
+      let factory = args['factory'] as string || void 0;
+      return fbWidget.openPath(path, factory);
     }
   });
 

+ 33 - 12
src/imagewidget/plugin.ts

@@ -17,22 +17,25 @@ import {
   IDocumentRegistry
 } from '../docregistry';
 
+import {
+  IStateDB
+} from '../statedb';
+
 import {
   ImageWidget, ImageWidgetFactory
 } from './widget';
 
 
-/**
- * The image widget instance tracker.
- */
-const tracker = new InstanceTracker<ImageWidget>();
-
 /**
  * The list of file extensions for images.
  */
 const EXTENSIONS = ['.png', '.gif', '.jpeg', '.jpg', '.svg', '.bmp', '.ico',
   '.xbm', '.tiff', '.tif'];
 
+/**
+ * The name of the factory that creates image widgets.
+ */
+const FACTORY = 'Image';
 
 /**
  * The image file handler extension.
@@ -40,7 +43,7 @@ const EXTENSIONS = ['.png', '.gif', '.jpeg', '.jpg', '.svg', '.bmp', '.ico',
 export
 const imageHandlerExtension: JupyterLabPlugin<void> = {
   id: 'jupyter.extensions.image-handler',
-  requires: [IDocumentRegistry, ICommandPalette],
+  requires: [IDocumentRegistry, ICommandPalette, IStateDB],
   activate: activateImageWidget,
   autoStart: true
 };
@@ -49,42 +52,60 @@ const imageHandlerExtension: JupyterLabPlugin<void> = {
 /**
  * Activate the image widget extension.
  */
-function activateImageWidget(app: JupyterLab, registry: IDocumentRegistry, palette: ICommandPalette): void {
+function activateImageWidget(app: JupyterLab, registry: IDocumentRegistry, palette: ICommandPalette, state: IStateDB): void {
   let zoomInImage = 'image-widget:zoom-in';
   let zoomOutImage = 'image-widget:zoom-out';
   let resetZoomImage = 'image-widget:reset-zoom';
-  let image = new ImageWidgetFactory({
-    name: 'Image',
+
+  const factory = new ImageWidgetFactory({
+    name: FACTORY,
     modelName: 'base64',
     fileExtensions: EXTENSIONS,
     defaultFor: EXTENSIONS
   });
 
+  const tracker = new InstanceTracker<ImageWidget>({
+    restore: {
+      state,
+      command: 'file-operations:open',
+      args: widget => ({ path: widget.context.path, factory: FACTORY }),
+      name: widget => widget.context.path,
+      namespace: 'images',
+      when: app.started,
+      registry: app.commands
+    }
+  });
+
   // Sync tracker with currently focused widget.
   app.shell.currentChanged.connect((sender, args) => {
     tracker.sync(args.newValue);
   });
 
-  registry.addWidgetFactory(image);
+  registry.addWidgetFactory(factory);
 
-  image.widgetCreated.connect((sender, newWidget) => {
-    tracker.add(newWidget);
+  factory.widgetCreated.connect((sender, widget) => {
+    // Notify the instance tracker if restore data needs to update.
+    widget.context.pathChanged.connect(() => { tracker.save(widget); });
+    tracker.add(widget);
   });
 
   app.commands.addCommand(zoomInImage, {
     execute: zoomIn,
     label: 'Zoom In'
   });
+
   app.commands.addCommand(zoomOutImage, {
     execute: zoomOut,
     label: 'Zoom Out'
   });
+
   app.commands.addCommand(resetZoomImage, {
     execute: resetZoom,
     label: 'Reset Zoom'
   });
 
   let category = 'Image Widget';
+
   [zoomInImage, zoomOutImage, resetZoomImage]
     .forEach(command => palette.addItem({ command, category }));
 

+ 10 - 3
src/imagewidget/widget.ts

@@ -40,9 +40,16 @@ class ImageWidget extends Widget {
     if (context.model.toString()) {
       this.update();
     }
-    context.pathChanged.connect(() => this.update());
-    context.model.contentChanged.connect(() => this.update());
-    context.fileChanged.connect(() => this.update());
+    context.pathChanged.connect(() => { this.update(); });
+    context.model.contentChanged.connect(() => { this.update(); });
+    context.fileChanged.connect(() => { this.update(); });
+  }
+
+  /**
+   * The image widget's context.
+   */
+  get context(): DocumentRegistry.IContext<DocumentRegistry.IModel> {
+    return this._context;
   }
 
   /**

+ 0 - 1
src/inspector/plugin.ts

@@ -15,7 +15,6 @@ import {
 } from './';
 
 
-
 /**
  * A service providing an inspector panel.
  */

+ 41 - 5
src/markdownwidget/plugin.ts

@@ -5,6 +5,10 @@ import {
   JupyterLab, JupyterLabPlugin
 } from '../application';
 
+import {
+  InstanceTracker
+} from '../common/instancetracker';
+
 import {
   IDocumentRegistry
 } from '../docregistry';
@@ -14,7 +18,11 @@ import {
 } from '../rendermime';
 
 import {
-  MarkdownWidgetFactory
+  IStateDB
+} from '../statedb';
+
+import {
+  MarkdownWidget, MarkdownWidgetFactory
 } from './widget';
 
 
@@ -28,6 +36,11 @@ const PORTRAIT_ICON_CLASS = 'jp-MainAreaPortraitIcon';
  */
 const TEXTEDITOR_ICON_CLASS = 'jp-ImageTextEditor';
 
+/**
+ * The name of the factory that creates markdown widgets.
+ */
+const FACTORY = 'Rendered Markdown';
+
 
 /**
  * The markdown handler extension.
@@ -35,17 +48,40 @@ const TEXTEDITOR_ICON_CLASS = 'jp-ImageTextEditor';
 export
 const markdownHandlerExtension: JupyterLabPlugin<void> = {
   id: 'jupyter.extensions.rendered-markdown',
-  requires: [IDocumentRegistry, IRenderMime],
-  activate: (app: JupyterLab, registry: IDocumentRegistry, rendermime: IRenderMime) => {
-    let factory = new MarkdownWidgetFactory({
-      name: 'Rendered Markdown',
+  requires: [IDocumentRegistry, IRenderMime, IStateDB],
+  activate: (app: JupyterLab, registry: IDocumentRegistry, rendermime: IRenderMime, state: IStateDB) => {
+    const factory = new MarkdownWidgetFactory({
+      name: FACTORY,
       fileExtensions: ['.md'],
       rendermime
     });
+
+    const tracker = new InstanceTracker<MarkdownWidget>({
+      restore: {
+        state,
+        command: 'file-operations:open',
+        args: widget => ({ path: widget.context.path, factory: FACTORY }),
+        name: widget => widget.context.path,
+        namespace: 'rendered-markdown',
+        when: app.started,
+        registry: app.commands
+      }
+    });
+
     let icon = `${PORTRAIT_ICON_CLASS} ${TEXTEDITOR_ICON_CLASS}`;
+
+    // Sync tracker with currently focused widget.
+    app.shell.currentChanged.connect((sender, args) => {
+      tracker.sync(args.newValue);
+    });
+
     factory.widgetCreated.connect((sender, widget) => {
       widget.title.icon = icon;
+      // Notify the instance tracker if restore data needs to update.
+      widget.context.pathChanged.connect(() => { tracker.save(widget); });
+      tracker.add(widget);
     });
+
     registry.addWidgetFactory(factory);
   },
   autoStart: true

+ 7 - 0
src/markdownwidget/widget.ts

@@ -70,6 +70,13 @@ class MarkdownWidget extends Widget {
     this._monitor.activityStopped.connect(() => { this.update(); });
   }
 
+  /**
+   * The markdown widget's context.
+   */
+  get context(): DocumentRegistry.IContext<DocumentRegistry.IModel> {
+    return this._context;
+  }
+
   /**
    * Dispose of the resources held by the widget.
    */

+ 26 - 36
src/notebook/plugin.ts

@@ -58,16 +58,6 @@ const PORTRAIT_ICON_CLASS = 'jp-MainAreaPortraitIcon';
  */
 const NOTEBOOK_ICON_CLASS = 'jp-ImageNotebook';
 
-/**
- * The notebook plugin state namespace.
- */
-const NAMESPACE = 'notebooks';
-
-/**
- * The notebook instance tracker.
- */
-const tracker = new NotebookTracker();
-
 /**
  * The map of command ids used by the notebook.
  */
@@ -114,6 +104,11 @@ const cmdIds = {
   markdown6: 'notebook-cells:markdown-header6',
 };
 
+/**
+ * The name of the factory that creates notebooks.
+ */
+const FACTORY = 'Notebook';
+
 
 /**
  * The notebook widget tracker provider.
@@ -142,8 +137,8 @@ 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, state: IStateDB): INotebookTracker {
-  let widgetFactory = new NotebookWidgetFactory({
-    name: 'Notebook',
+  const factory = new NotebookWidgetFactory({
+    name: FACTORY,
     fileExtensions: ['.ipynb'],
     modelName: 'notebook',
     defaultFor: ['.ipynb'],
@@ -154,6 +149,18 @@ function activateNotebookHandler(app: JupyterLab, registry: IDocumentRegistry, s
     renderer
   });
 
+  const tracker = new NotebookTracker({
+    restore: {
+      state,
+      command: 'file-operations:open',
+      args: widget => ({ path: widget.context.path, factory: FACTORY }),
+      name: widget => widget.context.path,
+      namespace: 'notebooks',
+      when: [app.started, services.ready],
+      registry: app.commands
+    }
+  });
+
   // Sync tracker and set the source of the code inspector.
   app.shell.currentChanged.connect((sender, args) => {
     let widget = tracker.sync(args.newValue);
@@ -163,8 +170,7 @@ function activateNotebookHandler(app: JupyterLab, registry: IDocumentRegistry, s
   });
 
   registry.addModelFactory(new NotebookModelFactory());
-  registry.addWidgetFactory(widgetFactory);
-
+  registry.addWidgetFactory(factory);
   registry.addFileType({
     name: 'Notebook',
     extension: '.ipynb',
@@ -177,38 +183,22 @@ function activateNotebookHandler(app: JupyterLab, registry: IDocumentRegistry, s
     widgetName: 'Notebook'
   });
 
-  addCommands(app, services);
+  addCommands(app, services, tracker);
   populatePalette(palette);
 
   let id = 0; // The ID counter for notebook panels.
 
-  widgetFactory.widgetCreated.connect((sender, widget) => {
+  factory.widgetCreated.connect((sender, widget) => {
     // If the notebook panel does not have an ID, assign it one.
     widget.id = widget.id || `notebook-${++id}`;
     widget.title.icon = `${PORTRAIT_ICON_CLASS} ${NOTEBOOK_ICON_CLASS}`;
     // Immediately set the inspector source to the current notebook.
     inspector.source = widget.content.inspectionHandler;
+    // Notify the instance tracker if restore data needs to update.
+    widget.context.pathChanged.connect(() => { tracker.save(widget); });
     // 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, { path: 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, services.ready])
-    .then(([items]) => {
-      let open = 'file-operations:open';
-      items.forEach(item => { app.commands.execute(open, item.value); });
-    });
+  });
 
   // Add main menu notebook menu.
   mainMenu.addMenu(createMenu(app), { rank: 20 });
@@ -219,7 +209,7 @@ function activateNotebookHandler(app: JupyterLab, registry: IDocumentRegistry, s
 /**
  * Add the notebook commands to the application's command registry.
  */
-function addCommands(app: JupyterLab, services: IServiceManager): void {
+function addCommands(app: JupyterLab, services: IServiceManager, tracker: NotebookTracker): void {
   let commands = app.commands;
 
   commands.addCommand(cmdIds.runAndAdvance, {

+ 21 - 109
src/statedb/plugin.ts

@@ -10,9 +10,13 @@ import {
 } from '../application';
 
 import {
-  IStateDB, IStateItem
+  IStateDB
 } from './index';
 
+import {
+  StateDB
+} from './statedb';
+
 
 /**
  * The default state database for storing application state.
@@ -20,120 +24,28 @@ import {
 export
 const stateProvider: JupyterLabPlugin<IStateDB> = {
   id: 'jupyter.services.statedb',
-  activate: (): IStateDB => new StateDB(),
+  activate: activateState,
   autoStart: true,
   provides: IStateDB
 };
 
 
 /**
- * The default concrete implementation of a state database.
+ * Activate the 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 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<JSONObject> {
-    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<IStateItem[]> {
-    let items: IStateItem[] = [];
-    for (let i = 0, len = window.localStorage.length; i < len; i++) {
-      let key = window.localStorage.key(i);
-      if (key.indexOf(`${namespace}:`) === 0) {
-        try {
-          items.push({
-            id: key,
-            value: JSON.parse(window.localStorage.getItem(key))
-          });
-        } catch (error) {
-          console.warn(error);
-        }
-      }
-    }
-    return Promise.resolve(items);
-  }
-
-  /**
-   * 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 value - 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, value: JSONObject): Promise<void> {
-    try {
-      let serialized = JSON.stringify(value);
-      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);
+function activateState(): Promise<IStateDB> {
+  let state = new StateDB();
+  let version = (window as any).jupyter.version;
+  let key = 'statedb:version';
+  let fetch = state.fetch(key);
+  let save = () => state.save(key, { version });
+  let reset = () => state.clear().then(save);
+  let check = (value: JSONObject) => {
+    let old = value && (value as any).version;
+    if (!old || old !== version) {
+      console.log(`Upgraded: ${old || 'unknown'} to ${version}. Resetting DB.`);
+      return reset();
     }
-  }
+  };
+  return fetch.then(check, reset).then(() => state);
 }

+ 132 - 0
src/statedb/statedb.ts

@@ -0,0 +1,132 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  JSONObject
+} from 'phosphor/lib/algorithm/json';
+
+import {
+  IStateDB, IStateItem
+} from './index';
+
+
+/**
+ * The default concrete implementation of a state database.
+ */
+export
+class StateDB implements IStateDB {
+  /**
+   * The maximum allowed length of the data after it has been serialized.
+   */
+  readonly maxLength = 2000;
+
+  /**
+   * Clear the entire database.
+   */
+  clear(): Promise<void> {
+    window.localStorage.clear();
+    return Promise.resolve(void 0);
+  }
+
+  /**
+   * Retrieve a saved bundle from the database.
+   *
+   * @param id - The identifier used to 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<JSONObject> {
+    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<IStateItem[]> {
+    let items: IStateItem[] = [];
+    for (let i = 0, len = window.localStorage.length; i < len; i++) {
+      let key = window.localStorage.key(i);
+      if (key.indexOf(`${namespace}:`) === 0) {
+        try {
+          items.push({
+            id: key,
+            value: JSON.parse(window.localStorage.getItem(key))
+          });
+        } catch (error) {
+          console.warn(error);
+        }
+      }
+    }
+    return Promise.resolve(items);
+  }
+
+  /**
+   * 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 value - 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, value: JSONObject): Promise<void> {
+    try {
+      let serialized = JSON.stringify(value);
+      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);
+    }
+  }
+}

+ 2 - 3
src/terminal/index.ts

@@ -84,9 +84,8 @@ class TerminalWidget extends Widget {
     if (!value) {
       return;
     }
-
-    value.ready.then(() => {
-      this._session = value;
+    this._session = value;
+    this._session.ready.then(() => {
       this._session.messageReceived.connect(this._onMessage, this);
       this.title.label = `Terminal ${this._session.name}`;
       this._resizeTerminal(-1, -1);

+ 22 - 27
src/terminal/plugin.ts

@@ -48,16 +48,6 @@ 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.
- */
-const tracker = new InstanceTracker<TerminalWidget>();
-
 
 /**
  * The default terminal extension.
@@ -89,6 +79,19 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
     fontSize: 13
   };
 
+  // Create an instance tracker for all terminal widgets.
+  const tracker = new InstanceTracker<TerminalWidget>({
+    restore: {
+      state,
+      command: 'terminal:create-new',
+      args: widget => ({ name: widget.session.name }),
+      name: widget => widget.session && widget.session.name,
+      namespace: 'terminals',
+      when: app.started,
+      registry: app.commands
+    }
+  });
+
   // Sync tracker with currently focused widget.
   app.shell.currentChanged.connect((sender, args) => {
     tracker.sync(args.newValue);
@@ -100,12 +103,6 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
     caption: 'Start a new terminal session',
     execute: args => {
       let name = args ? args['name'] as string : '';
-      let term = new TerminalWidget(options);
-      term.title.closable = true;
-      term.title.icon = `${LANDSCAPE_ICON_CLASS} ${TERMINAL_ICON_CLASS}`;
-      app.shell.addToMainArea(term);
-      app.shell.activateMain(term.id);
-      tracker.add(term);
       let promise: Promise<TerminalSession.ISession>;
       if (name) {
         promise = services.terminals.connectTo(name);
@@ -113,10 +110,15 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
         promise = services.terminals.startNew();
       }
       promise.then(session => {
-        let key = `${NAMESPACE}:${session.name}`;
-        term.session = session;
-        state.save(key, { name: session.name });
-        term.disposed.connect(() => { state.remove(key); });
+        session.ready.then(() => {
+          let term = new TerminalWidget(options);
+          term.session = session;
+          term.title.closable = true;
+          term.title.icon = `${LANDSCAPE_ICON_CLASS} ${TERMINAL_ICON_CLASS}`;
+          app.shell.addToMainArea(term);
+          app.shell.activateMain(term.id);
+          tracker.add(term);
+        });
       });
     }
   });
@@ -173,13 +175,6 @@ function activateTerminal(app: JupyterLab, services: IServiceManager, mainMenu:
     }
   });
 
-  // Reload any terminals whose state has been stored.
-  Promise.all([state.fetchNamespace(NAMESPACE), app.started])
-    .then(([items]) => {
-      let create = 'terminal:create-new';
-      items.forEach(item => { app.commands.execute(create, item.value); });
-    });
-
   // Add command palette items.
   let category = 'Terminal';
   [