Просмотр исходного кода

Merge pull request #2585 from blink1073/settings-backend

Settings backend
Steven Silvester 7 лет назад
Родитель
Сommit
b1af1dbd1a
36 измененных файлов с 1341 добавлено и 601 удалено
  1. 1 0
      .gitignore
  2. 1 0
      MANIFEST.in
  3. 42 10
      docs/extensions_dev.md
  4. 44 4
      jupyterlab/commands.py
  5. 30 0
      jupyterlab/copy-schemas.js
  6. 17 1
      jupyterlab/extension.py
  7. 11 6
      jupyterlab/labapp.py
  8. 4 0
      jupyterlab/make-release.js
  9. 1 0
      jupyterlab/package.app.json
  10. 3 1
      jupyterlab/package.json
  11. 99 0
      jupyterlab/settings_handler.py
  12. 2 1
      packages/apputils-extension/package.json
  13. 63 11
      packages/apputils-extension/src/index.ts
  14. 0 88
      packages/apputils-extension/src/settingclientdataconnector.ts
  15. 6 2
      packages/codemirror-extension/package.json
  16. 11 0
      packages/codemirror-extension/schema/jupyter.services.codemirror-commands.json
  17. 16 38
      packages/codemirror-extension/src/index.ts
  18. 20 30
      packages/coreutils/src/settingregistry.ts
  19. 6 2
      packages/fileeditor-extension/package.json
  20. 21 0
      packages/fileeditor-extension/schema/jupyter.services.editor-tracker.json
  21. 15 42
      packages/fileeditor-extension/src/index.ts
  22. 23 21
      packages/services/src/contents/index.ts
  23. 1 0
      packages/services/src/index.ts
  24. 26 4
      packages/services/src/manager.ts
  25. 141 0
      packages/services/src/setting/index.ts
  26. 1 1
      packages/services/src/terminal/default.ts
  27. 1 1
      packages/services/src/terminal/terminal.ts
  28. 44 0
      packages/services/test/src/setting/manager.spec.ts
  29. 1 1
      packages/services/test/src/terminal/manager.spec.ts
  30. 1 1
      packages/services/test/src/terminal/terminal.spec.ts
  31. 17 9
      packages/settingeditor-extension/src/settingeditor.ts
  32. 10 3
      packages/shortcuts-extension/package.json
  33. 582 0
      packages/shortcuts-extension/schema/jupyter.extensions.shortcuts.json
  34. 76 320
      packages/shortcuts-extension/src/index.ts
  35. 1 1
      setup.py
  36. 3 3
      setupbase.py

+ 1 - 0
.gitignore

@@ -3,6 +3,7 @@ build
 dist
 lib
 jupyterlab/build
+jupyterlab/schemas
 
 node_modules
 .cache

+ 1 - 0
MANIFEST.in

@@ -1,4 +1,5 @@
 recursive-include jupyterlab/build *
+recursive-include jupyterlab/schemas *
 
 include package.json
 include LICENSE

+ 42 - 10
docs/extensions_dev.md

@@ -4,9 +4,9 @@ JupyterLab can be extended in three ways via:
 
 - **application plugins (top level):** Application plugins extend the
   functionality of JupyterLab itself.
-- **mime renderer extension (top level):**  Mime Renderer extensions are 
+- **mime renderer extension (top level):**  Mime Renderer extensions are
   a convenience for creating an extension that can render mime data and
-  potentially render files of a given type. 
+  potentially render files of a given type.
 - document widget extensions (lower level): Document widget extensions extend
   the functionality of document widgets added to the application, and we cover
   them in the "Documents" tutorial.
@@ -68,7 +68,6 @@ information like resize events to flow through the widget hierarchy in
 the application.  **Phosphor signals** are a *one-to-many* interaction that allow
 listeners to react to changes in an observed object.
 
