Browse Source

Merge pull request #2585 from blink1073/settings-backend

Settings backend
Steven Silvester 7 years ago
parent
commit
b1af1dbd1a
36 changed files with 1341 additions and 601 deletions
  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
 dist
 lib
 lib
 jupyterlab/build
 jupyterlab/build
+jupyterlab/schemas
 
 
 node_modules
 node_modules
 .cache
 .cache

+ 1 - 0
MANIFEST.in

@@ -1,4 +1,5 @@
 recursive-include jupyterlab/build *
 recursive-include jupyterlab/build *
+recursive-include jupyterlab/schemas *
 
 
 include package.json
 include package.json
 include LICENSE
 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
 - **application plugins (top level):** Application plugins extend the
   functionality of JupyterLab itself.
   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
   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
 - document widget extensions (lower level): Document widget extensions extend
   the functionality of document widgets added to the application, and we cover
   the functionality of document widgets added to the application, and we cover
   them in the "Documents" tutorial.
   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
 the application.  **Phosphor signals** are a *one-to-many* interaction that allow
 listeners to react to changes in an observed object.
 listeners to react to changes in an observed object.
 
 
-
 ## Extension Authoring
 ## Extension Authoring
 An Extension is a valid [npm package](https://docs.npmjs.com/getting-started/what-is-npm) that meets the following criteria:
 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
   - 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
 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`).
 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
-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.
 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.
 package.
 
 
 The JupyterLab repo has an example mime renderer extension for [vega2](https://github.com/jupyterlab/jupyterlab/tree/master/packages/vega2-extension).  It
 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.
 registers itself as a document renderer for vega file types.
 
 
 The `rendermime-interfaces` package is intended to be the only JupyterLab
 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`
 The only other difference from a standard extension is that has a `jupyterlab`
 key in its `package.json` with `"mimeRenderer": true` metadata.
 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 shutil
 import sys
 import sys
 import tarfile
 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 (
 from notebook.nbextensions import (
     GREEN_ENABLED, GREEN_OK, RED_DISABLED, RED_X
     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)
     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):
 def run(cmd, **kwargs):
     """Run a command in the given working directory.
     """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):
     if os.path.exists(target):
         shutil.rmtree(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):
 def link_package(path, app_dir=None, logger=None):
     """Link a package against the JupyterLab build.
     """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.
     # Look for mismatched version.
     pkg_path = pjoin(staging, 'package.json')
     pkg_path = pjoin(staging, 'package.json')
+    version_updated = False
     if os.path.exists(pkg_path):
     if os.path.exists(pkg_path):
         with open(pkg_path) as fid:
         with open(pkg_path) as fid:
             data = json.load(fid)
             data = json.load(fid)
         if data['jupyterlab'].get('version', '') != __version__:
         if data['jupyterlab'].get('version', '') != __version__:
             shutil.rmtree(staging)
             shutil.rmtree(staging)
+            version_updated = True
 
 
     if not os.path.exists(staging):
     if not os.path.exists(staging):
         os.makedirs(staging)
         os.makedirs(staging)
@@ -707,12 +726,21 @@ def _ensure_package(app_dir, name=None, version=None, logger=None):
     if version:
     if version:
         data['jupyterlab']['version'] = version
         data['jupyterlab']['version'] = version
 
 
-    data['scripts']['build'] = 'webpack'
-
     pkg_path = pjoin(staging, 'package.json')
     pkg_path = pjoin(staging, 'package.json')
     with open(pkg_path, 'w') as fid:
     with open(pkg_path, 'w') as fid:
         json.dump(data, fid, indent=4)
         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):
 def _is_extension(data):
     """Detect if a package is an extension using its metadata.
     """Detect if a package is an extension using its metadata.
@@ -822,7 +850,19 @@ def _read_package(target):
     """
     """
     tar = tarfile.open(target, "r:gz")
     tar = tarfile.open(target, "r:gz")
     f = tar.extractfile('package/package.json')
     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):
 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 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__
 from ._version import __version__
 
 
 #-----------------------------------------------------------------------------
 #-----------------------------------------------------------------------------
@@ -89,3 +92,16 @@ def load_jupyter_server_extension(nbapp):
         nbapp.log.info(CORE_NOTE.strip())
         nbapp.log.info(CORE_NOTE.strip())
 
 
     add_handlers(web_app, config)
     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 ._version import __version__
 from .extension import load_jupyter_server_extension
 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)
 build_aliases = dict(base_aliases)
