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

Merge pull request #3490 from afshin/sessions

Workspaces
Brian E. Granger преди 7 години
родител
ревизия
cf002f5178

+ 2 - 1
.gitignore

@@ -7,8 +7,9 @@ jupyterlab/schemas
 jupyterlab/themes
 jupyterlab/geckodriver
 dev_mode/schemas
-dev_mode/themes
 dev_mode/static
+dev_mode/themes
+dev_mode/workspaces
 
 node_modules
 .cache

+ 4 - 4
jupyterlab/extension.py

@@ -4,9 +4,9 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
-#-----------------------------------------------------------------------------
+# ----------------------------------------------------------------------------
 # Module globals
-#-----------------------------------------------------------------------------
+# ----------------------------------------------------------------------------
 import os
 
 DEV_NOTE = """You're running JupyterLab from source.
@@ -37,7 +37,6 @@ def load_jupyter_server_extension(nbapp):
         get_app_dir, get_user_settings_dir, watch, ensure_dev, watch_dev,
         pjoin, DEV_DIR, HERE, get_app_info, ensure_core
     )
-    from ._version import __version__
 
     web_app = nbapp.web_app
     logger = nbapp.log
@@ -102,6 +101,7 @@ def load_jupyter_server_extension(nbapp):
     config.app_settings_dir = pjoin(app_dir, 'settings')
     config.schemas_dir = pjoin(app_dir, 'schemas')
     config.themes_dir = pjoin(app_dir, 'themes')
+    config.workspaces_dir = pjoin(app_dir, 'workspaces')
     info = get_app_info(app_dir)
     config.app_version = info['version']
     public_url = info['publicUrl']
@@ -132,6 +132,6 @@ def load_jupyter_server_extension(nbapp):
     build_handler = (build_url, BuildHandler, {'builder': builder})
 
     # Must add before the launcher handlers to avoid shadowing.
-    web_app.add_handlers(".*$", [build_handler])
+    web_app.add_handlers('.*$', [build_handler])
 
     add_handlers(web_app, config)

+ 9 - 14
packages/application-extension/src/index.ts

@@ -48,9 +48,6 @@ namespace CommandIDs {
 
   export
   const tree: string = 'router:tree';
-
-  export
-  const url: string = 'router:tree-url';
 }
 
 
@@ -172,26 +169,24 @@ const router: JupyterLabPlugin<IRouter> = {
       PageConfig.getBaseUrl(),
       PageConfig.getOption('pageUrl')
     );
-    const tree = PageConfig.getTreeUrl();
     const router = new Router({ base, commands });
+    const pattern = /^\/tree\/(.*)/;
 
     commands.addCommand(CommandIDs.tree, {
       execute: (args: IRouter.ICommandArgs) => {
-        const path = (args.path as string).replace('/tree', '');
+        return app.restored.then(() => {
+          const path = decodeURIComponent((args.path.match(pattern)[1]));
 
-        // Change the URL back to the base application URL.
-        window.history.replaceState({ }, '', base);
+          // Change the URL back to the base application URL.
+          window.history.replaceState({ }, '', base);
 
-        return commands.execute('filebrowser:navigate-main', { path });
+          return commands.execute('filebrowser:navigate-main', { path });
+        });
       }
     });
 
-    commands.addCommand(CommandIDs.url, {
-      execute: args => URLExt.join(tree, (args.path as string))
-    });
-
-    app.restored.then(() => { router.route(window.location.href); });
-    router.register(/^\/tree\/.+/, CommandIDs.tree);
+    router.register({ command: CommandIDs.tree, pattern });
+    app.started.then(() => { router.route(window.location.href); });
 
     return router;
   },

+ 78 - 20
packages/application/src/router.ts

@@ -16,9 +16,13 @@ import {
 } from '@phosphor/coreutils';
 
 import {
-  IDisposable, DisposableDelegate
+  DisposableDelegate, IDisposable
 } from '@phosphor/disposable';
 
+import {
+  ISignal, Signal
+} from '@phosphor/signaling';
+
 
 /* tslint:disable */
 /**
@@ -44,16 +48,19 @@ interface IRouter {
    */
   readonly commands: CommandRegistry;
 
+  /**
+   * A signal emitted when the router routes a route.
+   */
+  readonly routed: ISignal<IRouter, IRouter.ICommandArgs>;
+
   /**
    * Register to route a path pattern to a command.
    *
-   * @param pattern - The regular expression that will be matched against URLs.
-   *
-   * @param command - The command string that will be invoked upon matching.
+   * @param options - The route registration options.
    *
    * @returns A disposable that removes the registered rul from the router.
    */
-  register(pattern: RegExp, command: string): IDisposable;
+  register(options: IRouter.IRegisterArgs): IDisposable;
 
   /**
    * Route a specific path to an action.
@@ -89,6 +96,28 @@ namespace IRouter {
      */
     search: string;
   }
+
+  /**
+   * The specification for registering a route with the router.
+   */
+  export
+  interface IRegisterArgs {
+    /**
+     * The command string that will be invoked upon matching.
+     */
+    command: string;
+
+    /**
+     * The regular expression that will be matched against URLs.
+     */
+    pattern: RegExp;
+
+    /**
+     * The rank order of the registered rule. A lower rank denotes a higher
+     * priority. The default rank is `100`.
+     */
+    rank?: number;
+  }
 }
 
 
@@ -115,19 +144,26 @@ class Router implements IRouter {
    */
   readonly commands: CommandRegistry;
 
+  /**
+   * A signal emitted when the router routes a route.
+   */
+  get routed(): ISignal<this, IRouter.ICommandArgs> {
+    return this._routed;
+  }
+
   /**
    * Register to route a path pattern to a command.
    *
-   * @param pattern - The regular expression that will be matched against URLs.
-   *
-   * @param command - The command string that will be invoked upon matching.
+   * @param options - The route registration options.
    *
    * @returns A disposable that removes the registered rul from the router.
    */
-  register(pattern: RegExp, command: string): IDisposable {
+  register(options: IRouter.IRegisterArgs): IDisposable {
+    const { command, pattern } = options;
+    const rank = 'rank' in options ? options.rank : 100;
     const rules = this._rules;
 
-    rules.set(pattern, command);
+    rules.set(pattern, { command, rank });
 
     return new DisposableDelegate(() => { rules.delete(pattern); });
   }
@@ -142,20 +178,30 @@ class Router implements IRouter {
    * match the `IRouter.ICommandArgs` interface.
    */
   route(url: string): void {
-    const { base } = this;
-    const parsed = URLExt.parse(url.replace(base, ''));
-    const path = parsed.pathname;
-    const search = parsed.search;
-    const rules = this._rules;
-
-    rules.forEach((command, pattern) => {
-      if (path.match(pattern)) {
-        this.commands.execute(command, { path, search });
+    const parsed = URLExt.parse(url.replace(this.base, ''));
+    const args = { path: parsed.pathname, search: parsed.search };
+    const matches: Private.Rule[] = [];
+
+    // Collect all rules that match the URL.
+    this._rules.forEach((rule, pattern) => {
+      if (parsed.pathname.match(pattern)) {
+        matches.push(rule);
       }
     });
+
+    // Order the matching rules by rank and execute them.
+    matches.sort((a, b) => a.rank - b.rank).forEach(rule => {
+      // Ignore the results of each executed promise.
+      this.commands.execute(rule.command, args).catch(reason => {
+        console.warn(`Routing ${url} using ${rule.command} failed:`, reason);
+      });
+    });
+
+    this._routed.emit(args);
   }
 
-  private _rules = new Map<RegExp, string>();
+  private _routed = new Signal<this, IRouter.ICommandArgs>(this);
+  private _rules = new Map<RegExp, Private.Rule>();
 }
 
 
@@ -180,3 +226,15 @@ namespace Router {
     commands: CommandRegistry;
   }
 }
+
+
+/**
+ * A namespace for private module data.
+ */
+namespace Private {
+  /**
+   * The internal representation of a routing rule.
+   */
+  export
+  type Rule = { command: string; rank: number };
+}

+ 0 - 1
packages/apputils-extension/package.json

@@ -37,7 +37,6 @@
     "@jupyterlab/coreutils": "^1.0.1",
     "@jupyterlab/mainmenu": "^0.3.1",
     "@jupyterlab/services": "^1.0.1",
-    "@phosphor/algorithm": "^1.1.2",
     "@phosphor/coreutils": "^1.3.0",
     "@phosphor/disposable": "^1.1.2",
     "@phosphor/widgets": "^1.5.0",

+ 131 - 55
packages/apputils-extension/src/index.ts

@@ -4,7 +4,7 @@
 |----------------------------------------------------------------------------*/
 
 import {
-  ILayoutRestorer, JupyterLab, JupyterLabPlugin
+  ILayoutRestorer, IRouter, JupyterLab, JupyterLabPlugin
 } from '@jupyterlab/application';
 
 import {
@@ -24,15 +24,11 @@ import {
 } from '@jupyterlab/services';
 
 import {
-  each
-} from '@phosphor/algorithm';
-
-import {
-  JSONObject
+  PromiseDelegate
 } from '@phosphor/coreutils';
 
 import {
-  DisposableDelegate, IDisposable
+  DisposableDelegate, DisposableSet, IDisposable
 } from '@phosphor/disposable';
 
 import {
@@ -46,15 +42,29 @@ import {
 import '../style/index.css';
 
 
+/**
+ * The interval in milliseconds that calls to save a workspace are debounced
+ * to allow for multiple quickly executed state changes to result in a single
+ * workspace save operation.
+ */
+const WORKSPACE_SAVE_DEBOUNCE_INTERVAL = 2000;
+
+
 /**
  * The command IDs used by the apputils plugin.
  */
 namespace CommandIDs {
   export
-  const clearStateDB = 'apputils:clear-statedb';
+  const changeTheme = 'apputils:change-theme';
 
   export
-  const changeTheme = 'apputils:change-theme';
+  const clearState = 'apputils:clear-statedb';
+
+  export
+  const loadState = 'apputils:load-statedb';
+
+  export
+  const saveState = 'apputils:save-statedb';
 }
 
 
@@ -160,11 +170,11 @@ const themes: JupyterLabPlugin<IThemeManager> = {
       const themeMenu = new Menu({ commands });
       themeMenu.title.label = 'JupyterLab Theme';
       manager.ready.then(() => {
-        each(manager.themes, theme => {
-          themeMenu.addItem({
-            command: CommandIDs.changeTheme,
-            args: { isPalette: false, theme: theme }
-          });
+        const command = CommandIDs.changeTheme;
+        const isPalette = false;
+
+        manager.themes.forEach(theme => {
+          themeMenu.addItem({ command, args: { isPalette, theme } });
         });
       });
       mainMenu.settingsMenu.addGroup([{
@@ -172,17 +182,15 @@ const themes: JupyterLabPlugin<IThemeManager> = {
       }], 0);
     }
 
-    // If we have a command palette, add theme
-    // switching options to it.
+    // If we have a command palette, add theme switching options to it.
     if (palette) {
-      const category = 'Settings';
       manager.ready.then(() => {
-        each(manager.themes, theme => {
-          palette.addItem({
-            command: CommandIDs.changeTheme,
-            args: { isPalette: true, theme: theme },
-            category
-          });
+        const category = 'Settings';
+        const command = CommandIDs.changeTheme;
+        const isPalette = true;
+
+        manager.themes.forEach(theme => {
+          palette.addItem({ command, args: { isPalette, theme }, category });
         });
       });
     }
@@ -201,13 +209,7 @@ const splash: JupyterLabPlugin<ISplashScreen> = {
   id: '@jupyterlab/apputils-extension:splash',
   autoStart: true,
   provides: ISplashScreen,
-  activate: () => {
-    return {
-      show: () => {
-        return Private.showSplash();
-      }
-    };
-  }
+  activate: app => ({ show: () => Private.showSplash(app.restored) })
 };
 
 
@@ -218,31 +220,106 @@ const state: JupyterLabPlugin<IStateDB> = {
   id: '@jupyterlab/apputils-extension:state',
   autoStart: true,
   provides: IStateDB,
-  activate: (app: JupyterLab) => {
+  requires: [IRouter],
+  activate: (app: JupyterLab, router: IRouter) => {
+    let command: string;
+    let debouncer: number;
+    let resolved = false;
+    let workspace = '';
+
+    const { commands, info, serviceManager } = app;
+    const { workspaces } = serviceManager;
+    const transform = new PromiseDelegate<StateDB.DataTransform>();
     const state = new StateDB({
-      namespace: app.info.namespace,
-      when: app.restored.then(() => { /* no-op */ })
+      namespace: info.namespace,
+      transform: transform.promise
     });
-    const version = app.info.version;
-    const key = 'statedb:version';
-    const fetch = state.fetch(key);
-    const save = () => state.save(key, { version });
-    const reset = () => state.clear().then(save);
-    const check = (value: JSONObject) => {
-      let old = value && value['version'];
-      if (!old || old !== version) {
-        const previous = old || 'unknown';
-        console.log(`Upgraded: ${previous} to ${version}; Resetting DB.`);
-        return reset();
+    const disposables = new DisposableSet();
+    const pattern = /^\/workspaces\/(.+)/;
+    const unload = () => {
+      disposables.dispose();
+      router.routed.disconnect(unload, state);
+
+      // If the request that was routed did not contain a workspace,
+      // leave the database intact.
+      if (!resolved) {
+        transform.resolve({ type: 'cancel', contents: null });
       }
     };
 
-    app.commands.addCommand(CommandIDs.clearStateDB, {
+    command = CommandIDs.clearState;
+    commands.addCommand(command, {
       label: 'Clear Application Restore State',
       execute: () => state.clear()
     });
 
-    return fetch.then(check, reset).then(() => state);
+    command = CommandIDs.saveState;
+    commands.addCommand(command, {
+      label: () => `Save Workspace (${workspace})`,
+      isEnabled: () => !!workspace,
+      execute: () => {
+        if (!workspace) {
+          return;
+        }
+
+        const id = workspace;
+        const metadata = { id };
+
+        if (debouncer) {
+          window.clearTimeout(debouncer);
+        }
+
+        debouncer = window.setTimeout(() => {
+          state.toJSON()
+            .then(data => workspaces.save(id, { data, metadata }))
+            .catch(reason => {
+              console.warn(`Saving workspace (${id}) failed.`, reason);
+            });
+        }, WORKSPACE_SAVE_DEBOUNCE_INTERVAL);
+      }
+    });
+
+    command = CommandIDs.loadState;
+    disposables.add(commands.addCommand(command, {
+      execute: (args: IRouter.ICommandArgs) => {
+        // Irrespective of whether the workspace exists, the state database's
+        // initial data transormation resolves if this command is executed.
+        resolved = true;
+
+        // Populate the workspace placeholder.
+        workspace = decodeURIComponent((args.path.match(pattern)[1]));
+
+        // If there is no workspace, leave the state database intact.
+        if (!workspace) {
+          transform.resolve({ type: 'cancel', contents: null });
+          return;
+        }
+
+        // Any time the local state database changes, save the workspace.
+        state.changed.connect(() => {
+          commands.execute(CommandIDs.saveState);
+        });
+
+        // Fetch the workspace and overwrite the state database.
+        return workspaces.fetch(workspace).then(session => {
+          transform.resolve({ type: 'overwrite', contents: session.data });
+        }).catch(reason => {
+          console.warn(`Fetching workspace (${workspace}) failed.`, reason);
+
+          // If the workspace does not exist, cancel the data transformation and
+          // save a workspace with the current user state data.
+          transform.resolve({ type: 'cancel', contents: null });
+          commands.execute(CommandIDs.saveState);
+        });
+      }
+    }));
+    disposables.add(router.register({ command, pattern }));
+
+    // After the first route in the application lifecycle has been routed,
+    // stop listening to routing events.
+    router.routed.connect(unload, state);
+
+    return state;
   }
 };
 
@@ -256,7 +333,6 @@ const plugins: JupyterLabPlugin<any>[] = [
 export default plugins;
 
 
-
 /**
  * The namespace for module private data.
  */
@@ -275,7 +351,7 @@ namespace Private {
    * Show the splash element.
    */
   export
-  function showSplash(): IDisposable {
+  function showSplash(ready: Promise<any>): IDisposable {
     if (!splash) {
       splash = document.createElement('div');
       splash.id = 'jupyterlab-splash';
@@ -318,13 +394,13 @@ namespace Private {
     document.body.appendChild(splash);
     splashCount++;
     return new DisposableDelegate(() => {
-      splashCount = Math.max(splashCount - 1, 0);
-      if (splashCount === 0 && splash) {
-        splash.classList.add('splash-fade');
-        setTimeout(() => {
-          document.body.removeChild(splash);
-        }, 500);
-      }
+      ready.then(() => {
+        splashCount = Math.max(splashCount - 1, 0);
+        if (splashCount === 0 && splash) {
+          splash.classList.add('splash-fade');
+          setTimeout(() => { document.body.removeChild(splash); }, 500);
+        }
+      });
     });
   }
 }

+ 1 - 1
packages/apputils-extension/src/palette.ts

@@ -68,7 +68,7 @@ class Palette implements ICommandPalette {
    */
   addItem(options: IPaletteItem): IDisposable {
     let item = this._palette.addItem(options as CommandPalette.IItemOptions);
-    return new DisposableDelegate(() => this._palette.removeItem(item));
+    return new DisposableDelegate(() => { this._palette.removeItem(item); });
   }
 
   private _palette: CommandPalette;

+ 184 - 74
packages/coreutils/src/statedb.ts

@@ -2,9 +2,13 @@
 // Distributed under the terms of the Modified BSD License.
 
 import {
-  ReadonlyJSONValue, Token
+  ReadonlyJSONObject, ReadonlyJSONValue, Token
 } from '@phosphor/coreutils';
 
+import {
+  ISignal, Signal
+} from '@phosphor/signaling';
+
 import {
   IDataConnector
 } from './interfaces';
@@ -73,6 +77,13 @@ interface IStateDB extends IDataConnector<ReadonlyJSONValue> {
    * This promise will always succeed.
    */
   fetchNamespace(namespace: string): Promise<IStateItem[]>;
+
+  /**
+   * Return a serialized copy of the state database's entire contents.
+   *
+   * @returns A promise that bears the database contents as JSON.
+   */
+  toJSON(): Promise<ReadonlyJSONObject>;
 }
 
 
@@ -87,10 +98,38 @@ class StateDB implements IStateDB {
    * @param options - The instantiation options for a state database.
    */
   constructor(options: StateDB.IOptions) {
-    this.namespace = options.namespace;
-    if (options.when) {
-      this._handleSentinel(options.when);
+    const { namespace, transform } = options;
+
+    this.namespace = namespace;
+
+    if (!transform) {
+      this._ready = Promise.resolve(undefined);
+      return;
     }
+
+    this._ready = transform.then(transformation => {
+      const { contents, type } = transformation;
+
+      switch (type) {
+        case 'cancel':
+          return;
+        case 'clear':
+          this._clear();
+          return;
+        case 'merge':
+          this._merge(contents || { });
+          return;
+        case 'overwrite':
+          this._overwrite(contents || { });
+          return;
+        default:
+          return;
+      }
+    });
+  }
+
+  get changed(): ISignal<this, StateDB.Change> {
+    return this._changed;
   }
 
   /**
@@ -112,9 +151,7 @@ class StateDB implements IStateDB {
    * Clear the entire database.
    */
   clear(): Promise<void> {
-    this._clear();
-
-    return Promise.resolve(undefined);
+    return this._ready.then(() => { this._clear(); });
   }
 
   /**
@@ -135,20 +172,16 @@ class StateDB implements IStateDB {
    * retrieving the data. Non-existence of an `id` will succeed with `null`.
    */
   fetch(id: string): Promise<ReadonlyJSONValue | undefined> {
-    const key = `${this.namespace}:${id}`;
-    const value = window.localStorage.getItem(key);
-
-    if (!value) {
-      return Promise.resolve(undefined);
-    }
+    return this._ready.then(() => {
+      const key = `${this.namespace}:${id}`;
+      const value = window.localStorage.getItem(key);
 
-    try {
-      const envelope = JSON.parse(value) as Private.Envelope;
+      if (value) {
+        const envelope = JSON.parse(value) as Private.Envelope;
 
-      return Promise.resolve(envelope.v);
-    } catch (error) {
-      return Promise.reject(error);
-    }
+        return envelope.v;
+      }
+    });
   }
 
   /**
@@ -168,33 +201,34 @@ class StateDB implements IStateDB {
    * This promise will always succeed.
    */
   fetchNamespace(namespace: string): Promise<IStateItem[]> {
-    const { localStorage } = window;
-    const prefix = `${this.namespace}:${namespace}:`;
-    const regex = new RegExp(`^${this.namespace}\:`);
-    let items: IStateItem[] = [];
-    let i = localStorage.length;
-
-    while (i) {
-      let key = localStorage.key(--i);
-
-      if (key && key.indexOf(prefix) === 0) {
-        let value = localStorage.getItem(key);
-
-        try {
-          let envelope = JSON.parse(value) as Private.Envelope;
-
-          items.push({
-            id: key.replace(regex, ''),
-            value: envelope ? envelope.v : undefined
-          });
-        } catch (error) {
-          console.warn(error);
-          localStorage.removeItem(key);
+    return this._ready.then(() => {
+      const { localStorage } = window;
+      const prefix = `${this.namespace}:${namespace}:`;
+      let items: IStateItem[] = [];
+      let i = localStorage.length;
+
+      while (i) {
+        let key = localStorage.key(--i);
+
+        if (key && key.indexOf(prefix) === 0) {
+          let value = localStorage.getItem(key);
+
+          try {
+            let envelope = JSON.parse(value) as Private.Envelope;
+
+            items.push({
+              id: key.replace(`${this.namespace}:`, ''),
+              value: envelope ? envelope.v : undefined
+            });
+          } catch (error) {
+            console.warn(error);
+            localStorage.removeItem(key);
+          }
         }
       }
-    }
 
-    return Promise.resolve(items);
+      return items;
+    });
   }
 
   /**
@@ -205,9 +239,10 @@ class StateDB implements IStateDB {
    * @returns A promise that is rejected if remove fails and succeeds otherwise.
    */
   remove(id: string): Promise<void> {
-    window.localStorage.removeItem(`${this.namespace}:${id}`);
-
-    return Promise.resolve(undefined);
+    return this._ready.then(() => {
+      window.localStorage.removeItem(`${this.namespace}:${id}`);
+      this._changed.emit({ id, type: 'remove' });
+    });
   }
 
   /**
@@ -227,23 +262,45 @@ class StateDB implements IStateDB {
    * using the `fetchNamespace()` method.
    */
   save(id: string, value: ReadonlyJSONValue): Promise<void> {
-    try {
-      const key = `${this.namespace}:${id}`;
-      const envelope: Private.Envelope = { v: value };
-      const serialized = JSON.stringify(envelope);
-      const length = serialized.length;
-      const max = this.maxLength;
+    return this._ready.then(() => {
+      this._save(id, value);
+      this._changed.emit({ id, type: 'save' });
+    });
+  }
 
-      if (length > max) {
-        throw new Error(`Data length (${length}) exceeds maximum (${max})`);
+  /**
+   * Return a serialized copy of the state database's entire contents.
+   *
+   * @returns A promise that bears the database contents as JSON.
+   */
+  toJSON(): Promise<ReadonlyJSONObject> {
+    return this._ready.then(() => {
+      const { localStorage } = window;
+      const prefix = `${this.namespace}:`;
+      const contents: Partial<ReadonlyJSONObject> =  { };
+      let i = localStorage.length;
+
+      while (i) {
+        let key = localStorage.key(--i);
+
+        if (key && key.indexOf(prefix) === 0) {
+          let value = localStorage.getItem(key);
+
+          try {
+            let envelope = JSON.parse(value) as Private.Envelope;
+
+            if (envelope) {
+              contents[key.replace(prefix, '')] = envelope.v;
+            }
+          } catch (error) {
+            console.warn(error);
+            localStorage.removeItem(key);
+          }
+        }
       }
 
-      window.localStorage.setItem(key, serialized);
-
-      return Promise.resolve(undefined);
-    } catch (error) {
-      return Promise.reject(error);
-    }
+      return contents;
+    });
   }
 
   /**
@@ -267,22 +324,42 @@ class StateDB implements IStateDB {
   }
 
   /**
-   * Handle the startup sentinel.
+   * Merge data into the state database.
    */
-  private _handleSentinel(when: Promise<void>): void {
-    const { localStorage } = window;
-    let key = `${this.namespace}:statedb:sentinel`;
-    let sentinel = localStorage.getItem(key);
+  private _merge(contents: ReadonlyJSONObject): void {
+    Object.keys(contents).forEach(key => { this._save(key, contents[key]); });
+  }
 
-    // Clear state if the sentinel was not properly cleared on last page load.
-    if (sentinel) {
-      this._clear();
+  /**
+   * Overwrite the entire database with new contents.
+   */
+  private _overwrite(contents: ReadonlyJSONObject): void {
+    this._clear();
+    this._merge(contents);
+  }
+
+  /**
+   * Save a key and its value in the database.
+   *
+   * #### Notes
+   * Unlike the public `save` method, this method is synchronous.
+   */
+  private _save(id: string, value: ReadonlyJSONValue): void {
+    const key = `${this.namespace}:${id}`;
+    const envelope: Private.Envelope = { v: value };
+    const serialized = JSON.stringify(envelope);
+    const length = serialized.length;
+    const max = this.maxLength;
+
+    if (length > max) {
+      throw new Error(`Data length (${length}) exceeds maximum (${max})`);
     }
 
-    // Set the sentinel value and clear it when the statedb is initialized.
-    localStorage.setItem(key, 'sentinel');
-    when.then(() => { localStorage.removeItem(key); });
+    window.localStorage.setItem(key, serialized);
   }
+
+  private _changed = new Signal<this, StateDB.Change>(this);
+  private _ready: Promise<void>;
 }
 
 /**
@@ -290,6 +367,38 @@ class StateDB implements IStateDB {
  */
 export
 namespace StateDB {
+  /**
+   * A state database change.
+   */
+  export
+  type Change = {
+    /**
+     * The key of the database item that was changed.
+     */
+    id: string;
+
+    /**
+     * The type of change.
+     */
+    type: 'remove' | 'save'
+  };
+
+  /**
+   * A data transformation that can be applied to a state database.
+   */
+  export
+  type DataTransform = {
+    /*
+     * The change operation being applied.
+     */
+    type: 'cancel' | 'clear' | 'merge' | 'overwrite',
+
+    /**
+     * The contents of the change operation.
+     */
+    contents: ReadonlyJSONObject | null
+  };
+
   /**
    * The instantiation options for a state database.
    */
@@ -301,10 +410,11 @@ namespace StateDB {
     namespace: string;
 
     /**
-     * An optional Promise for when the state database should be considered
-     * initialized.
+     * An optional promise that resolves with a data transformation that is
+     * applied to the database contents before the database begins resolving
+     * client requests.
      */
-    when?: Promise<void>;
+    transform?: Promise<DataTransform>;
   }
 }
 

+ 2 - 2
packages/filebrowser-extension/src/index.ts

@@ -380,10 +380,10 @@ function addCommands(app: JupyterLab, tracker: InstanceTracker<FileBrowser>, bro
 
   commands.addCommand(CommandIDs.share, {
     execute: () => {
-      const path = browser.selectedItems().next().path;
+      const path = encodeURIComponent(browser.selectedItems().next().path);
       const tree = PageConfig.getTreeUrl();
 
-      Clipboard.copyToSystem(URLExt.join(tree, (path as string)));
+      Clipboard.copyToSystem(URLExt.join(tree, path));
     },
     isVisible: () => toArray(browser.selectedItems()).length === 1,
     iconClass: 'jp-MaterialIcon jp-LinkIcon',

+ 27 - 7
packages/help-extension/src/index.ts

@@ -198,8 +198,8 @@ function activate(app: JupyterLab, mainMenu: IMainMenu, palette: ICommandPalette
     CommandIDs.launchClassic
   ].map(command => { return { command }; });
   helpMenu.addGroup(labGroup, 0);
-  const resourcesGroup =
-    RESOURCES.map(args => { return { args, command: CommandIDs.open }; });
+  const resourcesGroup = RESOURCES
+    .map(args => ({ args, command: CommandIDs.open }));
   helpMenu.addGroup(resourcesGroup, 10);
 
   // Generate a cache of the kernel help links.
@@ -273,7 +273,12 @@ function activate(app: JupyterLab, mainMenu: IMainMenu, palette: ICommandPalette
             showDialog({
               title,
               body,
-              buttons: [Dialog.createButton({label: 'DISMISS', className: 'jp-About-button jp-mod-reject jp-mod-styled'})]
+              buttons: [
+                Dialog.createButton({
+                  label: 'DISMISS',
+                  className: 'jp-About-button jp-mod-reject jp-mod-styled'
+                })
+              ]
             });
           }
         });
@@ -321,10 +326,20 @@ function activate(app: JupyterLab, mainMenu: IMainMenu, palette: ICommandPalette
       let jupyterURL = 'https://jupyter.org/about.html';
       let contributorsURL = 'https://github.com/jupyterlab/jupyterlab/graphs/contributors';
       let externalLinks = h.span({className: 'jp-About-externalLinks'},
-        h.a({href: contributorsURL, target: '_blank', className: 'jp-Button-flat'}, 'CONTRIBUTOR LIST'),
-        h.a({href: jupyterURL, target: '_blank', className: 'jp-Button-flat'}, 'ABOUT PROJECT JUPYTER')
+        h.a({
+          href: contributorsURL,
+          target: '_blank',
+          className: 'jp-Button-flat'
+        }, 'CONTRIBUTOR LIST'),
+        h.a({
+          href: jupyterURL,
+          target: '_blank',
+          className: 'jp-Button-flat'
+        }, 'ABOUT PROJECT JUPYTER')
       );
-      let copyright = h.span({className: 'jp-About-copyright'}, '© 2017 Project Jupyter');
+      let copyright = h.span({
+        className: 'jp-About-copyright'
+      }, '© 2017 Project Jupyter');
       let body = h.div({ className: 'jp-About-body' },
         externalLinks,
         copyright
@@ -333,7 +348,12 @@ function activate(app: JupyterLab, mainMenu: IMainMenu, palette: ICommandPalette
       showDialog({
         title,
         body,
-        buttons: [Dialog.createButton({label: 'DISMISS', className: 'jp-About-button jp-mod-reject jp-mod-styled'})]
+        buttons: [
+          Dialog.createButton({
+            label: 'DISMISS',
+            className: 'jp-About-button jp-mod-reject jp-mod-styled'
+          })
+        ]
       });
     }
   });

+ 1 - 0
packages/services/src/index.ts

@@ -9,3 +9,4 @@ export * from './serverconnection';
 export * from './session';
 export * from './setting';
 export * from './terminal';
+export * from './workspace';

+ 10 - 0
packages/services/src/manager.ts

@@ -37,6 +37,10 @@ import {
   ServerConnection
 } from './serverconnection';
 
+import {
+  WorkspaceManager
+} from './workspace';
+
 
 /**
  * A Jupyter services manager.
@@ -56,6 +60,7 @@ class ServiceManager implements ServiceManager.IManager {
     this.settings = new SettingManager(options);
     this.terminals = new TerminalManager(options);
     this.builder = new BuildManager(options);
+    this.workspaces = new WorkspaceManager(options);
 
     this.sessions.specsChanged.connect((sender, specs) => {
       this._specsChanged.emit(specs);
@@ -135,6 +140,11 @@ class ServiceManager implements ServiceManager.IManager {
    */
   readonly terminals: TerminalManager;
 
+  /**
+   * Get the workspace manager instance.
+   */
+  readonly workspaces: WorkspaceManager;
+
   /**
    * Test whether the manager is ready.
    */

+ 5 - 5
packages/services/src/setting/index.ts

@@ -17,7 +17,7 @@ const SERVICE_SETTINGS_URL = 'api/settings';
 
 
 /**
- * The static namespace for `SettingManager`.
+ * The settings API service manager.
  */
 export
 class SettingManager {
@@ -47,7 +47,7 @@ class SettingManager {
     const { baseUrl, pageUrl } = serverSettings;
     const base = baseUrl + pageUrl;
     const url = Private.url(base, id);
-    const promise = ServerConnection.makeRequest(url, {}, serverSettings);
+    const promise = ServerConnection.makeRequest(url, { }, serverSettings);
 
     return promise.then(response => {
       if (response.status !== 200) {
@@ -65,8 +65,8 @@ class SettingManager {
    *
    * @param raw - The user setting values as a raw string of JSON with comments.
    *
-   * @returns A promise that resolves when saving is complete or rejects
-   * with a `ServerConnection.IError`.
+   * @returns A promise that resolves when saving is complete or rejects with
+   * a `ServerConnection.IError`.
    */
   save(id: string, raw: string): Promise<void> {
     const { serverSettings } = this;
@@ -84,7 +84,7 @@ class SettingManager {
         throw new ServerConnection.ResponseError(response);
       }
 
-      return void 0;
+      return undefined;
     });
   }
 }

+ 160 - 0
packages/services/src/workspace/index.ts

@@ -0,0 +1,160 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  URLExt
+} from '@jupyterlab/coreutils';
+
+import {
+  ReadonlyJSONObject
+} from '@phosphor/coreutils';
+
+import {
+  ServerConnection
+} from '../serverconnection';
+
+
+/**
+ * The url for the lab workspaces service.
+ */
+const SERVICE_WORKSPACES_URL = 'api/workspaces';
+
+
+/**
+ * The workspaces API service manager.
+ */
+export
+class WorkspaceManager {
+  /**
+   * Create a new workspace manager.
+   */
+  constructor(options: WorkspaceManager.IOptions = { }) {
+    this.serverSettings = options.serverSettings ||
+      ServerConnection.makeSettings();
+  }
+
+  /**
+   * The server settings used to make API requests.
+   */
+  readonly serverSettings: ServerConnection.ISettings;
+
+  /**
+   * Fetch a workspace.
+   *
+   * @param id - The workspaces's ID.
+   *
+   * @returns A promise that resolves with the workspace or rejects with a
+   * `ServerConnection.IError`.
+   */
+  fetch(id: string): Promise<Workspace.IWorkspace> {
+    const { serverSettings } = this;
+    const { baseUrl, pageUrl } = serverSettings;
+    const base = baseUrl + pageUrl;
+    const url = Private.url(base, id);
+    const promise = ServerConnection.makeRequest(url, { }, serverSettings);
+
+    return promise.then(response => {
+      if (response.status !== 200) {
+        throw new ServerConnection.ResponseError(response);
+      }
+
+      return response.json();
+    });
+  }
+
+  /**
+   * Save a workspace.
+   *
+   * @param id - The workspace's ID.
+   *
+   * @param workspace - The workspace being saved.
+   *
+   * @returns A promise that resolves when saving is complete or rejects with
+   * a `ServerConnection.IError`.
+   */
+  save(id: string, workspace: Workspace.IWorkspace): Promise<void> {
+    const { serverSettings } = this;
+    const { baseUrl, pageUrl } = serverSettings;
+    const base = baseUrl + pageUrl;
+    const url = Private.url(base, id);
+    const init = {
+      body: JSON.stringify(workspace),
+      method: 'PUT'
+    };
+    const promise = ServerConnection.makeRequest(url, init, serverSettings);
+
+    return promise.then(response => {
+      if (response.status !== 204) {
+        throw new ServerConnection.ResponseError(response);
+      }
+
+      return undefined;
+    });
+  }
+}
+
+
+/**
+ * A namespace for `WorkspaceManager` statics.
+ */
+export
+namespace WorkspaceManager {
+  /**
+   * The instantiation options for a workspace manager.
+   */
+  export
+  interface IOptions {
+    /**
+     * The server settings used to make API requests.
+     */
+    serverSettings?: ServerConnection.ISettings;
+  }
+}
+
+
+/**
+ * A namespace for workspace API interfaces.
+ */
+export
+namespace Workspace {
+  /**
+   * The interface for the workspace API manager.
+   */
+  export
+  interface IManager extends WorkspaceManager { }
+
+  /**
+   * The interface describing a workspace API response.
+   */
+  export
+  interface IWorkspace {
+    /**
+     * The workspace data.
+     */
+    data: ReadonlyJSONObject;
+
+    /**
+     * The metadata for a workspace.
+     */
+    metadata: {
+      /**
+       * The workspace ID.
+       */
+      id: string;
+    };
+  }
+}
+
+
+/**
+ * A namespace for private data.
+ */
+namespace Private {
+  /**
+   * Get the url for a workspace.
+   */
+  export
+  function url(base: string, id: string): string {
+    return URLExt.join(base, SERVICE_WORKSPACES_URL, id);
+  }
+}

+ 3 - 3
packages/settingeditor-extension/src/index.ts

@@ -86,7 +86,7 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, registry: ISetting
   commands.addCommand(CommandIDs.debug, {
     execute: () => { tracker.currentWidget.toggleDebug(); },
     iconClass: 'jp-MaterialIcon jp-BugIcon',
-    label: 'Debug user settings in inspector',
+    label: 'Debug User Settings In Inspector',
     isToggled: () => tracker.currentWidget.isDebugVisible
   });
 
@@ -132,14 +132,14 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, registry: ISetting
   commands.addCommand(CommandIDs.revert, {
     execute: () => { tracker.currentWidget.revert(); },
     iconClass: 'jp-MaterialIcon jp-RefreshIcon',
-    label: 'Revert user settings',
+    label: 'Revert User Settings',
     isEnabled: () => tracker.currentWidget.canRevertRaw
   });
 
   commands.addCommand(CommandIDs.save, {
     execute: () => tracker.currentWidget.save(),
     iconClass: 'jp-MaterialIcon jp-SaveIcon',
-    label: 'Save user settings',
+    label: 'Save User Settings',
     isEnabled: () => tracker.currentWidget.canSaveRaw
   });
 

+ 1 - 1
packages/shortcuts-extension/schema/plugin.json

@@ -40,7 +40,7 @@
       },
       "type": "object"
     },
-    "command-palette:activate": {
+    "apputils:activate-command-palette": {
       "default": { },
       "properties": {
         "command": { "default": "apputils:activate-command-palette" },

+ 3 - 2
test/src/application/layoutrestorer.spec.ts

@@ -163,14 +163,15 @@ describe('apputils', () => {
           execute: () => { called = true; }
         });
         state.save(key, { data: null }).then(() => {
+          ready.resolve(undefined);
           return restorer.restore(tracker, {
             args: () => null,
             name: () => tracker.namespace,
             command: tracker.namespace
           });
         }).catch(done);
-        ready.resolve(void 0);
-        restorer.restored.then(() => { expect(called).to.be(true); })
+        restorer.restored
+          .then(() => { expect(called).to.be(true); })
           .then(() => state.remove(key))
           .then(() => { done(); })
           .catch(done);

+ 88 - 26
test/src/coreutils/statedb.spec.ts

@@ -8,15 +8,13 @@ import {
 } from '@jupyterlab/coreutils';
 
 import {
-  PromiseDelegate
+  PromiseDelegate, ReadonlyJSONObject
 } from '@phosphor/coreutils';
 
 
 describe('StateDB', () => {
 
-  beforeEach(() => {
-    window.localStorage.clear();
-  });
+  beforeEach(() => { window.localStorage.clear(); });
 
   describe('#constructor()', () => {
 
@@ -25,30 +23,68 @@ describe('StateDB', () => {
       expect(db).to.be.a(StateDB);
     });
 
-    it('should take an optional when promise', () => {
-      let { localStorage } = window;
-      let promise = new PromiseDelegate<void>();
-      let db = new StateDB({ namespace: 'test', when: promise.promise });
-      let key = 'foo:bar';
-      let value = { baz: 'qux' };
-      promise.resolve(void 0);
-      return promise.promise.then(() => {
-        expect(localStorage.length).to.be(0);
-        return db.save(key, value);
-      }).then(() => db.fetch(key))
-      .then(fetched => { expect(fetched).to.eql(value); });
+    it('should allow an overwrite data transformation', done => {
+      let transform = new PromiseDelegate<StateDB.DataTransform>();
+      let db = new StateDB({ namespace: 'test', transform: transform.promise });
+      let prepopulate = new StateDB({ namespace: 'test' });
+      let key = 'foo';
+      let correct = 'bar';
+      let incorrect = 'baz';
+
+      // By sharing a namespace, the two databases will share data.
+      prepopulate.save(key, incorrect)
+        .then(() => db.fetch(key))
+        .then(value => { expect(value).to.be(correct); })
+        .then(() => db.clear())
+        .then(done)
+        .catch(done);
+      transform.resolve({ type: 'overwrite', contents: { [key]: correct } });
     });
 
-    it('should clear the namespace if the sentinel is set', () => {
-      let { localStorage } = window;
-      let key = 'test:statedb:sentinel';
-      localStorage.setItem(key, 'sentinel');
-      localStorage.setItem('test:foo', 'bar');
-      let promise = new PromiseDelegate<void>();
-      let db = new StateDB({ namespace: 'test', when: promise.promise });
-      expect(db).to.be.a(StateDB);
-      expect(localStorage.length).to.be(1);
-      expect(localStorage.getItem('test:foo')).to.be(null);
+    it('should allow a merge data transformation', done => {
+      let transform = new PromiseDelegate<StateDB.DataTransform>();
+      let db = new StateDB({ namespace: 'test', transform: transform.promise });
+      let prepopulate = new StateDB({ namespace: 'test' });
+      let key = 'baz';
+      let value = 'qux';
+
+      // By sharing a namespace, the two databases will share data.
+      prepopulate.save('foo', 'bar')
+        .then(() => db.fetch('foo'))
+        .then(saved => { expect(saved).to.be('bar'); })
+        .then(() => db.fetch(key))
+        .then(saved => { expect(saved).to.be(value); })
+        .then(() => db.clear())
+        .then(done)
+        .catch(done);
+      transform.resolve({ type: 'merge', contents: { [key]: value } });
+    });
+
+  });
+
+  describe('#changed', () => {
+
+    it('should emit changes when the database is updated', done => {
+      let namespace = 'test-namespace';
+      let db = new StateDB({ namespace });
+      let changes: StateDB.Change[] = [
+        { id: 'foo', type: 'save' },
+        { id: 'foo', type: 'remove' },
+        { id: 'bar', type: 'save' },
+        { id: 'bar', type: 'remove' }
+      ];
+      let recorded: StateDB.Change[] = [];
+
+      db.changed.connect((sender, change) => { recorded.push(change); });
+
+      db.save('foo', 0)
+        .then(() => db.remove('foo'))
+        .then(() => db.save('bar', 1))
+        .then(() => db.remove('bar'))
+        .then(() => { expect(recorded).to.eql(changes); })
+        .then(() => db.clear())
+        .then(done)
+        .catch(done);
     });
 
   });
@@ -236,4 +272,30 @@ describe('StateDB', () => {
 
   });
 
+  describe('#toJSON()', () => {
+
+    it('return the full contents of a state database', done => {
+      let { localStorage } = window;
+
+      let db = new StateDB({ namespace: 'test-namespace' });
+      let contents: ReadonlyJSONObject = {
+        abc: 'def',
+        ghi: 'jkl',
+        mno: 1,
+        pqr: {
+          foo: { bar: { baz: 'qux' } }
+        }
+      };
+
+      expect(localStorage.length).to.be(0);
+      Promise.all(Object.keys(contents).map(key => db.save(key, contents[key])))
+        .then(() => db.toJSON())
+        .then(serialized => { expect(serialized).to.eql(contents); })
+        .then(() => db.clear())
+        .then(done)
+        .catch(done);
+    });
+
+  });
+
 });