-
 ## Extension Authoring
 An Extension is a valid [npm package](https://docs.npmjs.com/getting-started/what-is-npm) that meets the following criteria:
   - Exports one or more JupyterLab plugins as the default export in its
@@ -140,19 +139,18 @@ provide a script that runs `jupyter labextension install` against a
 local folder path on the user's machine or a provided tarball.  Any
 valid `npm install` specifier can be used in `jupyter labextension install` (e.g. `foo@latest`, `bar@3.0.0.0`, `path/to/folder`, and `path/to/tar.gz`).
 
-
 ## Mime Renderer Extensions
-Mime Renderer extensions are a convenience for creating an extension that can 
-render mime data and potentially render files of a given type. 
+Mime Renderer extensions are a convenience for creating an extension that can
+render mime data and potentially render files of a given type.
 
 Mime renderer extensions are more declarative than standard extensions.
-The extension is treated the same from the command line perspective (`install` 
-and `link`), but it does not directly create JupyterLab plugins.  Instead it 
-exports an interface given in the [rendermime-interfaces](http://jupyterlab.github.io/jupyterlab/interfaces/_rendermime_interfaces_src_index_.irendermime.iextension.html) 
+The extension is treated the same from the command line perspective (`install`
+and `link`), but it does not directly create JupyterLab plugins.  Instead it
+exports an interface given in the [rendermime-interfaces](http://jupyterlab.github.io/jupyterlab/interfaces/_rendermime_interfaces_src_index_.irendermime.iextension.html)
 package.
 
 The JupyterLab repo has an example mime renderer extension for [vega2](https://github.com/jupyterlab/jupyterlab/tree/master/packages/vega2-extension).  It
-provides a mime renderer for [vega](https://vega.github.io/vega/) data and 
+provides a mime renderer for [vega](https://vega.github.io/vega/) data and
 registers itself as a document renderer for vega file types.
 
 The `rendermime-interfaces` package is intended to be the only JupyterLab
@@ -161,3 +159,37 @@ in TypeScript or as a form of documentation if using plain JavaScript).
 
 The only other difference from a standard extension is that has a `jupyterlab`
 key in its `package.json` with `"mimeRenderer": true` metadata.
+
+## Storing Extension Data
+In addition to the file system that is accessed by using the `@jupyterlab/services` package, JupyterLab offers two ways for extensions to store data: a client-side state database that is built on top of `localStorage` and a plugin settings system that allows for default setting values and user overrides.
+
+### State Database
+The state database can be accessed by importing `IStateDB` from `@jupyterlab/coreutils` and adding it to the list of `requires` for a plugin:
+```typescript
+const id = 'foo-author.services.foo';
+
+const IFoo = new Token<IFoo>(id);
+
+interface IFoo {}
+
+class Foo implements IFoo {}
+
+const plugin: JupyterLabPlugin<IFoo> = {
+  id,
+  requires: [IStateDB],
+  provides: IFoo,
+  activate: (app: JupyterLab, state: IStateDB): IFoo => {
+    const foo = new Foo();
+    const key = `${id}:some-attribute`;
+
+    // Load the saved plugin state and apply it once the app
+    // has finished restoring its former layout.
+    Promise.all([state.fetch(key), app.restored])
+      .then(([saved]) => { /* Update `foo` with `saved`. */ });
+
+    // Fulfill the plugin contract by returning an `IFoo`.
+    return foo;
+  },
+  autoStart: true
+};
+```

+ 44 - 4
jupyterlab/commands.py

@@ -17,7 +17,7 @@ from subprocess import check_output, CalledProcessError, STDOUT
 import shutil
 import sys
 import tarfile
-from jupyter_core.paths import ENV_JUPYTER_PATH
+from jupyter_core.paths import ENV_JUPYTER_PATH, jupyter_config_path
 from notebook.nbextensions import (
     GREEN_ENABLED, GREEN_OK, RED_DISABLED, RED_X
 )
@@ -45,6 +45,16 @@ def get_app_dir(app_dir=None):
     return os.path.realpath(app_dir)
 
 
+def get_user_settings_dir():
+    """Get the configured JupyterLab app directory.
+    """
+    settings_dir = os.environ.get('JUPYTERLAB_SETTINGS_DIR')
+    settings_dir = settings_dir or pjoin(
+        jupyter_config_path()[0], 'lab', 'user-settings'
+    )
+    return os.path.realpath(settings_dir)
+
+
 def run(cmd, **kwargs):
     """Run a command in the given working directory.
     """
@@ -129,6 +139,13 @@ def install_extension(extension, app_dir=None, logger=None):
     if os.path.exists(target):
         shutil.rmtree(target)
 
+    # Handle any schemas.
+    schema_data = data['jupyterlab'].get('schema_data', dict())
+    for (key, value) in schema_data.items():
+        path = pjoin(app_dir, 'schemas', key + '.json')
+        with open(path, 'w') as fid:
+            fid.write(value)
+
 
 def link_package(path, app_dir=None, logger=None):
     """Link a package against the JupyterLab build.
@@ -659,11 +676,13 @@ def _ensure_package(app_dir, name=None, version=None, logger=None):
 
     # Look for mismatched version.
     pkg_path = pjoin(staging, 'package.json')
+    version_updated = False
     if os.path.exists(pkg_path):
         with open(pkg_path) as fid:
             data = json.load(fid)
         if data['jupyterlab'].get('version', '') != __version__:
             shutil.rmtree(staging)
+            version_updated = True
 
     if not os.path.exists(staging):
         os.makedirs(staging)
@@ -707,12 +726,21 @@ def _ensure_package(app_dir, name=None, version=None, logger=None):
     if version:
         data['jupyterlab']['version'] = version
 
-    data['scripts']['build'] = 'webpack'
-
     pkg_path = pjoin(staging, 'package.json')
     with open(pkg_path, 'w') as fid:
         json.dump(data, fid, indent=4)
 
+    # Copy any missing or outdated schema files.
+    schema_local = pjoin(here, 'schemas')
+    schema_app = pjoin(app_dir, 'schemas')
+    if not os.path.exists(schema_app):
+        os.makedirs(schema_app)
+
+    for schema in os.listdir(schema_local):
+        dest = pjoin(schema_app, schema)
+        if version_updated or not os.path.exists(dest):
+            shutil.copy(pjoin(schema_local, schema), dest)
+
 
 def _is_extension(data):
     """Detect if a package is an extension using its metadata.
@@ -822,7 +850,19 @@ def _read_package(target):
     """
     tar = tarfile.open(target, "r:gz")
     f = tar.extractfile('package/package.json')
-    return json.loads(f.read().decode('utf8'))
+    data = json.loads(f.read().decode('utf8'))
+    jlab = data.get('jupyterlab', None)
+    if not jlab:
+        return data
+    schemas = jlab.get('schemas', None)
+    if not schemas:
+        return data
+    schema_data = dict()
+    for schema in schemas:
+        f = tar.extractfile('package/' + schema)
+        schema_data[schema] = f.read().decode('utf8')
+    data['jupyterlab']['schema_data'] = schema_data
+    return data
 
 
 def _normalize_path(extension):

+ 30 - 0
jupyterlab/copy-schemas.js

@@ -0,0 +1,30 @@
+var childProcess = require('child_process');
+var fs = require('fs-extra');
+var glob = require('glob');
+var path = require('path');
+var sortPackageJson = require('sort-package-json');
+
+var schemaDir = path.resolve('./schemas');
+fs.removeSync(schemaDir);
+fs.ensureDirSync(schemaDir);
+
+var basePath = path.resolve('..');
+var packages = glob.sync(path.join(basePath, 'packages/*'));
+packages.forEach(function(packagePath) {
+   var dataPath = path.join(packagePath, 'package.json');
+   try {
+    var data = require(dataPath);
+  } catch (e) {
+    return;
+  }
+  var schemas = data['jupyterlab'] && data['jupyterlab']['schemas'];
+  if (!schemas) {
+    return;
+  }
+  schemas.forEach(function(schemaPath) {
+    var file = path.basename(schemaPath);
+    var from = path.join(packagePath, schemaPath)
+    var to = path.join(basePath, 'jupyterlab', 'schemas', file);
+    fs.copySync(from, to);
+  });
+});

+ 17 - 1
jupyterlab/extension.py

@@ -7,7 +7,10 @@ import os
 
 from jupyterlab_launcher import add_handlers, LabConfig
 
-from .commands import get_app_dir, list_extensions, should_build
+from .commands import (
+    get_app_dir, list_extensions, should_build, get_user_settings_dir
+)
+from .settings_handler import settings_path, SettingsHandler
 from ._version import __version__
 
 #-----------------------------------------------------------------------------
@@ -89,3 +92,16 @@ def load_jupyter_server_extension(nbapp):
         nbapp.log.info(CORE_NOTE.strip())
 
     add_handlers(web_app, config)
+
+    user_settings_dir = get_user_settings_dir()
+
+    if core_mode:
+        schemas_dir = os.path.join(here, 'schemas')
+    else:
+        schemas_dir = os.path.join(app_dir, 'schemas')
+
+    settings_handler = (settings_path, SettingsHandler, {
+        'schemas_dir': schemas_dir,
+        'settings_dir': user_settings_dir
+    })
+    web_app.add_handlers(".*$", [settings_handler])

+ 11 - 6
jupyterlab/labapp.py

@@ -11,7 +11,7 @@ from traitlets import Bool, Unicode
 
 from ._version import __version__
 from .extension import load_jupyter_server_extension
-from .commands import build, clean, get_app_dir
+from .commands import build, clean, get_app_dir, get_user_settings_dir
 
 
 build_aliases = dict(base_aliases)
@@ -68,13 +68,17 @@ class LabCleanApp(JupyterApp):
 class LabPathApp(JupyterApp):
     version = __version__
     description = """
-    Print the configured path to the JupyterLab application
+    Print the configured paths for the JupyterLab application
 
-    The path can be configured using the JUPYTERLAB_DIR environment variable.
+    The application path can be configured using the JUPYTERLAB_DIR environment variable.
+    The user settings path can be configured using the JUPYTERLAB_SETTINGS_DIR
+        environment variable or it will fall back to
+        `/lab/user-settings` in the default Jupyter configuration directory.
     """
 
     def start(self):
-        print(get_app_dir())
+        print('Application directory:   %s' % get_app_dir())
+        print('User Settings directory: %s' % get_user_settings_dir())
 
 
 lab_aliases = dict(aliases)
@@ -103,7 +107,7 @@ class LabApp(NotebookApp):
     JupyterLab has three different modes of running:
 
     * Core mode (`--core-mode`): in this mode JupyterLab will run using the JavaScript
-      assets contained in the installed `jupyterlab` Python package. In core mode, no 
+      assets contained in the installed `jupyterlab` Python package. In core mode, no
       extensions are enabled. This is the default in a stable JupyterLab release if you
       have no extensions installed.
     * Dev mode (`--dev-mode`): like core mode, but when the `jupyterlab` Python package
@@ -129,7 +133,8 @@ class LabApp(NotebookApp):
     subcommands = dict(
         build=(LabBuildApp, LabBuildApp.description.splitlines()[0]),
         clean=(LabCleanApp, LabCleanApp.description.splitlines()[0]),
-        path=(LabPathApp, LabPathApp.description.splitlines()[0])
+        path=(LabPathApp, LabPathApp.description.splitlines()[0]),
+        paths=(LabPathApp, LabPathApp.description.splitlines()[0])
     )
 
     default_url = Unicode('/lab', config=True,

+ 4 - 0
jupyterlab/make-release.js

@@ -51,6 +51,10 @@ data['jupyterlab']['version'] = version;
 updateDependencies(data);
 var text = JSON.stringify(sortPackageJson(data), null, 2) + '\n';
 fs.writeFileSync('./package.json', text);
+
+// Update the build script.
+text = JSON.stringify(sortPackageJson(data), null, 2) + '\n';
+data['scripts']['build'] = 'webpack'
 fs.writeFileSync('./package.app.json', text);
 
 // Update our app index file.

+ 1 - 0
jupyterlab/package.app.json

@@ -142,6 +142,7 @@
     "mimeExtensions": [
       "@jupyterlab/vega2-extension"
     ],
+    "name": "JupyterLab",
     "singletonPackages": [
       "@jupyterlab/application",
       "@jupyterlab/apputils",

+ 3 - 1
jupyterlab/package.json

@@ -2,7 +2,7 @@
   "name": "@jupyterlab/application-top",
   "version": "0.8.4",
   "scripts": {
-    "build": "webpack",
+    "build": "node copy-schemas.js && webpack",
     "publish": "node make-release.js"
   },
   "dependencies": {
@@ -102,6 +102,7 @@
     "css-loader": "^0.27.3",
     "file-loader": "^0.10.1",
     "fs-extra": "^2.1.2",
+    "glob": "^7.1.2",
     "handlebars": "^4.0.6",
     "json-loader": "^0.5.4",
     "sort-package-json": "^1.7.0",
@@ -140,6 +141,7 @@
     "mimeExtensions": [
       "@jupyterlab/vega2-extension"
     ],
+    "name": "JupyterLab",
     "singletonPackages": [
       "@jupyterlab/application",
       "@jupyterlab/apputils",

+ 99 - 0
jupyterlab/settings_handler.py

@@ -0,0 +1,99 @@
+"""Tornado handlers for frontend config storage."""
+
+# Copyright (c) Jupyter Development Team.
+# Distributed under the terms of the Modified BSD License.
+import json
+import os
+from tornado import web
+
+from notebook.base.handlers import APIHandler, json_errors
+
+try:
+    from jsonschema import ValidationError
+    from jsonschema import Draft4Validator as Validator
+except ImportError:
+    Validator = None
+
+
+class SettingsHandler(APIHandler):
+
+    def initialize(self, schemas_dir, settings_dir):
+        self.schemas_dir = schemas_dir
+        self.settings_dir = settings_dir
+
+    @json_errors
+    @web.authenticated
+    def get(self, section_name):
+        self.set_header('Content-Type', "application/json")
+        path = os.path.join(self.schemas_dir, section_name + ".json")
+
+        if not os.path.exists(path):
+            raise web.HTTPError(404, "Schema not found: %r" % section_name)
+        with open(path) as fid:
+            # Attempt to load the schema file.
+            try:
+                schema = json.load(fid)
+            except Exception as e:
+                name = section_name
+                message = "Failed parsing schema ({}): {}".format(name, str(e))
+                raise web.HTTPError(500, message)
+
+        path = os.path.join(self.settings_dir, section_name + '.json')
+        settings = dict()
+        if os.path.exists(path):
+            with open(path) as fid:
+                # Attempt to load the settings file.
+                try:
+                    settings = json.load(fid)
+                except Exception as e:
+                    self.log.warn(str(e))
+
+        # Validate the data against the schema.
+        if Validator is not None and len(settings):
+            validator = Validator(schema)
+            try:
+                validator.validate(settings)
+            except ValidationError as e:
+                self.log.warn(str(e))
+                settings = dict()
+
+        resp = dict(id=section_name, data=dict(user=settings), schema=schema)
+        self.finish(json.dumps(resp))
+
+    @json_errors
+    @web.authenticated
+    def patch(self, section_name):
+        if not self.settings_dir:
+            raise web.HTTPError(404, "No current settings directory")
+
+        path = os.path.join(self.schemas_dir, section_name + '.json')
+
+        if not os.path.exists(path):
+            raise web.HTTPError(404, "Schema not found for: %r" % section_name)
+
+        data = self.get_json_body()  # Will raise 400 if content is not valid JSON
+
+        # Validate the data against the schema.
+        if Validator is not None:
+            with open(path) as fid:
+                schema = json.load(fid)
+            validator = Validator(schema)
+            try:
+                validator.validate(data)
+            except ValidationError as e:
+                raise web.HTTPError(400, str(e))
+
+        # Create the settings dir as needed.
+        if not os.path.exists(self.settings_dir):
+            os.makedirs(self.settings_dir)
+
+        path = os.path.join(self.settings_dir, section_name + '.json')
+
+        with open(path, 'w') as fid:
+            json.dump(data, fid)
+
+        self.set_status(204)
+
+
+# The path for a lab settings section.
+settings_path = r"/lab/api/settings/(?P<section_name>[\w.-]+)"

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

@@ -14,7 +14,8 @@
   "dependencies": {
     "@jupyterlab/application": "^0.8.3",
     "@jupyterlab/apputils": "^0.8.2",
-    "@jupyterlab/coreutils": "^0.8.1"
+    "@jupyterlab/coreutils": "^0.8.1",
+    "@jupyterlab/services": "^0.47.1"
   },
   "devDependencies": {
     "rimraf": "^2.5.2",

+ 63 - 11
packages/apputils-extension/src/index.ts

@@ -12,9 +12,13 @@ import {
 } from '@jupyterlab/apputils';
 
 import {
-  ISettingRegistry, IStateDB, SettingRegistry, StateDB
+  IDataConnector, ISettingRegistry, IStateDB, SettingRegistry, StateDB
 } from '@jupyterlab/coreutils';
 
+import {
+  IServiceManager, ServerConnection
+} from '@jupyterlab/services';
+
 import {
   JSONObject
 } from '@phosphor/coreutils';
@@ -27,10 +31,6 @@ import {
   activatePalette
 } from './palette';
 
-import {
-  SettingClientDataConnector
-} from './settingclientdataconnector';
-
 
 /**
  * The command IDs used by the apputils plugin.
@@ -41,6 +41,57 @@ namespace CommandIDs {
 };
 
 
+/**
+ * Convert an API `XMLHTTPRequest` error to a simple error.
+ */
+function apiError(id: string, xhr: XMLHttpRequest): Error {
+  let message: string;
+
+  try {
+    message = JSON.parse(xhr.response).message;
+  } catch (error) {
+    message = `Error accessing ${id} HTTP ${xhr.status} ${xhr.statusText}`;
+  }
+
+  return new Error(message);
+}
+
+
+/**
+ * Create a data connector to access plugin settings.
+ */
+function newConnector(manager: IServiceManager): IDataConnector<ISettingRegistry.IPlugin, JSONObject> {
+  return {
+    /**
+     * Retrieve a saved bundle from the data connector.
+     */
+    fetch(id: string): Promise<ISettingRegistry.IPlugin> {
+      return manager.settings.fetch(id).catch(reason => {
+        throw apiError(id, (reason as ServerConnection.IError).xhr);
+      });
+    },
+
+    /**
+     * Remove a value from the data connector.
+     */
+    remove(): Promise<void> {
+      const message = 'Removing setting resources is not supported.';
+
+      return Promise.reject(new Error(message));
+    },
+
+    /**
+     * Save the user setting data in the data connector.
+     */
+    save(id: string, user: JSONObject): Promise<void> {
+      return manager.settings.save(id, user).catch(reason => {
+        throw apiError(id, (reason as ServerConnection.IError).xhr);
+      });
+    }
+  };
+}
+
+
 /**
  * A service providing an interface to the main menu.
  */
@@ -52,7 +103,8 @@ const mainMenuPlugin: JupyterLabPlugin<IMainMenu> = {
     menu.id = 'jp-MainMenu';
 
     let logo = new Widget();
-    logo.node.className = 'jp-MainAreaPortraitIcon jp-JupyterIcon';
+    logo.addClass('jp-MainAreaPortraitIcon');
+    logo.addClass('jp-JupyterIcon');
     logo.id = 'jp-MainLogo';
 
     app.shell.addToTopArea(logo);
@@ -80,12 +132,12 @@ const palettePlugin: JupyterLabPlugin<ICommandPalette> = {
  */
 const settingPlugin: JupyterLabPlugin<ISettingRegistry> = {
   id: 'jupyter.services.setting-registry',
-  activate: () => new SettingRegistry({
-    connector: new SettingClientDataConnector(),
-    preload: SettingClientDataConnector.preload
-  }),
+  activate: (app: JupyterLab, services: IServiceManager): ISettingRegistry => {
+    return new SettingRegistry({ connector: newConnector(services) });
+  },
   autoStart: true,
-  provides: ISettingRegistry
+  provides: ISettingRegistry,
+  requires: [IServiceManager]
 };
 
 

+ 0 - 88
packages/apputils-extension/src/settingclientdataconnector.ts

@@ -1,88 +0,0 @@
-/*-----------------------------------------------------------------------------
-| Copyright (c) Jupyter Development Team.
-| Distributed under the terms of the Modified BSD License.
-|----------------------------------------------------------------------------*/
-
-import {
-  IDataConnector, ISettingRegistry, StateDB
-} from '@jupyterlab/coreutils';
-
-import {
-  JSONObject
-} from '@phosphor/coreutils';
-
-
-/**
- * A client-side data connector for setting schemas.
- *
- * #### Notes
- * This class is deprecated. Its use is only as a storage mechanism of settings
- * data while an API for server-side persistence is being implemented.
- */
-export
-class SettingClientDataConnector extends StateDB implements IDataConnector<ISettingRegistry.IPlugin, JSONObject> {
-  /**
-   * Create a new setting client data connector.
-   */
-  constructor() {
-    super({ namespace: 'setting-client-data-connector' });
-  }
-
-  /**
-   * Retrieve a saved bundle from the data connector.
-   */
-  fetch(id: string): Promise<ISettingRegistry.IPlugin | undefined> {
-    return super.fetch(id).then(user => {
-      if (!user && !Private.schemas[id]) {
-        return undefined;
-      }
-
-      user = user || { };
-
-      const schema = Private.schemas[id] || { type: 'object' };
-      const result = { data: { composite: { }, user }, id, schema };
-
-      return result;
-    });
-  }
-
-  /**
-   * Remove a value from the data connector.
-   */
-  remove(id: string): Promise<void> {
-    return super.remove(id);
-  }
-
-  /**
-   * Save the user setting data in the data connector.
-   */
-  save(id: string, user: JSONObject): Promise<void> {
-    return super.save(id, user);
-  }
-}
-
-
-/**
- * A namespace for `SettingClientDataConnector` statics.
- */
-export
-namespace SettingClientDataConnector {
-  /**
-   * Preload the schema for a plugin.
-   */
-  export
-  function preload(plugin: string, schema: ISettingRegistry.ISchema): void {
-    Private.schemas[plugin] = schema;
-  }
-}
-
-
-/**
- * A namespace for private module data.
- */
-namespace Private {
-  /* tslint:disable */
-  export
-  const schemas: { [key: string]: ISettingRegistry.ISchema } = { };
-  /* tslint:enable */
-}

+ 6 - 2
packages/codemirror-extension/package.json

@@ -6,7 +6,8 @@
   "types": "lib/index.d.ts",
   "files": [
     "lib/*.d.ts",
-    "lib/*.js"
+    "lib/*.js",
+    "schema/*.json"
   ],
   "directories": {
     "lib": "lib/"
@@ -30,7 +31,10 @@
     "watch": "tsc -w"
   },
   "jupyterlab": {
-    "extension": true
+    "extension": true,
+    "schemas": [
+      "schema/jupyter.services.codemirror-commands.json"
+    ]
   },
   "repository": {
     "type": "git",

+ 11 - 0
packages/codemirror-extension/schema/jupyter.services.codemirror-commands.json

@@ -0,0 +1,11 @@
+{
+  "jupyter.lab.setting-icon-class": "jp-TextEditorIcon",
+  "jupyter.lab.setting-icon-label": "CodeMirror",
+  "title": "CodeMirror",
+  "description": "Text editor settings for all CodeMirror editors.",
+  "properties": {
+    "keyMap": { "type": "string", "title": "Key Map", "default": "default" },
+    "theme": { "type": "string", "title": "Theme", "default": "default" }
+  },
+  "type": "object"
+}

+ 16 - 38
packages/codemirror-extension/src/index.ts

@@ -49,32 +49,12 @@ namespace CommandIDs {
 };
 
 
-/* tslint:disable */
-/**
- * The commands plugin setting schema.
- *
- * #### Notes
- * This will eventually reside in its own settings file.
- */
-const schema = {
-  "jupyter.lab.setting-icon-class": "jp-TextEditorIcon",
-  "jupyter.lab.setting-icon-label": "CodeMirror",
-  "title": "CodeMirror",
-  "description": "Text editor settings for all CodeMirror editors.",
-  "properties": {
-    "keyMap": { "type": "string", "title": "Key Map", "default": "default" },
-    "theme": { "type": "string", "title": "Theme", "default": "default" }
-  },
-  "type": "object"
-};
-/* tslint:enable */
-
 /**
  * The editor services.
  */
 export
 const servicesPlugin: JupyterLabPlugin<IEditorServices> = {
-  id: IEditorServices.name,
+  id: 'jupyter.services.codemirror-services',
   provides: IEditorServices,
   activate: (): IEditorServices => editorServices
 };
@@ -128,9 +108,6 @@ function activateEditorCommands(app: JupyterLab, tracker: IEditorTracker, mainMe
     });
   }
 
-  // Preload the settings schema into the registry. This is deprecated.
-  settingRegistry.preload(id, schema);
-
   // Fetch the initial state of the settings.
   Promise.all([settingRegistry.load(id), restored]).then(([settings]) => {
     updateSettings(settings);
@@ -139,6 +116,9 @@ function activateEditorCommands(app: JupyterLab, tracker: IEditorTracker, mainMe
       updateSettings(settings);
       updateTracker();
     });
+  }).catch((reason: Error) => {
+    console.error(reason.message);
+    updateTracker();
   });
 
   /**
@@ -185,14 +165,13 @@ function activateEditorCommands(app: JupyterLab, tracker: IEditorTracker, mainMe
     commands.addCommand(CommandIDs.changeTheme, {
       label: args => args['theme'] as string,
       execute: args => {
-        theme = args['theme'] as string || theme;
-        tracker.forEach(widget => {
-          if (widget.editor instanceof CodeMirrorEditor) {
-            let cm = widget.editor.editor;
-            cm.setOption('theme', theme);
-          }
+        const key = 'theme';
+        const value = theme = args['theme'] as string || theme;
+
+        updateTracker();
+        return settingRegistry.set(id, key, value).catch((reason: Error) => {
+          console.error(`Failed to set ${id}:${key} - ${reason.message}`);
         });
-        return settingRegistry.set(id, 'theme', theme as string);
       },
       isEnabled: hasWidget,
       isToggled: args => args['theme'] === theme
@@ -204,14 +183,13 @@ function activateEditorCommands(app: JupyterLab, tracker: IEditorTracker, mainMe
         return title === 'sublime' ? 'Sublime Text' : title;
       },
       execute: args => {
-        keyMap = args['keyMap'] as string || keyMap;
-        tracker.forEach(widget => {
-          if (widget.editor instanceof CodeMirrorEditor) {
-            let cm = widget.editor.editor;
-            cm.setOption('keyMap', keyMap);
-          }
+        const key = 'keyMap';
+        const value = keyMap = args['keyMap'] as string || keyMap;
+
+        updateTracker();
+        return settingRegistry.set(id, key, value).catch((reason: Error) => {
+          console.error(`Failed to set ${id}:${key} - ${reason.message}`);
         });
-        return settingRegistry.set(id, 'keyMap', keyMap as string);
       },
       isEnabled: hasWidget,
       isToggled: args => args['keyMap'] === keyMap

+ 20 - 30
packages/coreutils/src/settingregistry.ts

@@ -85,6 +85,11 @@ namespace ISchemaValidator {
    */
   export
   interface IError {
+    /**
+     * The path in the data where the error occurred.
+     */
+    dataPath: string;
+
     /**
      * The keyword whose validation failed.
      */
@@ -402,7 +407,6 @@ class SettingRegistry {
   constructor(options: SettingRegistry.IOptions) {
     this._connector = options.connector;
     this._validator = options.validator || new DefaultSchemaValidator();
-    this._preload = options.preload || (() => { /* no op */ });
   }
 
   /**
@@ -475,21 +479,6 @@ class SettingRegistry {
     return this.reload(plugin);
   }
 
-  /**
-   * Preload the schema for a plugin.
-   *
-   * @param plugin - The plugin ID.
-   *
-   * @param schema - The schema being added.
-   *
-   * #### Notes
-   * This method is deprecated and is only intented for use until there is a
-   * server-side API for storing setting data.
-   */
-  preload(plugin: string, schema: ISettingRegistry.ISchema): void {
-    this._preload(plugin, schema);
-  }
-
   /**
    * Reload a plugin's settings into the registry even if they already exist.
    *
@@ -504,12 +493,23 @@ class SettingRegistry {
 
     // If the plugin needs to be loaded from the connector, fetch.
     return connector.fetch(plugin).then(data => {
-      if (!data) {
-        const message = `Setting data for ${plugin} does not exist.`;
-        throw [{ keyword: '', message, schemaPath: '' }];
+      // Validate the response from the connector; populate `composite` field.
+      try {
+        this._validate(data);
+      } catch (errors) {
+        const output = [`Validating ${plugin} failed:`];
+        (errors as ISchemaValidator.IError[]).forEach((error, index) => {
+          const { dataPath, schemaPath, keyword, message } = error;
+          output.push(`${index} - schema @ ${schemaPath}, data @ ${dataPath}`);
+          output.push(`\t${keyword} ${message}`);
+        });
+        console.error(output.join('\n'));
+
+        throw new Error(`Failed validating ${plugin}`);
       }
 
-      this._validate(data);
+      // Emit that a plugin has changed.
+      this._pluginChanged.emit(plugin);
 
       return new Settings({
         plugin: copy(plugins[plugin]) as ISettingRegistry.IPlugin,
@@ -635,7 +635,6 @@ class SettingRegistry {
   private _connector: IDataConnector<ISettingRegistry.IPlugin, JSONObject>;
   private _pluginChanged = new Signal<this, string>(this);
   private _plugins: { [name: string]: ISettingRegistry.IPlugin } = Object.create(null);
-  private _preload: (plugin: string, schema: ISettingRegistry.ISchema) => void;
   private _validator: ISchemaValidator;
 }
 
@@ -823,15 +822,6 @@ namespace SettingRegistry {
      */
     connector: IDataConnector<ISettingRegistry.IPlugin, JSONObject>;
 
-    /**
-     * A function that preloads a plugin's schema in the client-side cache.
-     *
-     * #### Notes
-     * This param is deprecated and is only intented for use until there is a
-     * server-side API for storing setting data.
-     */
-    preload?: (plugin: string, schema: ISettingRegistry.ISchema) => void;
-
     /**
      * The validator used to enforce the settings JSON schema.
      */

+ 6 - 2
packages/fileeditor-extension/package.json

@@ -6,7 +6,8 @@
   "types": "lib/index.d.ts",
   "files": [
     "lib/*.d.ts",
-    "lib/*.js"
+    "lib/*.js",
+    "schema/*.json"
   ],
   "directories": {
     "lib": "lib/"
@@ -30,7 +31,10 @@
     "watch": "tsc -w"
   },
   "jupyterlab": {
-    "extension": true
+    "extension": true,
+    "schemas": [
+      "schema/jupyter.services.editor-tracker.json"
+    ]
   },
   "repository": {
     "type": "git",

+ 21 - 0
packages/fileeditor-extension/schema/jupyter.services.editor-tracker.json

@@ -0,0 +1,21 @@
+{
+  "jupyter.lab.setting-icon-class": "jp-TextEditorIcon",
+  "jupyter.lab.setting-icon-label": "Editor",
+  "title": "Text Editor",
+  "description": "Text editor settings for all editors.",
+  "properties": {
+    "autoClosingBrackets": {
+      "type": "boolean", "title": "Autoclosing Brackets", "default": true
+    },
+    "lineNumbers": {
+      "type": "boolean", "title": "Line Numbers", "default": true
+    },
+    "lineWrap": {
+      "type": "boolean", "title": "Line Wrap", "default": false
+    },
+    "matchBrackets": {
+      "type": "boolean", "title": "Match Brackets", "default": true
+    }
+  },
+  "type": "object"
+}

+ 15 - 42
packages/fileeditor-extension/src/index.ts

@@ -88,37 +88,6 @@ const plugin: JupyterLabPlugin<IEditorTracker> = {
 };
 
 
-/* tslint:disable */
-/**
- * The commands plugin setting schema.
- *
- * #### Notes
- * This will eventually reside in its own settings file.
- */
-const schema = {
-  "jupyter.lab.setting-icon-class": "jp-TextEditorIcon",
-  "jupyter.lab.setting-icon-label": "Editor",
-  "title": "Text Editor",
-  "description": "Text editor settings for all editors.",
-  "properties": {
-    "autoClosingBrackets": {
-      "type": "boolean", "title": "Autoclosing Brackets", "default": true
-    },
-    "lineNumbers": {
-      "type": "boolean", "title": "Line Numbers", "default": true
-    },
-    "lineWrap": {
-      "type": "boolean", "title": "Line Wrap", "default": false
-    },
-    "matchBrackets": {
-      "type": "boolean", "title": "Match Brackets", "default": true
-    }
-  },
-  "type": "object"
-};
-/* tslint:enable */
-
-
 /**
  * Export the plugins as default.
  */
@@ -182,9 +151,6 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, editorServices: IE
     editor.setOption('autoClosingBrackets', autoClosingBrackets);
   }
 
-  // Preload the settings schema into the registry. This is deprecated.
-  settingRegistry.preload(id, schema);
-
   // Fetch the initial state of the settings.
   Promise.all([settingRegistry.load(id), restored]).then(([settings]) => {
     updateSettings(settings);
@@ -193,6 +159,9 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, editorServices: IE
       updateSettings(settings);
       updateTracker();
     });
+  }).catch((reason: Error) => {
+    console.error(reason.message);
+    updateTracker();
   });
 
   factory.widgetCreated.connect((sender, widget) => {
@@ -212,11 +181,13 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, editorServices: IE
 
   commands.addCommand(CommandIDs.lineNumbers, {
     execute: () => {
-      lineNumbers = !lineNumbers;
-      tracker.forEach(widget => {
-        widget.editor.setOption('lineNumbers', lineNumbers);
+      const key = 'lineNumbers';
+      const value = lineNumbers = !lineNumbers;
+
+      updateTracker();
+      return settingRegistry.set(id, key, value).catch((reason: Error) => {
+        console.error(`Failed to set ${id}:${key} - ${reason.message}`);
       });
-      return settingRegistry.set(id, 'lineNumbers', lineNumbers);
     },
     isEnabled: hasWidget,
     isToggled: () => lineNumbers,
@@ -225,11 +196,13 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, editorServices: IE
 
   commands.addCommand(CommandIDs.lineWrap, {
     execute: () => {
-      lineWrap = !lineWrap;
-      tracker.forEach(widget => {
-        widget.editor.setOption('lineWrap', lineWrap);
+      const key = 'lineWrap';
+      const value = lineWrap = !lineWrap;
+
+      updateTracker();
+      return settingRegistry.set(id, key, value).catch((reason: Error) => {
+        console.error(`Failed to set ${id}:${key} - ${reason.message}`);
       });
-      return settingRegistry.set(id, 'lineWrap', lineWrap);
     },
     isEnabled: hasWidget,
     isToggled: () => lineWrap,

+ 23 - 21
packages/services/src/contents/index.ts

@@ -936,8 +936,8 @@ class Drive implements Contents.IDrive {
       }
       try {
          validate.validateContentsModel(response.data);
-       } catch (err) {
-         throw ServerConnection.makeError(response, err.message);
+       } catch (error) {
+         throw ServerConnection.makeError(response, error.message);
        }
       return response.data;
     });
@@ -988,8 +988,8 @@ class Drive implements Contents.IDrive {
       let data = response.data as Contents.IModel;
       try {
         validate.validateContentsModel(data);
-      } catch (err) {
-        throw ServerConnection.makeError(response, err.message);
+      } catch (error) {
+        throw ServerConnection.makeError(response, error.message);
       }
       this._fileChanged.emit({
         type: 'new',
@@ -1065,8 +1065,8 @@ class Drive implements Contents.IDrive {
       let data = response.data as Contents.IModel;
       try {
         validate.validateContentsModel(data);
-      } catch (err) {
-        throw ServerConnection.makeError(response, err.message);
+      } catch (error) {
+        throw ServerConnection.makeError(response, error.message);
       }
       this._fileChanged.emit({
         type: 'rename',
@@ -1107,8 +1107,8 @@ class Drive implements Contents.IDrive {
       let data = response.data as Contents.IModel;
       try {
         validate.validateContentsModel(data);
-      } catch (err) {
-        throw ServerConnection.makeError(response, err.message);
+      } catch (error) {
+        throw ServerConnection.makeError(response, error.message);
       }
       this._fileChanged.emit({
         type: 'save',
@@ -1147,8 +1147,8 @@ class Drive implements Contents.IDrive {
       let data = response.data as Contents.IModel;
       try {
         validate.validateContentsModel(data);
-      } catch (err) {
-        throw ServerConnection.makeError(response, err.message);
+      } catch (error) {
+        throw ServerConnection.makeError(response, error.message);
       }
       this._fileChanged.emit({
         type: 'new',
@@ -1182,8 +1182,8 @@ class Drive implements Contents.IDrive {
       let data = response.data as Contents.ICheckpointModel;
       try {
         validate.validateCheckpointModel(data);
-      } catch (err) {
-        throw ServerConnection.makeError(response, err.message);
+      } catch (error) {
+        throw ServerConnection.makeError(response, error.message);
       }
       return data;
     });
@@ -1215,9 +1215,9 @@ class Drive implements Contents.IDrive {
       }
       for (let i = 0; i < response.data.length; i++) {
         try {
-        validate.validateCheckpointModel(response.data[i]);
-        } catch (err) {
-          throw ServerConnection.makeError(response, err.message);
+          validate.validateCheckpointModel(response.data[i]);
+        } catch (error) {
+          throw ServerConnection.makeError(response, error.message);
         }
       }
       return response.data;
@@ -1279,8 +1279,7 @@ class Drive implements Contents.IDrive {
   private _getUrl(...args: string[]): string {
     let parts = args.map(path => URLExt.encodeParts(path));
     let baseUrl = this.serverSettings.baseUrl;
-    return URLExt.join(baseUrl, this._apiEndpoint,
-                       ...parts);
+    return URLExt.join(baseUrl, this._apiEndpoint, ...parts);
   }
 
   private _apiEndpoint: string;
@@ -1361,13 +1360,16 @@ namespace Private {
    */
   export
   function normalize(path: string): string {
-    let parts = path.split(':');
+    const parts = path.split(':');
+
     if (parts.length === 1) {
       return PathExt.normalize(path);
-    } else if (parts.length === 2) {
+    }
+
+    if (parts.length === 2) {
       return parts[0] + ':' + PathExt.normalize(parts[1]);
-    } else {
-      throw Error('Malformed path: '+path);
     }
+
+    throw new Error('Malformed path: ' + path);
   }
 }

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

@@ -15,6 +15,7 @@ export * from './kernel';
 export * from './manager';
 export * from './serverconnection';
 export * from './session';
+export * from './setting';
 export * from './terminal';
 
 

+ 26 - 4
packages/services/src/manager.ts

@@ -21,6 +21,10 @@ import {
   Session, SessionManager
 } from './session';
 
+import {
+  Setting, SettingManager
+} from './setting';
+
 import {
   TerminalSession, TerminalManager
 } from './terminal';
@@ -42,9 +46,12 @@ class ServiceManager implements ServiceManager.IManager {
     this.serverSettings = (
       options.serverSettings || ServerConnection.makeSettings()
     );
-    this._sessionManager = new SessionManager(options);
+
     this._contentsManager = new ContentsManager(options);
+    this._sessionManager = new SessionManager(options);
+    this._settingManager = new SettingManager(options);
     this._terminalManager = new TerminalManager(options);
+
     this._sessionManager.specsChanged.connect((sender, specs) => {
       this._specsChanged.emit(specs);
     });
@@ -63,7 +70,7 @@ class ServiceManager implements ServiceManager.IManager {
   }
 
   /**
-   * Test whether the terminal manager is disposed.
+   * Test whether the service manager is disposed.
    */
   get isDisposed(): boolean {
     return this._isDisposed;
@@ -76,11 +83,13 @@ class ServiceManager implements ServiceManager.IManager {
     if (this.isDisposed) {
       return;
     }
+
     this._isDisposed = true;
     Signal.clearData(this);
-    this._sessionManager.dispose();
+
     this._contentsManager.dispose();
     this._sessionManager.dispose();
+    this._terminalManager.dispose();
   }
 
   /**
@@ -102,6 +111,13 @@ class ServiceManager implements ServiceManager.IManager {
     return this._sessionManager;
   }
 
+  /**
+   * Get the setting manager instance.
+   */
+  get settings(): SettingManager {
+    return this._settingManager;
+  }
+
   /**
    * Get the contents manager instance.
    */
@@ -130,8 +146,9 @@ class ServiceManager implements ServiceManager.IManager {
     return this._readyPromise;
   }
 
-  private _sessionManager: SessionManager;
   private _contentsManager: ContentsManager;
+  private _sessionManager: SessionManager;
+  private _settingManager: SettingManager;
   private _terminalManager: TerminalManager;
   private _isDisposed = false;
   private _readyPromise: Promise<void>;
@@ -169,6 +186,11 @@ namespace ServiceManager {
      */
     readonly sessions: Session.IManager;
 
+    /**
+     * The setting manager for the manager.
+     */
+    readonly settings: Setting.IManager;
+
     /**
      * The contents manager for the manager.
      */

+ 141 - 0
packages/services/src/setting/index.ts

@@ -0,0 +1,141 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  ISettingRegistry, URLExt
+} from '@jupyterlab/coreutils';
+
+import {
+  JSONObject
+} from '@phosphor/coreutils';
+
+import {
+  ServerConnection
+} from '../serverconnection';
+
+
+/**
+ * The url for the lab settings service.
+ */
+const SERVICE_SETTINGS_URL = 'lab/api/settings';
+
+
+/**
+ * The static namespace for `SettingManager`.
+ */
+export
+class SettingManager {
+  /**
+   * Create a new setting manager.
+   */
+  constructor(options: SettingManager.IOptions = { }) {
+    this.serverSettings = options.serverSettings ||
+      ServerConnection.makeSettings();
+  }
+
+  /**
+   * The server settings used to make API requests.
+   */
+  readonly serverSettings: ServerConnection.ISettings;
+
+  /**
+   * Fetch a plugin's settings.
+   *
+   * @param id - The plugin's ID.
+   *
+   * @returns A promise that resolves with the plugin settings or rejects
+   * with a `ServerConnection.IError`.
+   */
+  fetch(id: string): Promise<ISettingRegistry.IPlugin> {
+    const base = this.serverSettings.baseUrl;
+    const request = { method: 'GET', url: Private.url(base, id) };
+    const { serverSettings } = this;
+    const promise = ServerConnection.makeRequest(request, serverSettings);
+
+    return promise.then(response => {
+      const { status } = response.xhr;
+
+      if (status !== 200) {
+        throw ServerConnection.makeError(response);
+      }
+
+      return response.data;
+    }).catch(reason => { throw ServerConnection.makeError(reason); });
+  }
+
+  /**
+   * Save a plugin's settings.
+   *
+   * @param id - The plugin's ID.
+   *
+   * @param user - The plugin's user setting values.
+   *
+   * @returns A promise that resolves when saving is complete or rejects
+   * with a `ServerConnection.IError`.
+   */
+  save(id: string, user: JSONObject): Promise<void> {
+    const base = this.serverSettings.baseUrl;
+    const request = {
+      data: JSON.stringify(user),
+      method: 'PATCH',
+      url: Private.url(base, id)
+    };
+    const { serverSettings } = this;
+    const promise = ServerConnection.makeRequest(request, serverSettings);
+
+    return promise.then(response => {
+      const { status } = response.xhr;
+
+      if (status !== 204) {
+        throw ServerConnection.makeError(response);
+      }
+
+      return void 0;
+    }).catch(reason => { throw ServerConnection.makeError(reason); });
+  }
+}
+
+
+/**
+ * A namespace for `SettingManager` statics.
+ */
+export
+namespace SettingManager {
+  /**
+   * The instantiation options for a setting manager.
+   */
+  export
+  interface IOptions {
+    /**
+     * The server settings used to make API requests.
+     */
+    serverSettings?: ServerConnection.ISettings;
+  }
+}
+
+
+/**
+ * A namespace for setting API interfaces.
+ */
+export
+namespace Setting {
+  /**
+   * The interface for the setting system manager.
+   */
+  export
+  interface IManager extends SettingManager { }
+}
+
+
+/**
+ * A namespace for private data.
+ */
+namespace Private {
+  /**
+   * Get the url for a plugin's settings.
+   */
+  export
+  function url(base: string, id: string): string {
+    return URLExt.join(base, SERVICE_SETTINGS_URL, id);
+  }
+}

+ 1 - 1
packages/services/src/terminal/default.ts

@@ -232,7 +232,7 @@ namespace DefaultTerminalSession {
   /**
    * Start a new terminal session.
    *
-   * @options - The session options to use.
+   * @param options - The session options to use.
    *
    * @returns A promise that resolves with the session instance.
    */

+ 1 - 1
packages/services/src/terminal/terminal.ts

@@ -100,7 +100,7 @@ namespace TerminalSession {
   /**
    * Start a new terminal session.
    *
-   * @options - The session options to use.
+   * @param options - The session options to use.
    *
    * @returns A promise that resolves with the session instance.
    */

+ 44 - 0
packages/services/test/src/setting/manager.spec.ts

@@ -0,0 +1,44 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import expect = require('expect.js');
+
+import {
+  ServerConnection, SettingManager
+} from '../../../lib';
+
+
+describe('setting', () => {
+
+  describe('SettingManager', () => {
+
+    describe('#constructor()', () => {
+
+      it('should accept no options', () => {
+        const manager = new SettingManager();
+        expect(manager).to.be.a(SettingManager);
+      });
+
+      it('should accept options', () => {
+        const manager = new SettingManager({
+          serverSettings: ServerConnection.makeSettings()
+        });
+        expect(manager).to.be.a(SettingManager);
+      });
+
+    });
+
+    describe('#serverSettings', () => {
+
+      it('should be the server settings', () => {
+        const baseUrl = 'foo';
+        const serverSettings = ServerConnection.makeSettings({ baseUrl });
+        const manager = new SettingManager({ serverSettings });
+        expect(manager.serverSettings.baseUrl).to.be(baseUrl);
+      });
+
+    });
+
+  });
+
+});

+ 1 - 1
packages/services/test/src/terminal/manager.spec.ts

@@ -20,7 +20,7 @@ import {
 } from '../utils';
 
 
-describe('terminals', () => {
+describe('terminal', () => {
 
   let tester: TerminalTester;
   let manager: TerminalSession.IManager;

+ 1 - 1
packages/services/test/src/terminal/terminal.spec.ts

@@ -24,7 +24,7 @@ import {
 } from '../utils';
 
 
-describe('terminals', () => {
+describe('terminal', () => {
 
   let tester: TerminalTester;
   let session: TerminalSession.ISession;

+ 17 - 9
packages/settingeditor-extension/src/settingeditor.ts

@@ -181,12 +181,12 @@ class SettingEditor extends Widget {
     requestAnimationFrame(() => {
       // Set the original (default) outer dimensions.
       this._panel.setRelativeSizes(this._presets.outer);
-      this._fetchState()
-        .then(() => { this._setPresets(); })
-        .catch(reason => {
-          console.error('Fetching setting editor state failed', reason);
-          this._setPresets();
-        });
+      this._fetchState().then(() => {
+        this._setPresets();
+      }).catch(reason => {
+        console.error('Fetching setting editor state failed', reason);
+        this._setPresets();
+      });
     });
   }
 
@@ -315,8 +315,8 @@ class SettingEditor extends Widget {
       }
       editor.settings = settings;
       list.selection = plugin;
-    }).catch(reason => {
-      console.error('Loading settings failed.', reason);
+    }).catch((reason: Error) => {
+      console.error(`Loading settings failed: ${reason.message}`);
       list.selection = this._presets.plugin = '';
       editor.settings = null;
     });
@@ -718,7 +718,15 @@ class PluginEditor extends Widget {
     const editor = this._editor;
     const settings = this._settings;
 
-    settings.save(editor.source.toJSON());
+    settings.save(editor.source.toJSON()).catch(reason => {
+      console.error(`Saving setting editor value failed: ${reason.message}`);
+
+      return showDialog({
+        title: 'Your changes were not saved.',
+        body: reason.message,
+        buttons: [Dialog.okButton()]
+      }).then(() => void 0);
+    });
   }
 
   private _editor: JSONEditor = null;

+ 10 - 3
packages/shortcuts-extension/package.json

@@ -6,13 +6,17 @@
   "types": "lib/index.d.ts",
   "files": [
     "lib/*.d.ts",
-    "lib/*.js"
+    "lib/*.js",
+    "schema/*.json"
   ],
   "directories": {
     "lib": "lib/"
   },
   "dependencies": {
-    "@jupyterlab/application": "^0.8.3"
+    "@jupyterlab/application": "^0.8.3",
+    "@jupyterlab/coreutils": "^0.8.1",
+    "@phosphor/commands": "^1.3.0",
+    "@phosphor/disposable": "^1.1.1"
   },
   "devDependencies": {
     "rimraf": "^2.5.2",
@@ -24,7 +28,10 @@
     "watch": "tsc -w"
   },
   "jupyterlab": {
-    "extension": true
+    "extension": true,
+    "schemas": [
+      "schema/jupyter.extensions.shortcuts.json"
+    ]
   },
   "repository": {
     "type": "git",

+ 582 - 0
packages/shortcuts-extension/schema/jupyter.extensions.shortcuts.json

@@ -0,0 +1,582 @@
+{
+  "jupyter.lab.setting-icon-class": "jp-LauncherIcon",
+  "jupyter.lab.setting-icon-label": "Keyboard Shortcuts",
+  "title": "Keyboard Shortcuts",
+  "description": "Keyboard shortcut settings for JupyterLab.",
+  "properties": {
+    "application:activate-next-tab": {
+      "default": { },
+      "properties": {
+        "command": { "default": "application:activate-next-tab" },
+        "keys": { "default": ["Ctrl Shift ]"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "application:activate-previous-tab": {
+      "default": { },
+      "properties": {
+        "command": { "default": "application:activate-previous-tab" },
+        "keys": { "default": ["Ctrl Shift ["] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "application:toggle-mode": {
+      "default": { },
+      "properties": {
+        "command": { "default": "application:toggle-mode" },
+        "keys": { "default": ["Accel Shift Enter"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "command-palette:activate": {
+      "default": { },
+      "properties": {
+        "command": { "default": "command-palette:activate" },
+        "keys": { "default": ["Accel Shift C"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "completer:invoke-console": {
+      "default": { },
+      "properties": {
+        "command": { "default": "completer:invoke-console" },
+        "keys": { "default": ["Tab"] },
+        "selector": { "default": ".jp-CodeConsole-promptCell .jp-mod-completer-enabled" }
+      },
+      "type": "object"
+    },
+    "completer:invoke-notebook": {
+      "default": { },
+      "properties": {
+        "command": { "default": "completer:invoke-notebook" },
+        "keys": { "default": ["Tab"] },
+        "selector": { "default": ".jp-Notebook.jp-mod-editMode .jp-mod-completer-enabled" }
+      },
+      "type": "object"
+    },
+    "console:linebreak": {
+      "default": { },
+      "properties": {
+        "command": { "default": "console:linebreak" },
+        "keys": { "default": ["Ctrl Enter"] },
+        "selector": { "default": ".jp-CodeConsole-promptCell" }
+      },
+      "type": "object"
+    },
+    "console:run": {
+      "default": { },
+      "properties": {
+        "command": { "default": "console:run" },
+        "keys": { "default": ["Enter"] },
+        "selector": { "default": ".jp-CodeConsole-promptCell" }
+      },
+      "type": "object"
+    },
+    "console:run-forced": {
+      "default": { },
+      "properties": {
+        "command": { "default": "console:run-forced" },
+        "keys": { "default": ["Shift Enter"] },
+        "selector": { "default": ".jp-CodeConsole-promptCell" }
+      },
+      "type": "object"
+    },
+    "docmanager:close": {
+      "default": { },
+      "properties": {
+        "command": { "default": "docmanager:close" },
+        "keys": { "default": ["Ctrl Q"] },
+        "selector": { "default": ".jp-Activity" }
+      },
+      "type": "object"
+    },
+    "docmanager:create-launcher": {
+      "default": { },
+      "properties": {
+        "command": { "default": "docmanager:create-launcher" },
+        "keys": { "default": ["Accel Shift L"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "docmanager:save": {
+      "default": { },
+      "properties": {
+        "command": { "default": "docmanager:save" },
+        "keys": { "default": ["Accel S"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "filebrowser:toggle-main": {
+      "default": { },
+      "properties": {
+        "command": { "default": "filebrowser:toggle-main" },
+        "keys": { "default": ["Accel Shift F"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "fileeditor:run-code": {
+      "default": { },
+      "properties": {
+        "command": { "default": "fileeditor:run-code" },
+        "keys": { "default": ["Shift Enter"] },
+        "selector": { "default": ".jp-FileEditor" }
+      },
+      "type": "object"
+    },
+    "help:toggle": {
+      "default": { },
+      "properties": {
+        "command": { "default": "help:toggle" },
+        "keys": { "default": ["Ctrl Shift H"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "imageviewer:reset-zoom": {
+      "default": { },
+      "properties": {
+        "command": { "default": "imageviewer:reset-zoom" },
+        "keys": { "default": ["0"] },
+        "selector": { "default": ".jp-ImageViewer" }
+      },
+      "type": "object"
+    },
+    "imageviewer:zoom-in": {
+      "default": { },
+      "properties": {
+        "command": { "default": "imageviewer:zoom-in" },
+        "keys": { "default": ["="] },
+        "selector": { "default": ".jp-ImageViewer" }
+      },
+      "type": "object"
+    },
+    "imageviewer:zoom-out": {
+      "default": { },
+      "properties": {
+        "command": { "default": "imageviewer:zoom-out" },
+        "keys": { "default": ["-"] },
+        "selector": { "default": ".jp-ImageViewer" }
+      },
+      "type": "object"
+    },
+    "inspector:open": {
+      "default": { },
+      "properties": {
+        "command": { "default": "inspector:open" },
+        "keys": { "default": ["Accel I"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "notebook:change-cell-to-code": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-cell-to-code" },
+        "keys": { "default": ["Y"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:change-to-cell-heading-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-to-cell-heading-1" },
+        "keys": { "default": ["1"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:change-to-cell-heading-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-to-cell-heading-2" },
+        "keys": { "default": ["2"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:change-to-cell-heading-3": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-to-cell-heading-3" },
+        "keys": { "default": ["3"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:change-to-cell-heading-4": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-to-cell-heading-4" },
+        "keys": { "default": ["4"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:change-to-cell-heading-5": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-to-cell-heading-5" },
+        "keys": { "default": ["5"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:change-to-cell-heading-6": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-to-cell-heading-6" },
+        "keys": { "default": ["6"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:change-cell-to-markdown": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-cell-to-markdown" },
+        "keys": { "default": ["M"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:change-cell-to-raw": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:change-cell-to-raw" },
+        "keys": { "default": ["R"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:copy-cell": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:copy-cell" },
+        "keys": { "default": ["C"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:cut-cell": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:cut-cell" },
+        "keys": { "default": ["X"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:delete-cell": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:delete-cell" },
+        "keys": { "default": ["D", "D"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:enter-command-mode-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:enter-command-mode" },
+        "keys": { "default": ["Escape"] },
+        "selector": { "default": ".jp-Notebook.jp-mod-editMode" }
+      },
+      "type": "object"
+    },
+    "notebook:enter-command-mode-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:enter-command-mode" },
+        "keys": { "default": ["Ctrl M"] },
+        "selector": { "default": ".jp-Notebook.jp-mod-editMode" }
+      },
+      "type": "object"
+    },
+    "notebook:enter-edit-mode": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:enter-edit-mode" },
+        "keys": { "default": ["Enter"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:extend-marked-cells-above-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:extend-marked-cells-above" },
+        "keys": { "default": ["Shift ArrowUp"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:extend-marked-cells-above-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:extend-marked-cells-above" },
+        "keys": { "default": ["Shift K"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:extend-marked-cells-below-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:extend-marked-cells-below" },
+        "keys": { "default": ["Shift ArrowDown"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:extend-marked-cells-below-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:extend-marked-cells-below" },
+        "keys": { "default": ["Shift J"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:insert-cell-above": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:insert-cell-above" },
+        "keys": { "default": ["A"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:insert-cell-below": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:insert-cell-below" },
+        "keys": { "default": ["B"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:interrupt-kernel": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:interrupt-kernel" },
+        "keys": { "default": ["I", "I"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:merge-cells": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:merge-cells" },
+        "keys": { "default": ["Shift M"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:move-cursor-down-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:move-cursor-down" },
+        "keys": { "default": ["ArrowDown"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:move-cursor-down-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:move-cursor-down" },
+        "keys": { "default": ["J"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:move-cursor-up-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:move-cursor-up" },
+        "keys": { "default": ["ArrowUp"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:move-cursor-up-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:move-cursor-up" },
+        "keys": { "default": ["K"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:paste-cell": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:paste-cell" },
+        "keys": { "default": ["V"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:redo-cell-action": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:redo-cell-action" },
+        "keys": { "default": ["Shift Z"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:restart-kernel": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:restart-kernel" },
+        "keys": { "default": ["0", "0"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:run-cell-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:run-cell" },
+        "keys": { "default": ["Ctrl Enter"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:run-cell-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:run-cell" },
+        "keys": { "default": ["Ctrl Enter"] },
+        "selector": { "default": ".jp-Notebook.jp-mod-editMode" }
+      },
+      "type": "object"
+    },
+    "notebook:run-cell-and-insert-below-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:run-cell-and-insert-below" },
+        "keys": { "default": ["Alt Enter"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:run-cell-and-insert-below-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:run-cell-and-insert-below" },
+        "keys": { "default": ["Alt Enter"] },
+        "selector": { "default": ".jp-Notebook.jp-mod-editMode" }
+      },
+      "type": "object"
+    },
+    "notebook:run-cell-and-select-next-1": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:run-cell-and-select-next" },
+        "keys": { "default": ["Shift Enter"] },
+        "selector": { "default": ".jp-Notebook.jp-mod-editMode" }
+      },
+      "type": "object"
+    },
+    "notebook:run-cell-and-select-next-2": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:run-cell-and-select-next" },
+        "keys": { "default": ["Shift Enter"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:split-cell-at-cursor": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:split-cell-at-cursor" },
+        "keys": { "default": ["Ctrl Shift -"] },
+        "selector": { "default": ".jp-Notebook.jp-mod-editMode" }
+      },
+      "type": "object"
+    },
+    "notebook:toggle-all-cell-line-numbers": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:toggle-all-cell-line-numbers" },
+        "keys": { "default": ["Shift L"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:toggle-cell-line-numbers": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:toggle-cell-line-numbers" },
+        "keys": { "default": ["L"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "notebook:undo-cell-action": {
+      "default": { },
+      "properties": {
+        "command": { "default": "notebook:undo-cell-action" },
+        "keys": { "default": ["Z"] },
+        "selector": { "default": ".jp-Notebook:focus" }
+      },
+      "type": "object"
+    },
+    "settingeditor:open": {
+      "default": { },
+      "properties": {
+        "command": { "default": "settingeditor:open" },
+        "keys": { "default": ["Accel ,"] },
+        "selector": { "default": "body" }
+      },
+      "type": "object"
+    },
+    "tooltip:launch-console": {
+      "default": { },
+      "properties": {
+        "command": { "default": "tooltip:launch-console" },
+        "keys": { "default": ["Shift Tab"] },
+        "selector": { "default": ".jp-CodeConsole-promptCell .jp-InputArea-editor:not(.jp-mod-has-primary-selection)" }
+      },
+      "type": "object"
+    },
+    "tooltip:launch-notebook": {
+      "default": { },
+      "properties": {
+        "command": { "default": "tooltip:launch-notebook" },
+        "keys": { "default": ["Shift Tab"] },
+        "selector": { "default": ".jp-Notebook.jp-mod-editMode .jp-InputArea-editor:not(.jp-mod-has-primary-selection)" }
+      },
+      "type": "object"
+    }
+  },
+  "oneOf": [ {"$ref": "#/definitions/shortcut" } ],
+  "type": "object",
+  "definitions": {
+    "shortcut": {
+      "properties": {
+        "command": { "type": "string" },
+        "keys": {
+          "items": { "type": "string" },
+          "minItems": 1,
+          "type": "array"
+        },
+        "selector": { "type": "string" }
+      },
+      "type": "object"
+    }
+  }
+}

+ 76 - 320
packages/shortcuts-extension/src/index.ts

@@ -5,11 +5,32 @@ import {
   JupyterLab, JupyterLabPlugin
 } from '@jupyterlab/application';
 
+import {
+  ISettingRegistry
+} from '@jupyterlab/coreutils';
+
+import {
+  CommandRegistry
+} from '@phosphor/commands';
+
+import {
+  JSONValue
+} from '@phosphor/coreutils';
+
+import {
+  DisposableSet, IDisposable
+} from '@phosphor/disposable';
 
 /**
- * The list of default application shortcuts.
+ * The default shortcuts extension.
  *
  * #### Notes
+ * Shortcut values are stored in the setting system. The default values for each
+ * shortcut are preset in the settings schema file of this extension.
+ * Additionally, each shortcut can be individually set by the end user by
+ * modifying its setting (either in the text editor or by modifying its
+ * underlying JSON file).
+ *
  * When setting shortcut selectors, there are two concepts to consider:
  * specificity and matchability. These two interact in sometimes
  * counterintuitive ways. Keyboard events are triggered from an element and
@@ -29,327 +50,20 @@ import {
  * (`'*'`) selector. For almost any use case where a global keyboard shortcut is
  * required, using the `'body'` selector is more appropriate.
  */
-const SHORTCUTS = [
-  {
-    command: 'application:activate-next-tab',
-    selector: 'body',
-    keys: ['Ctrl Shift ]']
-  },
-  {
-    command: 'application:activate-previous-tab',
-    selector: 'body',
-    keys: ['Ctrl Shift [']
-  },
-  {
-    command: 'application:toggle-mode',
-    selector: 'body',
-    keys: ['Accel Shift Enter']
-  },
-  {
-    command: 'command-palette:activate',
-    selector: 'body',
-    keys: ['Accel Shift C']
-  },
-  {
-    command: 'completer:invoke-console',
-    selector: '.jp-CodeConsole-promptCell .jp-mod-completer-enabled',
-    keys: ['Tab']
-  },
-  {
-    command: 'completer:invoke-notebook',
-    selector: '.jp-Notebook.jp-mod-editMode .jp-mod-completer-enabled',
-    keys: ['Tab']
-  },
-  {
-    command: 'console:linebreak',
-    selector: '.jp-CodeConsole-promptCell',
-    keys: ['Ctrl Enter']
-  },
-  {
-    command: 'console:run',
-    selector: '.jp-CodeConsole-promptCell',
-    keys: ['Enter']
-  },
-  {
-    command: 'fileeditor:run-code',
-    selector: '.jp-FileEditor',
-    keys: ['Shift Enter']
-  },
-  {
-    command: 'console:run-forced',
-    selector: '.jp-CodeConsole-promptCell',
-    keys: ['Shift Enter']
-  },
-  {
-    command: 'filebrowser:toggle-main',
-    selector: 'body',
-    keys: ['Accel Shift F']
-  },
-  {
-    command: 'docmanager:create-launcher',
-    selector: 'body',
-    keys: ['Accel Shift L']
-  },
-  {
-    command: 'docmanager:save',
-    selector: 'body',
-    keys: ['Accel S']
-  },
-  {
-    command: 'docmanager:close',
-    selector: '.jp-Activity',
-    keys: ['Ctrl Q']
-  },
-  {
-    command: 'help:toggle',
-    selector: 'body',
-    keys: ['Ctrl Shift H']
-  },
-  {
-    command: 'imageviewer:reset-zoom',
-    selector: '.jp-ImageViewer',
-    keys: ['0']
-  },
-  {
-    command: 'imageviewer:zoom-in',
-    selector: '.jp-ImageViewer',
-    keys: ['=']
-  },
-  {
-    command: 'imageviewer:zoom-out',
-    selector: '.jp-ImageViewer',
-    keys: ['-']
-  },
-  {
-    command: 'inspector:open',
-    selector: 'body',
-    keys: ['Accel I']
-  },
-  {
-    command: 'notebook:run-cell-and-select-next',
-    selector: '.jp-Notebook:focus',
-    keys: ['Shift Enter']
-  },
-  {
-    command: 'notebook:run-cell-and-insert-below',
-    selector: '.jp-Notebook:focus',
-    keys: ['Alt Enter']
-  },
-  {
-    command: 'notebook:run-cell',
-    selector: '.jp-Notebook:focus',
-    keys: ['Ctrl Enter']
-  },
-  {
-    command: 'notebook:run-cell-and-select-next',
-    selector: '.jp-Notebook.jp-mod-editMode',
-    keys: ['Shift Enter']
-  },
-  {
-    command: 'notebook:run-cell-and-insert-below',
-    selector: '.jp-Notebook.jp-mod-editMode',
-    keys: ['Alt Enter']
-  },
-  {
-    command: 'notebook:run-cell',
-    selector: '.jp-Notebook.jp-mod-editMode',
-    keys: ['Ctrl Enter']
-  },
-  {
-    command: 'notebook:interrupt-kernel',
-    selector: '.jp-Notebook:focus',
-    keys: ['I', 'I']
-  },
-  {
-    command: 'notebook:restart-kernel',
-    selector: '.jp-Notebook:focus',
-    keys: ['0', '0']
-  },
-  {
-    command: 'notebook:change-cell-to-code',
-    selector: '.jp-Notebook:focus',
-    keys: ['Y']
-  },
-  {
-    command: 'notebook:change-cell-to-markdown',
-    selector: '.jp-Notebook:focus',
-    keys: ['M']
-  },
-  {
-    command: 'notebook:change-cell-to-raw',
-    selector: '.jp-Notebook:focus',
-    keys: ['R']
-  },
-  {
-    command: 'notebook:delete-cell',
-    selector: '.jp-Notebook:focus',
-    keys: ['D', 'D'],
-  },
-  {
-    command: 'notebook:split-cell-at-cursor',
-    selector: '.jp-Notebook.jp-mod-editMode',
-    keys: ['Ctrl Shift -'],
-  },
-  {
-    command: 'notebook:merge-cells',
-    selector: '.jp-Notebook:focus',
-    keys: ['Shift M'],
-  },
-  {
-    command: 'notebook:move-cursor-up',
-    selector: '.jp-Notebook:focus',
-    keys: ['ArrowUp'],
-  },
-  {
-    command: 'notebook:move-cursor-up',
-    selector: '.jp-Notebook:focus',
-    keys: ['K'],
-  },
-  {
-    command: 'notebook:move-cursor-down',
-    selector: '.jp-Notebook:focus',
-    keys: ['ArrowDown'],
-  },
-  {
-    command: 'notebook:move-cursor-down',
-    selector: '.jp-Notebook:focus',
-    keys: ['J'],
-  },
-  {
-    command: 'notebook:extend-marked-cells-above',
-    selector: '.jp-Notebook:focus',
-    keys: ['Shift ArrowUp'],
-  },
-  {
-    command: 'notebook:extend-marked-cells-above',
-    selector: '.jp-Notebook:focus',
-    keys: ['Shift K'],
-  },
-  {
-    command: 'notebook:extend-marked-cells-below',
-    selector: '.jp-Notebook:focus',
-    keys: ['Shift ArrowDown'],
-  },
-  {
-    command: 'notebook:extend-marked-cells-below',
-    selector: '.jp-Notebook:focus',
-    keys: ['Shift J'],
-  },
-  {
-    command: 'notebook:undo-cell-action',
-    selector: '.jp-Notebook:focus',
-    keys: ['Z'],
-  },
-  {
-    command: 'notebook:redo-cell-action',
-    selector: '.jp-Notebook:focus',
-    keys: ['Shift Z'],
-  },
-  {
-    command: 'notebook:cut-cell',
-    selector: '.jp-Notebook:focus',
-    keys: ['X']
-  },
-  {
-    command: 'notebook:copy-cell',
-    selector: '.jp-Notebook:focus',
-    keys: ['C']
-  },
-  {
-    command: 'notebook:paste-cell',
-    selector: '.jp-Notebook:focus',
-    keys: ['V']
-  },
-  {
-    command: 'notebook:insert-cell-above',
-    selector: '.jp-Notebook:focus',
-    keys: ['A']
-  },
-  {
-    command: 'notebook:insert-cell-below',
-    selector: '.jp-Notebook:focus',
-    keys: ['B']
-  },
-  {
-    command: 'notebook:toggle-cell-line-numbers',
-    selector: '.jp-Notebook:focus',
-    keys: ['L']
-  },
-  {
-    command: 'notebook:toggle-all-cell-line-numbers',
-    selector: '.jp-Notebook:focus',
-    keys: ['Shift L']
-  },
-  {
-    command: 'notebook:change-to-cell-heading-1',
-    selector: '.jp-Notebook:focus',
-    keys: ['1']
-  },
-  {
-    command: 'notebook:change-to-cell-heading-2',
-    selector: '.jp-Notebook:focus',
-    keys: ['2']
-  },
-  {
-    command: 'notebook:change-to-cell-heading-3',
-    selector: '.jp-Notebook:focus',
-    keys: ['3']
-  },
-  {
-    command: 'notebook:change-to-cell-heading-4',
-    selector: '.jp-Notebook:focus',
-    keys: ['4']
-  },
-  {
-    command: 'notebook:change-to-cell-heading-5',
-    selector: '.jp-Notebook:focus',
-    keys: ['5']
-  },
-  {
-    command: 'notebook:change-to-cell-heading-6',
-    selector: '.jp-Notebook:focus',
-    keys: ['6']
-  },
-  {
-    command: 'notebook:enter-edit-mode',
-    selector: '.jp-Notebook:focus',
-    keys: ['Enter']
-  },
-  {
-    command: 'notebook:enter-command-mode',
-    selector: '.jp-Notebook.jp-mod-editMode',
-    keys: ['Escape']
-  },
-  {
-    command: 'notebook:enter-command-mode',
-    selector: '.jp-Notebook.jp-mod-editMode',
-    keys: ['Ctrl M']
-  },
-  {
-    command: 'settingeditor:open',
-    selector: 'body',
-    keys: ['Accel ,']
-  },
-  {
-    command: 'tooltip:launch-notebook',
-    selector: '.jp-Notebook.jp-mod-editMode .jp-InputArea-editor:not(.jp-mod-has-primary-selection)',
-    keys: ['Shift Tab']
-  },
-  {
-    command: 'tooltip:launch-console',
-    selector: '.jp-CodeConsole-promptCell .jp-InputArea-editor:not(.jp-mod-has-primary-selection)',
-    keys: ['Shift Tab']
-  }
-];
-
-
-/**
- * The default shortcuts extension.
- */
 const plugin: JupyterLabPlugin<void> = {
   id: 'jupyter.extensions.shortcuts',
-  activate: (app: JupyterLab): void => {
-    SHORTCUTS.forEach(shortcut => { app.commands.addKeyBinding(shortcut); });
+  requires: [ISettingRegistry],
+  activate: (app: JupyterLab, settingReqistry: ISettingRegistry): void => {
+    settingReqistry.load(plugin.id).then(settings => {
+      let disposables = Private.loadShortcuts(app, settings);
+
+      settings.changed.connect(() => {
+        disposables.dispose();
+        disposables = Private.loadShortcuts(app, settings);
+      });
+    }).catch((reason: Error) => {
+      console.error('Loading shortcut settings failed.', reason.message);
+    });
   },
   autoStart: true
 };
@@ -359,3 +73,45 @@ const plugin: JupyterLabPlugin<void> = {
  * Export the plugin as default.
  */
 export default plugin;
+
+
+/**
+ * A namespace for private module data.
+ */
+namespace Private {
+  /**
+   * Load the keyboard shortcuts from settings.
+   */
+  export
+  function loadShortcuts(app: JupyterLab, settings: ISettingRegistry.ISettings): IDisposable {
+    const { composite } = settings;
+    const keys = Object.keys(composite);
+
+    return keys.reduce((acc, val): DisposableSet => {
+      const options = normalizeOptions(composite[val]);
+
+      if (options) {
+        acc.add(app.commands.addKeyBinding(options));
+      }
+
+      return acc;
+    }, new DisposableSet());
+  }
+
+  /**
+   * Normalize potential keyboard shortcut options.
+   */
+  function normalizeOptions(value: JSONValue | Partial<CommandRegistry.IKeyBindingOptions>): CommandRegistry.IKeyBindingOptions | undefined {
+    if (!value || typeof value !== 'object') {
+      return undefined;
+    }
+
+    const { isArray } = Array;
+    const valid = 'command' in value &&
+      'keys' in value &&
+      'selector' in value &&
+      isArray((value as Partial<CommandRegistry.IKeyBindingOptions>).keys);
+
+    return valid ? value as CommandRegistry.IKeyBindingOptions : undefined;
+  }
+}

+ 1 - 1
setup.py

@@ -107,7 +107,7 @@ setup_args['cmdclass'] = cmdclass
 setuptools_args = {}
 install_requires = setuptools_args['install_requires'] = [
     'notebook>=4.3.1',
-    'jupyterlab_launcher>=0.2.8'
+    'jupyterlab_launcher>=0.3.0'
 ]
 
 extras_require = setuptools_args['extras_require'] = {

+ 3 - 3
setupbase.py

@@ -75,9 +75,9 @@ def find_package_data():
     Find package_data.
     """
     return {
-        'jupyterlab': ['build/*', 'index.app.js', 'webpack.config.js',
-                       'package.app.json', 'released_packages.txt',
-                       'node-version-check.js']
+        'jupyterlab': ['build/*', 'schemas/*', 'index.app.js',
+                       'webpack.config.js', 'package.app.json',
+                       'released_packages.txt', 'node-version-check.js']
     }