@@ -68,13 +68,17 @@ class LabCleanApp(JupyterApp):
 class LabPathApp(JupyterApp):
 class LabPathApp(JupyterApp):
     version = __version__
     version = __version__
     description = """
     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):
     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)
 lab_aliases = dict(aliases)
@@ -103,7 +107,7 @@ class LabApp(NotebookApp):
     JupyterLab has three different modes of running:
     JupyterLab has three different modes of running:
 
 
     * Core mode (`--core-mode`): in this mode JupyterLab will run using the JavaScript
     * 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
       extensions are enabled. This is the default in a stable JupyterLab release if you
       have no extensions installed.
       have no extensions installed.
     * Dev mode (`--dev-mode`): like core mode, but when the `jupyterlab` Python package
     * Dev mode (`--dev-mode`): like core mode, but when the `jupyterlab` Python package
@@ -129,7 +133,8 @@ class LabApp(NotebookApp):
     subcommands = dict(
     subcommands = dict(
         build=(LabBuildApp, LabBuildApp.description.splitlines()[0]),
         build=(LabBuildApp, LabBuildApp.description.splitlines()[0]),
         clean=(LabCleanApp, LabCleanApp.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,
     default_url = Unicode('/lab', config=True,

+ 4 - 0
jupyterlab/make-release.js

@@ -51,6 +51,10 @@ data['jupyterlab']['version'] = version;
 updateDependencies(data);
 updateDependencies(data);
 var text = JSON.stringify(sortPackageJson(data), null, 2) + '\n';
 var text = JSON.stringify(sortPackageJson(data), null, 2) + '\n';
 fs.writeFileSync('./package.json', text);
 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);
 fs.writeFileSync('./package.app.json', text);
 
 
 // Update our app index file.
 // Update our app index file.

+ 1 - 0
jupyterlab/package.app.json

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

+ 3 - 1
jupyterlab/package.json

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

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

@@ -12,9 +12,13 @@ import {
 } from '@jupyterlab/apputils';
 } from '@jupyterlab/apputils';
 
 
 import {
 import {
-  ISettingRegistry, IStateDB, SettingRegistry, StateDB
+  IDataConnector, ISettingRegistry, IStateDB, SettingRegistry, StateDB
 } from '@jupyterlab/coreutils';
 } from '@jupyterlab/coreutils';
 
 
+import {
+  IServiceManager, ServerConnection
+} from '@jupyterlab/services';
+
 import {
 import {
   JSONObject
   JSONObject
 } from '@phosphor/coreutils';
 } from '@phosphor/coreutils';
@@ -27,10 +31,6 @@ import {
   activatePalette
   activatePalette
 } from './palette';
 } from './palette';
 
 
-import {
-  SettingClientDataConnector
-} from './settingclientdataconnector';
-
 
 
 /**
 /**
  * The command IDs used by the apputils plugin.
  * 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.
  * A service providing an interface to the main menu.
  */
  */
@@ -52,7 +103,8 @@ const mainMenuPlugin: JupyterLabPlugin<IMainMenu> = {
     menu.id = 'jp-MainMenu';
     menu.id = 'jp-MainMenu';
 
 
     let logo = new Widget();
     let logo = new Widget();
-    logo.node.className = 'jp-MainAreaPortraitIcon jp-JupyterIcon';
+    logo.addClass('jp-MainAreaPortraitIcon');
+    logo.addClass('jp-JupyterIcon');
     logo.id = 'jp-MainLogo';
     logo.id = 'jp-MainLogo';
 
 
     app.shell.addToTopArea(logo);
     app.shell.addToTopArea(logo);
@@ -80,12 +132,12 @@ const palettePlugin: JupyterLabPlugin<ICommandPalette> = {
  */
  */
 const settingPlugin: JupyterLabPlugin<ISettingRegistry> = {
 const settingPlugin: JupyterLabPlugin<ISettingRegistry> = {
   id: 'jupyter.services.setting-registry',
   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,
   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",
   "types": "lib/index.d.ts",
   "files": [
   "files": [
     "lib/*.d.ts",
     "lib/*.d.ts",
-    "lib/*.js"
+    "lib/*.js",
+    "schema/*.json"
   ],
   ],
   "directories": {
   "directories": {
     "lib": "lib/"
     "lib": "lib/"
@@ -30,7 +31,10 @@
     "watch": "tsc -w"
     "watch": "tsc -w"
   },
   },
   "jupyterlab": {
   "jupyterlab": {
-    "extension": true
+    "extension": true,
+    "schemas": [
+      "schema/jupyter.services.codemirror-commands.json"
+    ]
   },
   },
   "repository": {
   "repository": {
     "type": "git",
     "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.
  * The editor services.
  */
  */
 export
 export
 const servicesPlugin: JupyterLabPlugin<IEditorServices> = {
 const servicesPlugin: JupyterLabPlugin<IEditorServices> = {
-  id: IEditorServices.name,
+  id: 'jupyter.services.codemirror-services',
   provides: IEditorServices,
   provides: IEditorServices,
   activate: (): IEditorServices => editorServices
   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.
   // Fetch the initial state of the settings.
   Promise.all([settingRegistry.load(id), restored]).then(([settings]) => {
   Promise.all([settingRegistry.load(id), restored]).then(([settings]) => {
     updateSettings(settings);
     updateSettings(settings);
@@ -139,6 +116,9 @@ function activateEditorCommands(app: JupyterLab, tracker: IEditorTracker, mainMe
       updateSettings(settings);
       updateSettings(settings);
       updateTracker();
       updateTracker();
     });
     });
+  }).catch((reason: Error) => {
+    console.error(reason.message);
+    updateTracker();
   });
   });
 
 
   /**
   /**
@@ -185,14 +165,13 @@ function activateEditorCommands(app: JupyterLab, tracker: IEditorTracker, mainMe
     commands.addCommand(CommandIDs.changeTheme, {
     commands.addCommand(CommandIDs.changeTheme, {
       label: args => args['theme'] as string,
       label: args => args['theme'] as string,
       execute: args => {
       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,
       isEnabled: hasWidget,
       isToggled: args => args['theme'] === theme
       isToggled: args => args['theme'] === theme
@@ -204,14 +183,13 @@ function activateEditorCommands(app: JupyterLab, tracker: IEditorTracker, mainMe
         return title === 'sublime' ? 'Sublime Text' : title;
         return title === 'sublime' ? 'Sublime Text' : title;
       },
       },
       execute: args => {
       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,
       isEnabled: hasWidget,
       isToggled: args => args['keyMap'] === keyMap
       isToggled: args => args['keyMap'] === keyMap

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

@@ -85,6 +85,11 @@ namespace ISchemaValidator {
    */
    */
   export
   export
   interface IError {
   interface IError {
+    /**
+     * The path in the data where the error occurred.
+     */
+    dataPath: string;
+
     /**
     /**
      * The keyword whose validation failed.
      * The keyword whose validation failed.
      */
      */
@@ -402,7 +407,6 @@ class SettingRegistry {
   constructor(options: SettingRegistry.IOptions) {
   constructor(options: SettingRegistry.IOptions) {
     this._connector = options.connector;
     this._connector = options.connector;
     this._validator = options.validator || new DefaultSchemaValidator();
     this._validator = options.validator || new DefaultSchemaValidator();
-    this._preload = options.preload || (() => { /* no op */ });
   }
   }
 
 
   /**
   /**
@@ -475,21 +479,6 @@ class SettingRegistry {
     return this.reload(plugin);
     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.
    * 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.
     // If the plugin needs to be loaded from the connector, fetch.
     return connector.fetch(plugin).then(data => {
     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({
       return new Settings({
         plugin: copy(plugins[plugin]) as ISettingRegistry.IPlugin,
         plugin: copy(plugins[plugin]) as ISettingRegistry.IPlugin,
@@ -635,7 +635,6 @@ class SettingRegistry {
   private _connector: IDataConnector<ISettingRegistry.IPlugin, JSONObject>;
   private _connector: IDataConnector<ISettingRegistry.IPlugin, JSONObject>;
   private _pluginChanged = new Signal<this, string>(this);
   private _pluginChanged = new Signal<this, string>(this);
   private _plugins: { [name: string]: ISettingRegistry.IPlugin } = Object.create(null);
   private _plugins: { [name: string]: ISettingRegistry.IPlugin } = Object.create(null);
-  private _preload: (plugin: string, schema: ISettingRegistry.ISchema) => void;
   private _validator: ISchemaValidator;
   private _validator: ISchemaValidator;
 }
 }
 
 
@@ -823,15 +822,6 @@ namespace SettingRegistry {
      */
      */
     connector: IDataConnector<ISettingRegistry.IPlugin, JSONObject>;
     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.
      * 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",
   "types": "lib/index.d.ts",
   "files": [
   "files": [
     "lib/*.d.ts",
     "lib/*.d.ts",
-    "lib/*.js"
+    "lib/*.js",
+    "schema/*.json"
   ],
   ],
   "directories": {
   "directories": {
     "lib": "lib/"
     "lib": "lib/"
@@ -30,7 +31,10 @@
     "watch": "tsc -w"
     "watch": "tsc -w"
   },
   },
   "jupyterlab": {
   "jupyterlab": {
-    "extension": true
+    "extension": true,
+    "schemas": [
+      "schema/jupyter.services.editor-tracker.json"
+    ]
   },
   },
   "repository": {
   "repository": {
     "type": "git",
     "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.
  * Export the plugins as default.
  */
  */
@@ -182,9 +151,6 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, editorServices: IE
     editor.setOption('autoClosingBrackets', autoClosingBrackets);
     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.
   // Fetch the initial state of the settings.
   Promise.all([settingRegistry.load(id), restored]).then(([settings]) => {
   Promise.all([settingRegistry.load(id), restored]).then(([settings]) => {
     updateSettings(settings);
     updateSettings(settings);
@@ -193,6 +159,9 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, editorServices: IE
       updateSettings(settings);
       updateSettings(settings);
       updateTracker();
       updateTracker();
     });
     });
+  }).catch((reason: Error) => {
+    console.error(reason.message);
+    updateTracker();
   });
   });
 
 
   factory.widgetCreated.connect((sender, widget) => {
   factory.widgetCreated.connect((sender, widget) => {
@@ -212,11 +181,13 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, editorServices: IE
 
 
   commands.addCommand(CommandIDs.lineNumbers, {
   commands.addCommand(CommandIDs.lineNumbers, {
     execute: () => {
     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,
     isEnabled: hasWidget,
     isToggled: () => lineNumbers,
     isToggled: () => lineNumbers,
@@ -225,11 +196,13 @@ function activate(app: JupyterLab, restorer: ILayoutRestorer, editorServices: IE
 
 
   commands.addCommand(CommandIDs.lineWrap, {
   commands.addCommand(CommandIDs.lineWrap, {
     execute: () => {
     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,
     isEnabled: hasWidget,
     isToggled: () => lineWrap,
     isToggled: () => lineWrap,

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

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

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

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

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

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

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

@@ -181,12 +181,12 @@ class SettingEditor extends Widget {
     requestAnimationFrame(() => {
     requestAnimationFrame(() => {
       // Set the original (default) outer dimensions.
       // Set the original (default) outer dimensions.
       this._panel.setRelativeSizes(this._presets.outer);
       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;
       editor.settings = settings;
       list.selection = plugin;
       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 = '';
       list.selection = this._presets.plugin = '';
       editor.settings = null;
       editor.settings = null;
     });
     });
@@ -718,7 +718,15 @@ class PluginEditor extends Widget {
     const editor = this._editor;
     const editor = this._editor;
     const settings = this._settings;
     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;
   private _editor: JSONEditor = null;

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

@@ -6,13 +6,17 @@
   "types": "lib/index.d.ts",
   "types": "lib/index.d.ts",
   "files": [
   "files": [
     "lib/*.d.ts",
     "lib/*.d.ts",
-    "lib/*.js"
+    "lib/*.js",
+    "schema/*.json"
   ],
   ],
   "directories": {
   "directories": {
     "lib": "lib/"
     "lib": "lib/"
   },
   },
   "dependencies": {
   "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": {
   "devDependencies": {
     "rimraf": "^2.5.2",
     "rimraf": "^2.5.2",
@@ -24,7 +28,10 @@
     "watch": "tsc -w"
     "watch": "tsc -w"
   },
   },
   "jupyterlab": {
   "jupyterlab": {
-    "extension": true
+    "extension": true,
+    "schemas": [
+      "schema/jupyter.extensions.shortcuts.json"
+    ]
   },
   },
   "repository": {
   "repository": {
     "type": "git",
     "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
   JupyterLab, JupyterLabPlugin
 } from '@jupyterlab/application';
 } 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
  * #### 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:
  * When setting shortcut selectors, there are two concepts to consider:
  * specificity and matchability. These two interact in sometimes
  * specificity and matchability. These two interact in sometimes
  * counterintuitive ways. Keyboard events are triggered from an element and
  * 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
  * (`'*'`) selector. For almost any use case where a global keyboard shortcut is
  * required, using the `'body'` selector is more appropriate.
  * 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> = {
 const plugin: JupyterLabPlugin<void> = {
   id: 'jupyter.extensions.shortcuts',
   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
   autoStart: true
 };
 };
@@ -359,3 +73,45 @@ const plugin: JupyterLabPlugin<void> = {
  * Export the plugin as default.
  * Export the plugin as default.
  */
  */
 export default plugin;
 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 = {}
 setuptools_args = {}
 install_requires = setuptools_args['install_requires'] = [
 install_requires = setuptools_args['install_requires'] = [
     'notebook>=4.3.1',
     'notebook>=4.3.1',
-    'jupyterlab_launcher>=0.2.8'
+    'jupyterlab_launcher>=0.3.0'
 ]
 ]
 
 
 extras_require = setuptools_args['extras_require'] = {
 extras_require = setuptools_args['extras_require'] = {

+ 3 - 3
setupbase.py

@@ -75,9 +75,9 @@ def find_package_data():
     Find package_data.
     Find package_data.
     """
     """
     return {
     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']
     }
     }