Преглед изворни кода

Merge pull request #2784 from afshin/settings-ui

Enhanced settings UI, part 3
Steven Silvester пре 7 година
родитељ
комит
9f5f0ac2a4

+ 11 - 5
packages/application/src/shell.ts

@@ -975,12 +975,18 @@ namespace Private {
    * A message hook that adds and removes the .jp-Activity class to widgets in the dock panel.
    */
   export
-  var activityClassHook = (handler: IMessageHandler, msg: Message): boolean => {
-    if (msg.type === 'child-added') {
-      (msg as Widget.ChildMessage).child.addClass(ACTIVITY_CLASS)
-    } else if (msg.type === 'child-removed') {
-      (msg as Widget.ChildMessage).child.removeClass(ACTIVITY_CLASS)
+  function activityClassHook(handler: IMessageHandler, msg: Message): boolean {
+    switch (msg.type) {
+      case 'child-added':
+        (msg as Widget.ChildMessage).child.addClass(ACTIVITY_CLASS);
+        break;
+      case 'child-removed':
+        (msg as Widget.ChildMessage).child.removeClass(ACTIVITY_CLASS);
+        break;
+      default:
+        break;
     }
+
     return true;
   }
 

+ 77 - 0
packages/coreutils/src/settingregistry.ts

@@ -253,6 +253,11 @@ namespace ISettingRegistry {
      */
     readonly user: JSONObject;
 
+    /**
+     * Return the defaults in a commented JSON format.
+     */
+    annotatedDefaults(): string;
+
     /**
      * Calculate the default value of a setting by iterating through the schema.
      *
@@ -714,6 +719,13 @@ class Settings implements ISettingRegistry.ISettings {
    */
   readonly registry: SettingRegistry;
 
+  /**
+   * Return the defaults in a commented JSON format.
+   */
+  annotatedDefaults(): string {
+    return Private.annotatedDefaults(this._schema, this.plugin);
+  }
+
   /**
    * Calculate the default value of a setting by iterating through the schema.
    *
@@ -896,6 +908,71 @@ namespace Private {
   };
   /* tslint:enable */
 
+  /**
+   * Replacement text for schema properties missing a `description` field.
+   */
+  const nondescript = '[missing schema description]';
+
+  /**
+   * Replacement text for schema properties missing a `default` field.
+   */
+  const undefaulted = '[missing schema default]';
+
+  /**
+   * Replacement text for schema properties missing a `title` field.
+   */
+  const untitled = '[missing schema title]';
+
+  /**
+   * Returns an annotated (JSON with comments) version of a schema's defaults.
+   */
+  export
+  function annotatedDefaults(schema: ISettingRegistry.ISchema, plugin: string): string {
+    const { description, properties, title } = schema;
+    const keys = Object.keys(properties).sort((a, b) => a.localeCompare(b));
+
+    return [
+      '{',
+      prefix(`${title || untitled}`),
+      prefix(plugin),
+      prefix(description || nondescript),
+      prefix(line((description || nondescript).length)),
+      '',
+      keys.map(key => docstring(schema, key)).join('\n\n'),
+      '}'
+    ].join('\n');
+  }
+
+  /**
+   * Returns a documentation string for a specific schema property.
+   */
+  function docstring(schema: ISettingRegistry.ISchema, key: string): string {
+    const { description, title } = schema.properties[key];
+    const reified = reifyDefault(schema, key);
+    const defaults = reified === undefined ? prefix(`"${key}": ${undefaulted}`)
+      : prefix(`"${key}": ${JSON.stringify(reified, null, 2)}`, '  ');
+
+    return [
+      prefix(`${title || untitled}`),
+      prefix(description || nondescript),
+      defaults
+    ].join('\n');
+  }
+
+  /**
+   * Returns a line of a specified length.
+   */
+  function line(length: number, ch = '*'): string {
+    return (new Array(length + 1)).join(ch);
+  }
+
+  /**
+   * Returns a documentation string with a comment prefix added on every line.
+   */
+  function prefix(source: string, pre = '  \/\/ '): string {
+    return pre + source.split('\n').join(`\n${pre}`);
+  }
+
   /**
    * Create a fully extrapolated default value for a root key in a schema.
    */

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

@@ -17,6 +17,7 @@
     "@jupyterlab/apputils": "^0.9.0",
     "@jupyterlab/codeeditor": "^0.9.0",
     "@jupyterlab/coreutils": "^0.9.0",
+    "@phosphor/coreutils": "^1.2.0",
     "@phosphor/messaging": "^1.2.1",
     "@phosphor/signaling": "^1.2.1",
     "@phosphor/virtualdom": "^1.1.1",

+ 233 - 0
packages/settingeditor-extension/src/plugineditor.ts

@@ -0,0 +1,233 @@
+
+import {
+  Dialog, showDialog
+} from '@jupyterlab/apputils';
+
+import {
+  CodeEditor
+} from '@jupyterlab/codeeditor';
+
+import {
+  ISettingRegistry
+} from '@jupyterlab/coreutils';
+
+import {
+  JSONExt
+} from '@phosphor/coreutils';
+
+import {
+  Message
+} from '@phosphor/messaging';
+
+import {
+  ISignal, Signal
+} from '@phosphor/signaling';
+
+import {
+  Widget, StackedLayout
+} from '@phosphor/widgets';
+
+import {
+  RawEditor
+} from './raweditor';
+
+import {
+  SettingEditor
+} from './settingeditor';
+
+import {
+  TableEditor
+} from './tableeditor';
+
+
+/**
+ * The class name added to all plugin editors.
+ */
+const PLUGIN_EDITOR_CLASS = 'jp-PluginEditor';
+
+
+/**
+ * An individual plugin settings editor.
+ */
+export
+class PluginEditor extends Widget {
+  /**
+   * Create a new plugin editor.
+   */
+  constructor(options: PluginEditor.IOptions) {
+    super();
+    this.addClass(PLUGIN_EDITOR_CLASS);
+
+    const { editorFactory } = options;
+    const layout = this.layout = new StackedLayout();
+    const { onSaveError } = Private;
+
+    this._rawEditor = new RawEditor({ editorFactory, onSaveError });
+    this._tableEditor = new TableEditor({ onSaveError });
+    this._rawEditor.handleMoved.connect(this._onStateChanged, this);
+
+    layout.addWidget(this._rawEditor);
+    layout.addWidget(this._tableEditor);
+  }
+
+  /**
+   * Tests whether the settings have been modified and need saving.
+   */
+  get isDirty(): boolean {
+    return this._rawEditor.isDirty || this._tableEditor.isDirty;
+  }
+
+  /**
+   * The plugin settings being edited.
+   */
+  get settings(): ISettingRegistry.ISettings | null {
+    return this._settings;
+  }
+  set settings(settings: ISettingRegistry.ISettings | null) {
+    if (this._settings === settings) {
+      return;
+    }
+
+    const raw = this._rawEditor;
+    const table = this._tableEditor;
+
+    this._settings = raw.settings = table.settings = settings;
+    this.update();
+  }
+
+  /**
+   * The plugin editor layout state.
+   */
+  get state(): SettingEditor.IPluginLayout {
+    const editor = this._editor;
+    const plugin = this._settings ? this._settings.plugin : '';
+    const { sizes } = this._rawEditor;
+
+    return { editor, plugin, sizes };
+  }
+  set state(state: SettingEditor.IPluginLayout) {
+    if (JSONExt.deepEqual(this.state, state)) {
+      return;
+    }
+
+    this._editor = state.editor;
+    this._rawEditor.sizes = state.sizes;
+    this.update();
+  }
+
+  /**
+   * A signal that emits when editor layout state changes and needs to be saved.
+   */
+  get stateChanged(): ISignal<this, void> {
+    return this._stateChanged;
+  }
+
+  /**
+   * If the editor is in a dirty state, confirm that the user wants to leave.
+   */
+  confirm(): Promise<void> {
+    if (this.isHidden || !this.isAttached || !this.isDirty) {
+      return Promise.resolve(void 0);
+    }
+
+    return showDialog({
+      title: 'You have unsaved changes.',
+      body: 'Do you want to leave without saving?',
+      buttons: [Dialog.cancelButton(), Dialog.okButton()]
+    }).then(result => {
+      if (!result.button.accept) {
+        throw new Error('User cancelled.');
+      }
+    });
+  }
+
+  /**
+   * Dispose of the resources held by the plugin editor.
+   */
+  dispose(): void {
+    if (this.isDisposed) {
+      return;
+    }
+
+    super.dispose();
+    this._rawEditor.dispose();
+    this._tableEditor.dispose();
+  }
+
+  /**
+   * Handle `after-attach` messages.
+   */
+  protected onAfterAttach(msg: Message): void {
+    this.update();
+  }
+
+  /**
+   * Handle `'update-request'` messages.
+   */
+  protected onUpdateRequest(msg: Message): void {
+    const editor = this._editor;
+    const raw = this._rawEditor;
+    const table = this._tableEditor;
+    const settings = this._settings;
+
+    if (!settings) {
+      this.hide();
+      return;
+    }
+
+    this.show();
+    (editor === 'raw' ? table : raw).hide();
+    (editor === 'raw' ? raw : table).show();
+  }
+
+  /**
+   * Handle layout state changes that need to be saved.
+   */
+  private _onStateChanged(): void {
+    (this.stateChanged as Signal<any, void>).emit(undefined);
+  }
+
+  private _editor: 'raw' | 'table' = 'raw';
+  private _rawEditor: RawEditor;
+  private _tableEditor: TableEditor;
+  private _settings: ISettingRegistry.ISettings | null = null;
+  private _stateChanged = new Signal<this, void>(this);
+}
+
+
+/**
+ * A namespace for `PluginEditor` statics.
+ */
+export
+namespace PluginEditor {
+  /**
+   * The instantiation options for a plugin editor.
+   */
+  export
+  interface IOptions {
+    /**
+     * The editor factory used by the plugin editor.
+     */
+    editorFactory: CodeEditor.Factory;
+  }
+}
+
+
+/**
+ * A namespace for private module data.
+ */
+namespace Private {
+  /**
+   * Handle save errors.
+   */
+  export
+  function onSaveError(reason: any): void {
+    console.error(`Saving setting editor value failed: ${reason.message}`);
+
+    showDialog({
+      title: 'Your changes were not saved.',
+      body: reason.message,
+      buttons: [Dialog.okButton()]
+    });
+  }
+}

+ 338 - 0
packages/settingeditor-extension/src/pluginlist.ts

@@ -0,0 +1,338 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+import {
+  ICON_CLASS_KEY, ICON_LABEL_KEY, ISettingRegistry
+} from '@jupyterlab/coreutils';
+
+import {
+  Message
+} from '@phosphor/messaging';
+
+import {
+  ISignal, Signal
+} from '@phosphor/signaling';
+
+import {
+  ElementAttrs, h, VirtualDOM
+} from '@phosphor/virtualdom';
+
+import {
+  Widget
+} from '@phosphor/widgets';
+
+
+/**
+ * The class name added to all plugin lists.
+ */
+const PLUGIN_LIST_CLASS = 'jp-PluginList';
+
+/**
+ * The class name added to the plugin list's editor switcher.
+ */
+const PLUGIN_LIST_SWITCHER_CLASS = 'jp-PluginList-switcher';
+
+/**
+ * The class name added to all plugin list icons.
+ */
+const PLUGIN_ICON_CLASS = 'jp-PluginList-icon';
+
+/**
+ * The class name added to selected items.
+ */
+const SELECTED_CLASS = 'jp-mod-selected';
+
+
+/**
+ * A list of plugins with editable settings.
+ */
+export
+class PluginList extends Widget {
+  /**
+   * Create a new plugin list.
+   */
+  constructor(options: PluginList.IOptions) {
+    super();
+    this.registry = options.registry;
+    this.addClass(PLUGIN_LIST_CLASS);
+    this._confirm = options.confirm;
+    this.registry.pluginChanged.connect(() => { this.update(); }, this);
+  }
+
+  /**
+   * The setting registry.
+   */
+  readonly registry: ISettingRegistry;
+
+  /**
+   * A signal emitted when a list user interaction happens.
+   */
+  get changed(): ISignal<this, void> {
+    return this._changed;
+  }
+
+  /**
+   * The editor type currently selected.
+   */
+  get editor(): 'raw' | 'table' {
+    return this._editor;
+  }
+  set editor(editor: 'raw' | 'table') {
+    if (this._editor === editor) {
+      return;
+    }
+
+    this._editor = editor;
+    this.update();
+  }
+
+  /**
+   * The selection value of the plugin list.
+   */
+  get scrollTop(): number {
+    return this.node.querySelector('ul').scrollTop;
+  }
+
+  /**
+   * The selection value of the plugin list.
+   */
+  get selection(): string {
+    return this._selection;
+  }
+  set selection(selection: string) {
+    if (this._selection === selection) {
+      return;
+    }
+    this._selection = selection;
+    this.update();
+  }
+
+  /**
+   * Handle the DOM events for the widget.
+   *
+   * @param event - The DOM event sent to the widget.
+   *
+   * #### Notes
+   * This method implements the DOM `EventListener` interface and is
+   * called in response to events on the plugin list's node. It should
+   * not be called directly by user code.
+   */
+  handleEvent(event: Event): void {
+    switch (event.type) {
+    case 'mousedown':
+      this._evtMousedown(event as MouseEvent);
+      break;
+    default:
+      break;
+    }
+  }
+
+  /**
+   * Handle `'after-attach'` messages.
+   */
+  protected onAfterAttach(msg: Message): void {
+    this.node.addEventListener('mousedown', this);
+    this.update();
+  }
+
+  /**
+   * Handle `before-detach` messages for the widget.
+   */
+  protected onBeforeDetach(msg: Message): void {
+    this.node.removeEventListener('mousedown', this);
+  }
+
+  /**
+   * Handle `'update-request'` messages.
+   */
+  protected onUpdateRequest(msg: Message): void {
+    const plugins = Private.sortPlugins(this.registry.plugins);
+    const switcher = Private.createSwitcher(this._editor);
+    const list = document.createElement('ul');
+
+    this.node.textContent = '';
+    this.node.appendChild(switcher);
+    this.node.appendChild(list);
+    plugins.forEach(plugin => {
+      const item = Private.createListItem(this.registry, plugin);
+
+      if (plugin.id === this._selection) {
+        item.classList.add(SELECTED_CLASS);
+      }
+
+      list.appendChild(item);
+    });
+    list.scrollTop = this._scrollTop;
+  }
+
+  /**
+   * Handle the `'mousedown'` event for the plugin list.
+   *
+   * @param event - The DOM event sent to the widget
+   */
+  private _evtMousedown(event: MouseEvent): void {
+    event.preventDefault();
+
+    let target = event.target as HTMLElement;
+    let id = target.getAttribute('data-id');
+
+    if (id === this._selection) {
+      return;
+    }
+
+    const editor = target.getAttribute('data-editor');
+
+    if (editor) {
+      this._editor = editor as 'raw' | 'table';
+      this._changed.emit(undefined);
+      this.update();
+      return;
+    }
+
+    if (!id) {
+      while (!id && target !== this.node) {
+        target = target.parentElement as HTMLElement;
+        id = target.getAttribute('data-id');
+      }
+    }
+
+    if (!id) {
+      return;
+    }
+
+    this._confirm().then(() => {
+      this._scrollTop = this.scrollTop;
+      this._selection = id;
+      this._changed.emit(undefined);
+      this.update();
+    }).catch(() => { /* no op */ });
+  }
+
+  private _changed = new Signal<this, void>(this);
+  private _confirm: () => Promise<void>;
+  private _editor: 'raw' | 'table' = 'raw';
+  private _scrollTop = 0;
+  private _selection = '';
+}
+
+
+/**
+ * A namespace for `PluginList` statics.
+ */
+export
+namespace PluginList {
+  /**
+   * The instantiation options for a plugin list.
+   */
+  export
+  interface IOptions {
+    /**
+     * A function that allows for asynchronously confirming a selection.
+     *
+     * #### Notest
+     * If the promise returned by the function resolves, then the selection will
+     * succeed and emit an event. If the promise rejects, the selection is not
+     * made.
+     */
+    confirm: () => Promise<void>;
+
+    /**
+     * The setting registry for the plugin list.
+     */
+    registry: ISettingRegistry;
+  }
+}
+
+
+/**
+ * A namespace for private module data.
+ */
+namespace Private {
+  /**
+   * Create a plugin list item.
+   */
+  export
+  function createListItem(registry: ISettingRegistry, plugin: ISettingRegistry.IPlugin): HTMLLIElement {
+    const icon = getHint(ICON_CLASS_KEY, registry, plugin);
+    const iconClass = `${PLUGIN_ICON_CLASS}${icon ? ' ' + icon : ''}`;
+    const iconLabel = getHint(ICON_LABEL_KEY, registry, plugin);
+    const title = plugin.schema.title || plugin.id;
+    const caption = `(${plugin.id}) ${plugin.schema.description}`;
+
+    return VirtualDOM.realize(
+      h.li({ dataset: { id: plugin.id }, title: caption },
+        h.span({ className: iconClass, title: iconLabel }),
+        h.span(title))
+    ) as HTMLLIElement;
+  }
+
+  /**
+   * Create the plugin list editor switcher.
+   */
+  export
+  function createSwitcher(current: 'raw' | 'table'): HTMLElement {
+    let raw: ElementAttrs;
+    let table: ElementAttrs = { dataset: { editor: 'table' } };
+
+    if (current === 'raw') {
+      raw = { dataset: { editor: 'raw' }, disabled: 'disabled' };
+      table = { dataset: { editor: 'table' } };
+    } else {
+      raw = { dataset: { editor: 'raw' } };
+      table = { dataset: { editor: 'table' }, disabled: 'disabled' };
+    }
+
+    return VirtualDOM.realize(
+      h.div({ className: PLUGIN_LIST_SWITCHER_CLASS },
+        h.button(raw, 'Raw View'),
+        h.button(table, 'Table View'))
+    );
+  }
+
+  /**
+   * Check the plugin for a rendering hint's value.
+   *
+   * #### Notes
+   * The order of priority for overridden hints is as follows, from most
+   * important to least:
+   * 1. Data set by the end user in a settings file.
+   * 2. Data set by the plugin author as a schema default.
+   * 3. Data set by the plugin author as a top-level key of the schema.
+   */
+  function getHint(key: string, registry: ISettingRegistry, plugin: ISettingRegistry.IPlugin): string {
+    // First, give priorty to checking if the hint exists in the user data.
+    let hint = plugin.data.user[key];
+
+    // Second, check to see if the hint exists in composite data, which folds
+    // in default values from the schema.
+    if (!hint) {
+      hint = plugin.data.composite[key];
+    }
+
+    // Third, check to see if the plugin schema has defined the hint.
+    if (!hint) {
+      hint = plugin.schema[key];
+    }
+
+    // Finally, use the defaults from the registry schema.
+    if (!hint) {
+      const properties = registry.schema.properties;
+
+      hint = properties && properties[key] && properties[key].default;
+    }
+
+    return typeof hint === 'string' ? hint : '';
+  }
+
+  /**
+   * Sort a list of plugins by ID.
+   */
+  export
+  function sortPlugins(plugins: ISettingRegistry.IPlugin[]): ISettingRegistry.IPlugin[] {
+    return plugins.sort((a, b) => {
+      return (a.schema.title || a.id).localeCompare(b.schema.title || b.id);
+    });
+  }
+}

+ 258 - 0
packages/settingeditor-extension/src/raweditor.ts

@@ -0,0 +1,258 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  CodeEditor, CodeEditorWrapper, JSONEditor
+} from '@jupyterlab/codeeditor';
+
+import {
+  ISettingRegistry, ObservableJSON
+} from '@jupyterlab/coreutils';
+
+import {
+  Message
+} from '@phosphor/messaging';
+
+import {
+  h, VirtualDOM
+} from '@phosphor/virtualdom';
+
+import {
+  BoxLayout, Widget
+} from '@phosphor/widgets';
+
+import {
+  SplitPanel
+} from './splitpanel';
+
+/**
+ * A class name added to all raw editors.
+ */
+const RAW_EDITOR_CLASS = 'jp-SettingsRawEditor';
+
+/**
+ * A class name added to the banner of editors in the raw settings editor.
+ */
+const BANNER_CLASS = 'jp-SettingsRawEditor-banner';
+
+/**
+ * The banner text for the default editor.
+ */
+const DEFAULT_TITLE = 'System Defaults';
+
+/**
+ * The banner text for the user settings editor.
+ */
+const USER_TITLE = 'User Overrides';
+
+
+/**
+ * A raw JSON settings editor.
+ */
+export
+class RawEditor extends SplitPanel {
+  /**
+   * Create a new plugin editor.
+   */
+  constructor(options: RawEditor.IOptions) {
+    super({
+      orientation: 'horizontal',
+      renderer: SplitPanel.defaultRenderer,
+      spacing: 1
+    });
+
+    const { editorFactory } = options;
+    const collapsible = false;
+
+    // Create read-only defaults editor.
+    const defaults = this._defaults = new CodeEditorWrapper({
+      model: new CodeEditor.Model(),
+      factory: editorFactory
+    });
+
+    defaults.editor.model.value.text = '';
+    defaults.editor.model.mimeType = 'text/javascript';
+    defaults.editor.setOption('readOnly', true);
+
+    // Create read-write user settings editor.
+    const user = this._user = new JSONEditor({ collapsible, editorFactory });
+
+    this.addClass(RAW_EDITOR_CLASS);
+    this._onSaveError = options.onSaveError;
+    this.addWidget(Private.addBanner(defaults, DEFAULT_TITLE, BANNER_CLASS));
+    this.addWidget(Private.addBanner(user, USER_TITLE, BANNER_CLASS));
+  }
+
+  /**
+   * Tests whether the settings have been modified and need saving.
+   */
+  get isDirty(): boolean {
+    return this._user.isDirty;
+  }
+
+  /**
+   * The plugin settings being edited.
+   */
+  get settings(): ISettingRegistry.ISettings | null {
+    return this._settings;
+  }
+  set settings(settings: ISettingRegistry.ISettings | null) {
+    if (!settings && !this._settings) {
+      return;
+    }
+
+    const samePlugin = (settings && this._settings) &&
+      settings.plugin === this._settings.plugin;
+
+    if (samePlugin) {
+      return;
+    }
+
+    const defaults = this._defaults;
+    const user = this._user;
+
+    // Disconnect old source change handler.
+    if (user.source) {
+      user.source.changed.disconnect(this._onSourceChanged, this);
+    }
+
+    // Disconnect old settings change handler.
+    if (this._settings) {
+      this._settings.changed.disconnect(this._onSettingsChanged, this);
+    }
+
+    if (settings) {
+      this._settings = settings;
+      this._settings.changed.connect(this._onSettingsChanged, this);
+      this._onSettingsChanged();
+    } else {
+      this._settings = user.source = null;
+      defaults.editor.model.value.text = '';
+    }
+
+    this.update();
+  }
+
+  /**
+   * Get the relative sizes of the two editor panels.
+   */
+  get sizes(): number[] {
+    return this.relativeSizes();
+  }
+  set sizes(sizes: number[]) {
+    this.setRelativeSizes(sizes);
+  }
+
+  /**
+   * Dispose of the resources held by the raw editor.
+   */
+  dispose(): void {
+    if (this.isDisposed) {
+      return;
+    }
+
+    super.dispose();
+    this._defaults.dispose();
+    this._user.dispose();
+  }
+
+  /**
+   * Handle `after-attach` messages.
+   */
+  protected onAfterAttach(msg: Message): void {
+    this.update();
+  }
+
+  /**
+   * Handle `'update-request'` messages.
+   */
+  protected onUpdateRequest(msg: Message): void {
+    const settings = this._settings;
+    const defaults = this._defaults;
+    const user = this._user;
+
+    if (settings) {
+      defaults.editor.refresh();
+      user.editor.refresh();
+    }
+  }
+
+  /**
+   * Handle updates to the settings.
+   */
+  private _onSettingsChanged(): void {
+    const settings = this._settings;
+    const defaults = this._defaults;
+    const user = this._user;
+    const values = settings && settings.user || { };
+
+    defaults.editor.model.value.text = settings.annotatedDefaults();
+    user.source = new ObservableJSON({ values });
+    user.source.changed.connect(this._onSourceChanged, this);
+  }
+
+  /**
+   * Handle source changes in the underlying editor.
+   */
+  private _onSourceChanged(): void {
+    const source = this._user.source;
+    const settings = this._settings;
+
+    if (!settings || !source) {
+      return;
+    }
+
+    settings.save(source.toJSON()).catch(this._onSaveError);
+  }
+
+  private _defaults: CodeEditorWrapper;
+  private _onSaveError: (reason: any) => void;
+  private _settings: ISettingRegistry.ISettings | null = null;
+  private _user: JSONEditor;
+}
+
+
+/**
+ * A namespace for `RawEditor` statics.
+ */
+export
+namespace RawEditor {
+  /**
+   * The instantiation options for a raw editor.
+   */
+  export
+  interface IOptions {
+    /**
+     * The editor factory used by the raw editor.
+     */
+    editorFactory: CodeEditor.Factory;
+
+    /**
+     * A function the raw editor calls on save errors.
+     */
+    onSaveError: (reason: any) => void;
+  }
+}
+
+
+/**
+ * A namespace for private module data.
+ */
+namespace Private {
+  /**
+   * Returns a wrapper widget to hold an editor and its banner.
+   */
+  export
+  function addBanner(editor: Widget, bannerText: string, bannerClass: string): Widget {
+    const widget = new Widget();
+    const layout = widget.layout = new BoxLayout({ spacing: 0 });
+    const banner = new Widget({
+      node: VirtualDOM.realize(h.div({ className: bannerClass }, bannerText))
+    });
+
+    layout.addWidget(banner);
+    layout.addWidget(editor);
+
+    return widget;
+  }
+}

+ 115 - 546
packages/settingeditor-extension/src/settingeditor.ts

@@ -4,47 +4,53 @@
 |----------------------------------------------------------------------------*/
 
 import {
-  Dialog, showDialog
-} from '@jupyterlab/apputils';
-
-import {
-  CodeEditor, JSONEditor
+  CodeEditor
 } from '@jupyterlab/codeeditor';
 
 import {
-  ICON_CLASS_KEY, ICON_LABEL_KEY, ISettingRegistry, IStateDB, ObservableJSON
+  ISettingRegistry, IStateDB
 } from '@jupyterlab/coreutils';
 
 import {
-  Message
-} from '@phosphor/messaging';
+  JSONExt, JSONObject, JSONValue
+} from '@phosphor/coreutils';
 
 import {
-  ISignal, Signal
-} from '@phosphor/signaling';
+  Message
+} from '@phosphor/messaging';
 
 import {
   h, VirtualDOM
 } from '@phosphor/virtualdom';
 
 import {
-  PanelLayout, SplitPanel as SPanel, Widget
+  PanelLayout, Widget
 } from '@phosphor/widgets';
 
 import {
-  TableEditor
-} from './tableeditor';
+  PluginEditor
+} from './plugineditor';
 
+import {
+  PluginList
+} from './pluginlist';
+
+import {
+  SplitPanel
+} from './splitpanel';
 
-/**
- * The ratio panes in the plugin editor.
- */
-const DEFAULT_INNER = [5, 2];
 
 /**
  * The ratio panes in the setting editor.
  */
-const DEFAULT_OUTER = [1, 3];
+const DEFAULT_LAYOUT: SettingEditor.ILayoutState = {
+  sizes: [1, 3],
+  container: {
+    editor: 'raw',
+    plugin: '',
+    sizes: [1, 1]
+  }
+};
 
 /**
  * The class name added to all setting editors.
@@ -52,24 +58,9 @@ const DEFAULT_OUTER = [1, 3];
 const SETTING_EDITOR_CLASS = 'jp-SettingEditor';
 
 /**
- * The class name added to all plugin editors.
- */
-const PLUGIN_EDITOR_CLASS = 'jp-PluginEditor';
-
-/**
- * The class name added to all plugin lists.
+ * The class name added to the top level split panel of the setting editor.
  */
-const PLUGIN_LIST_CLASS = 'jp-PluginList';
-
-/**
- * The class name added to all plugin list icons.
- */
-const PLUGIN_ICON_CLASS = 'jp-PluginList-icon';
-
-/**
- * The class name added to selected items.
- */
-const SELECTED_CLASS = 'jp-mod-selected';
+const SETTING_EDITOR_MAIN_PANEL_CLASS = 'jp-SettingEditor-main';
 
 /**
  * The class name added to the instructions widget.
@@ -103,6 +94,7 @@ const INSTRUCTIONS_TEXT = `
 Select a plugin from the list to view and edit its preferences.
 `;
 
+
 /**
  * An interface for modifying and saving application settings.
  */
@@ -137,13 +129,14 @@ class SettingEditor extends Widget {
       this._when = Array.isArray(when) ? Promise.all(when) : when;
     }
 
+    panel.addClass(SETTING_EDITOR_MAIN_PANEL_CLASS);
     layout.addWidget(panel);
     panel.addWidget(list);
     panel.addWidget(instructions);
 
-    editor.handleMoved.connect(this._onHandleMoved, this);
-    list.selected.connect(this._onSelected, this);
-    panel.handleMoved.connect(this._onHandleMoved, this);
+    editor.stateChanged.connect(this._onStateChanged, this);
+    list.changed.connect(this._onStateChanged, this);
+    panel.handleMoved.connect(this._onStateChanged, this);
   }
 
   /**
@@ -189,19 +182,14 @@ class SettingEditor extends Widget {
    */
   protected onAfterAttach(msg: Message): void {
     super.onAfterAttach(msg);
-
-    // Allow the message queue (which includes fit requests that might disrupt
-    // setting relative sizes) to clear before setting sizes.
-    requestAnimationFrame(() => {
-      this._panel.hide();
-      this._fetchState().then(() => {
-        this._panel.show();
-        this._setPresets();
-      }).catch(reason => {
-        console.error('Fetching setting editor state failed', reason);
-        this._panel.show();
-        this._setPresets();
-      });
+    this._panel.hide();
+    this._fetchState().then(() => {
+      this._panel.show();
+      this._setState();
+    }).catch(reason => {
+      console.error('Fetching setting editor state failed', reason);
+      this._panel.show();
+      this._setState();
     });
   }
 
@@ -224,7 +212,6 @@ class SettingEditor extends Widget {
     }
 
     const { key, state } = this;
-    const editor = this._editor;
     const promises = [state.fetch(key), this._when];
 
     return this._fetching = Promise.all(promises).then(([saved]) => {
@@ -234,50 +221,23 @@ class SettingEditor extends Widget {
         return;
       }
 
-      const inner = DEFAULT_INNER;
-      const outer = DEFAULT_OUTER;
-      const plugin = editor.settings ? editor.settings.plugin : '';
-
-      if (!saved) {
-        this._presets = { inner, outer, plugin };
-        return;
-      }
-
-      const presets = this._presets;
-
-      if (Array.isArray(saved.inner)) {
-        presets.inner = saved.inner as number[];
-      }
-      if (Array.isArray(saved.outer)) {
-        presets.outer = saved.outer as number[];
-      }
-      if (typeof saved.plugin === 'string') {
-        presets.plugin = saved.plugin as string;
-      }
-    });
-  }
-
-  /**
-   * Handle layout changes.
-   */
-  private _onHandleMoved(): void {
-    this._presets.inner = this._editor.sizes;
-    this._presets.outer = this._panel.relativeSizes();
-    this._saveState().catch(reason => {
-      console.error('Saving setting editor state failed', reason);
+      this._state = Private.normalizeState(saved, this._state);
     });
   }
 
   /**
-   * Handle a new selection in the plugin list.
+   * Handle root level layout state changes.
    */
-  private _onSelected(sender: any, plugin: string): void {
-    this._presets.plugin = plugin;
+  private _onStateChanged(): void {
+    this._state.sizes = this._panel.relativeSizes();
+    this._state.container = this._editor.state;
+    this._state.container.editor = this._list.editor;
+    this._state.container.plugin = this._list.selection;
     this._saveState()
-      .then(() => { this._setPresets(); })
+      .then(() => { this._setState(); })
       .catch(reason => {
         console.error('Saving setting editor state failed', reason);
-        this._setPresets();
+        this._setState();
       });
   }
 
@@ -286,41 +246,56 @@ class SettingEditor extends Widget {
    */
   private _saveState(): Promise<void> {
     const { key, state } = this;
-    const value = this._presets;
+    const value = this._state;
 
     this._saving = true;
     return state.save(key, value)
       .then(() => { this._saving = false; })
-      .catch(reason => {
+      .catch((reason: any) => {
         this._saving = false;
         throw reason;
       });
   }
 
+  /**
+   * Set the layout sizes.
+   */
+  private _setLayout(): void {
+    const editor = this._editor;
+    const panel = this._panel;
+    const state = this._state;
+
+    editor.state = state.container;
+
+    // Allow the message queue (which includes fit requests that might disrupt
+    // setting relative sizes) to clear before setting sizes.
+    requestAnimationFrame(() => { panel.setRelativeSizes(state.sizes); });
+  }
+
   /**
    * Set the presets of the setting editor.
    */
-  private _setPresets(): void {
+  private _setState(): void {
     const editor = this._editor;
     const list = this._list;
     const panel = this._panel;
-    const { plugin } = this._presets;
+    const { container } = this._state;
 
-    if (!plugin) {
+    if (!container.plugin) {
       editor.settings = null;
       list.selection = '';
-      this._setSizes();
+      this._setLayout();
       return;
     }
 
-    if (editor.settings && editor.settings.plugin === plugin) {
-      this._setSizes();
+    if (editor.settings && editor.settings.plugin === container.plugin) {
+      this._setLayout();
       return;
     }
 
     const instructions = this._instructions;
 
-    this.registry.load(plugin).then(settings => {
+    this.registry.load(container.plugin).then(settings => {
       if (instructions.isAttached) {
         instructions.parent = null;
       }
@@ -328,35 +303,24 @@ class SettingEditor extends Widget {
         panel.addWidget(editor);
       }
       editor.settings = settings;
-      list.selection = plugin;
-      this._setSizes();
+      list.editor = container.editor;
+      list.selection = container.plugin;
+      this._setLayout();
     }).catch((reason: Error) => {
       console.error(`Loading settings failed: ${reason.message}`);
-      list.selection = this._presets.plugin = '';
+      list.selection = this._state.container.plugin = '';
       editor.settings = null;
-      this._setSizes();
+      this._setLayout();
     });
   }
 
-  /**
-   * Set the layout sizes.
-   */
-  private _setSizes(): void {
-    const { inner, outer } = this._presets;
-    const editor = this._editor;
-    const panel = this._panel;
-
-    editor.sizes = inner;
-    panel.setRelativeSizes(outer);
-  }
-
   private _editor: PluginEditor;
   private _fetching: Promise<void> | null = null;
   private _instructions: Widget;
   private _list: PluginList;
   private _panel: SplitPanel;
-  private _presets = { inner: DEFAULT_INNER, outer: DEFAULT_OUTER, plugin: '' };
   private _saving = false;
+  private _state: SettingEditor.ILayoutState = JSONExt.deepCopy(DEFAULT_LAYOUT);
   private _when: Promise<any>;
 }
 
@@ -396,389 +360,36 @@ namespace SettingEditor {
      */
     when?: Promise<any> | Array<Promise<any>>;
   }
-}
-
-
-/**
- * A deprecated split panel that will be removed when the phosphor split panel
- * supports a handle moved signal.
- */
-class SplitPanel extends SPanel {
-  /**
-   * Emits when the split handle has moved.
-   */
-  readonly handleMoved: ISignal<any, void> = new Signal<any, void>(this);
-
-  handleEvent(event: Event): void {
-    super.handleEvent(event);
-
-    if (event.type === 'mouseup') {
-      (this.handleMoved as Signal<any, void>).emit(void 0);
-    }
-  }
-}
-
-
-/**
- * A list of plugins with editable settings.
- */
-class PluginList extends Widget {
-  /**
-   * Create a new plugin list.
-   */
-  constructor(options: PluginList.IOptions) {
-    super({ node: document.createElement('ul') });
-    this.registry = options.registry;
-    this.addClass(PLUGIN_LIST_CLASS);
-    this._confirm = options.confirm;
-    this.registry.pluginChanged.connect(() => { this.update(); }, this);
-  }
-
-  /**
-   * The setting registry.
-   */
-  readonly registry: ISettingRegistry;
-
-  /**
-   * A signal emitted when a selection is made from the plugin list.
-   */
-  get selected(): ISignal<this, string> {
-    return this._selected;
-  }
-
-  /**
-   * The selection value of the plugin list.
-   */
-  get selection(): string {
-    return this._selection;
-  }
-  set selection(selection: string) {
-    if (this._selection === selection) {
-      return;
-    }
-    this._selection = selection;
-    this.update();
-  }
-
-  /**
-   * Handle the DOM events for the widget.
-   *
-   * @param event - The DOM event sent to the widget.
-   *
-   * #### Notes
-   * This method implements the DOM `EventListener` interface and is
-   * called in response to events on the plugin list's node. It should
-   * not be called directly by user code.
-   */
-  handleEvent(event: Event): void {
-    switch (event.type) {
-    case 'click':
-      this._evtClick(event as MouseEvent);
-      break;
-    default:
-      break;
-    }
-  }
-
-  /**
-   * Reset the list selection.
-   */
-  reset(): void {
-    this._selection = '';
-    this._selected.emit('');
-    this.update();
-  }
-
-  /**
-   * Handle `'after-attach'` messages.
-   */
-  protected onAfterAttach(msg: Message): void {
-    this.node.addEventListener('click', this);
-    this.update();
-  }
-
-  /**
-   * Handle `before-detach` messages for the widget.
-   */
-  protected onBeforeDetach(msg: Message): void {
-    this.node.removeEventListener('click', this);
-  }
-
-  /**
-   * Handle `'update-request'` messages.
-   */
-  protected onUpdateRequest(msg: Message): void {
-    const plugins = Private.sortPlugins(this.registry.plugins);
-
-    this.node.textContent = '';
-    plugins.forEach(plugin => {
-      const item = Private.createListItem(this.registry, plugin);
-
-      if (plugin.id === this._selection) {
-        item.classList.add(SELECTED_CLASS);
-      }
-
-      this.node.appendChild(item);
-    });
-  }
-
-  /**
-   * Handle the `'click'` event for the plugin list.
-   *
-   * @param event - The DOM event sent to the widget
-   */
-  private _evtClick(event: MouseEvent): void {
-    let target = event.target as HTMLElement;
-    let id = target.getAttribute('data-id');
-
-    if (id === this._selection) {
-      return;
-    }
-
-    if (!id) {
-      while (!id && target !== this.node) {
-        target = target.parentElement as HTMLElement;
-        id = target.getAttribute('data-id');
-      }
-    }
-
-    if (id) {
-      this._confirm().then(() => {
-        if (!id) {
-          return;
-        }
-        this._selection = id;
-        this._selected.emit(id);
-        this.update();
-      }).catch(() => { /* no op */ });
-    }
-  }
 
-  private _confirm: () => Promise<void>;
-  private _selected = new Signal<this, string>(this);
-  private _selection = '';
-}
-
-
-/**
- * A namespace for `PluginList` statics.
- */
-namespace PluginList {
   /**
-   * The instantiation options for a plugin list.
+   * The layout state for the setting editor.
    */
   export
-  interface IOptions {
+  interface ILayoutState extends JSONObject {
     /**
-     * A function that allows for asynchronously confirming a selection.
-     *
-     * #### Notest
-     * If the promise returned by the function resolves, then the selection will
-     * succeed and emit an event. If the promise rejects, the selection is not
-     * made.
+     * The layout state for a plugin editor container.
      */
-    confirm: () => Promise<void>;
+    container: IPluginLayout;
 
     /**
-     * The setting registry for the plugin list.
+     * The relative sizes of the plugin list and plugin editor.
      */
-    registry: ISettingRegistry;
-  }
-}
-
-
-/**
- * An individual plugin settings editor.
- */
-class PluginEditor extends Widget {
-  /**
-   * Create a new plugin editor.
-   */
-  constructor(options: PluginEditor.IOptions) {
-    super();
-    this.addClass(PLUGIN_EDITOR_CLASS);
-
-    const { editorFactory } = options;
-    const collapsible = false;
-    const layout = this.layout = new PanelLayout();
-    const panel = this._panel = new SplitPanel({
-      orientation: 'vertical',
-      renderer: SplitPanel.defaultRenderer,
-      spacing: 1
-    });
-
-    this.handleMoved = panel.handleMoved;
-    this._editor = new JSONEditor({ collapsible, editorFactory });
-    this._fieldset = new TableEditor({ onSaveError: Private.onSaveError });
-
-    layout.addWidget(panel);
-    panel.addWidget(this._editor);
-    panel.addWidget(this._fieldset);
-  }
-
-  /**
-   * Emits when the split handle has moved.
-   */
-  readonly handleMoved: ISignal<any, void>;
-
-  /**
-   * The plugin settings being edited.
-   */
-  get settings(): ISettingRegistry.ISettings | null {
-    return this._settings;
-  }
-  set settings(settings: ISettingRegistry.ISettings | null) {
-    if (!settings && !this._settings) {
-      return;
-    }
-
-    const samePlugin = (settings && this._settings) &&
-      settings.plugin === this._settings.plugin;
-
-    if (samePlugin) {
-      return;
-    }
-
-    const fieldset = this._fieldset;
-    const editor = this._editor;
-
-    // Disconnect old source change handler.
-    if (editor.source) {
-      editor.source.changed.disconnect(this._onSourceChanged, this);
-    }
-
-    // Disconnect old settings change handler.
-    if (this._settings) {
-      this._settings.changed.disconnect(this._onSettingsChanged, this);
-    }
-
-    if (settings) {
-      this._settings = fieldset.settings = settings;
-      this._settings.changed.connect(this._onSettingsChanged, this);
-      this._onSettingsChanged();
-    } else {
-      this._settings = fieldset.settings = null;
-      editor.source = null;
-    }
-
-    this.update();
-  }
-
-  /**
-   * Get the relative sizes of the two editor panels.
-   */
-  get sizes(): number[] {
-    return this._panel.relativeSizes();
-  }
-  set sizes(sizes: number[]) {
-    this._panel.setRelativeSizes(sizes);
-  }
-
-  /**
-   * If the editor is in a dirty state, confirm that the user wants to leave.
-   */
-  confirm(): Promise<void> {
-    if (this.isHidden || !this.isAttached || !this._editor.isDirty) {
-      return Promise.resolve(void 0);
-    }
-
-    return showDialog({
-      title: 'You have unsaved changes.',
-      body: 'Do you want to leave without saving?',
-      buttons: [Dialog.cancelButton(), Dialog.okButton()]
-    }).then(result => {
-      if (!result.button.accept) {
-        throw new Error('User cancelled.');
-      }
-    });
+    sizes: number[];
   }
 
   /**
-   * Dispose of the resources held by the plugin editor.
-   */
-  dispose(): void {
-    if (this.isDisposed) {
-      return;
-    }
-
-    super.dispose();
-    this._editor.dispose();
-    this._fieldset.dispose();
-    this._panel.dispose();
-  }
-
-  /**
-   * Handle `after-attach` messages.
-   */
-  protected onAfterAttach(msg: Message): void {
-    this.update();
-  }
-
-  /**
-   * Handle `'update-request'` messages.
-   */
-  protected onUpdateRequest(msg: Message): void {
-    const json = this._editor;
-    const fieldset = this._fieldset;
-    const settings = this._settings;
-
-    if (settings) {
-      json.show();
-      fieldset.show();
-      json.editor.refresh();
-      return;
-    }
-
-    json.hide();
-    fieldset.hide();
-  }
-
-  /**
-   * Handle updates to the settings.
-   */
-  private _onSettingsChanged(): void {
-    const editor = this._editor;
-    const settings = this._settings;
-    const values = settings && settings.user || { };
-
-    editor.source = new ObservableJSON({ values });
-    editor.source.changed.connect(this._onSourceChanged, this);
-  }
-
-  /**
-   * Handle source changes in the underlying editor.
-   */
-  private _onSourceChanged(): void {
-    const source = this._editor.source;
-    const settings = this._settings;
-
-    if (!settings || !source) {
-      return;
-    }
-
-    settings.save(source.toJSON()).catch(Private.onSaveError);
-  }
-
-  private _editor: JSONEditor;
-  private _fieldset: TableEditor;
-  private _panel: SplitPanel;
-  private _settings: ISettingRegistry.ISettings | null = null;
-}
-
-
-/**
- * A namespace for `PluginEditor` statics.
- */
-namespace PluginEditor {
-  /**
-   * The instantiation options for a plugin editor.
+   * The layout information that is stored and restored from the state database.
    */
   export
-  interface IOptions {
+  interface IPluginLayout extends JSONObject {
     /**
-     * The editor factory used by the plugin editor.
+     * The current plugin being displayed.
      */
-    editorFactory: CodeEditor.Factory;
+    plugin: string;
+
+    editor: 'raw' | 'table';
+
+    sizes: number[];
   }
 }
 
@@ -799,80 +410,38 @@ namespace Private {
       h.span({ className: INSTRUCTIONS_TEXT_CLASS }, INSTRUCTIONS_TEXT)));
   }
 
-  /**
-   * Create a plugin list item.
-   */
   export
-  function createListItem(registry: ISettingRegistry, plugin: ISettingRegistry.IPlugin): HTMLLIElement {
-    const icon = getHint(ICON_CLASS_KEY, registry, plugin);
-    const iconClass = `${PLUGIN_ICON_CLASS}${icon ? ' ' + icon : ''}`;
-    const iconLabel = getHint(ICON_LABEL_KEY, registry, plugin);
-    const title = plugin.schema.title || plugin.id;
-    const caption = `(${plugin.id}) ${plugin.schema.description}`;
-
-    return VirtualDOM.realize(
-      h.li({ dataset: { id: plugin.id }, title: caption },
-        h.span({ className: iconClass, title: iconLabel }),
-        h.span(title))
-    ) as HTMLLIElement;
-  }
-
-  /**
-   * Check the plugin for a rendering hint's value.
-   *
-   * #### Notes
-   * The order of priority for overridden hints is as follows, from most
-   * important to least:
-   * 1. Data set by the end user in a settings file.
-   * 2. Data set by the plugin author as a schema default.
-   * 3. Data set by the plugin author as a top-level key of the schema.
-   */
-  function getHint(key: string, registry: ISettingRegistry, plugin: ISettingRegistry.IPlugin): string {
-    // First, give priorty to checking if the hint exists in the user data.
-    let hint = plugin.data.user[key];
-
-    // Second, check to see if the hint exists in composite data, which folds
-    // in default values from the schema.
-    if (!hint) {
-      hint = plugin.data.composite[key];
+  function normalizeState(saved: JSONObject | null, current: SettingEditor.ILayoutState): SettingEditor.ILayoutState {
+    if (!saved) {
+      return JSONExt.deepCopy(DEFAULT_LAYOUT);
     }
 
-    // Third, check to see if the plugin schema has defined the hint.
-    if (!hint) {
-      hint = plugin.schema[key];
+    if (!('sizes' in saved) || !numberArray(saved.sizes)) {
+      saved.sizes = JSONExt.deepCopy(DEFAULT_LAYOUT.sizes);
     }
-
-    // Finally, use the defaults from the registry schema.
-    if (!hint) {
-      const properties = registry.schema.properties;
-
-      hint = properties && properties[key] && properties[key].default;
+    if (!('container' in saved)) {
+      saved.container = JSONExt.deepCopy(DEFAULT_LAYOUT.container);
+      return saved as SettingEditor.ILayoutState;
     }
 
-    return typeof hint === 'string' ? hint : '';
-  }
+    const container = ('container' in saved) &&
+      saved.container &&
+      typeof saved.container === 'object' ? saved.container as JSONObject
+        : { };
 
-  /**
-   * Handle save errors.
-   */
-  export
-  function onSaveError(reason: any): void {
-    console.error(`Saving setting editor value failed: ${reason.message}`);
+    saved.container = {
+      editor: container.editor === 'raw' || container.editor === 'table' ?
+        container.editor : DEFAULT_LAYOUT.container.editor,
+      plugin: typeof container.plugin === 'string' ? container.plugin
+        : DEFAULT_LAYOUT.container.plugin,
+      sizes: numberArray(container.sizes) ? container.sizes
+        : JSONExt.deepCopy(DEFAULT_LAYOUT.container.sizes)
+    };
 
-    showDialog({
-      title: 'Your changes were not saved.',
-      body: reason.message,
-      buttons: [Dialog.okButton()]
-    });
+    return saved as SettingEditor.ILayoutState;
   }
 
-  /**
-   * Sort a list of plugins by ID.
-   */
-  export
-  function sortPlugins(plugins: ISettingRegistry.IPlugin[]): ISettingRegistry.IPlugin[] {
-    return plugins.sort((a, b) => {
-      return (a.schema.title || a.id).localeCompare(b.schema.title || b.id);
-    });
+  function numberArray(value: JSONValue): boolean {
+    return Array.isArray(value) && value.every(x => typeof x === 'number');
   }
 }

+ 33 - 0
packages/settingeditor-extension/src/splitpanel.ts

@@ -0,0 +1,33 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+import {
+  SplitPanel as SPanel
+} from '@phosphor/widgets';
+
+import {
+  ISignal, Signal
+} from '@phosphor/signaling';
+
+
+/**
+ * A deprecated split panel that will be removed when the phosphor split panel
+ * supports a handle moved signal.
+ */
+export
+class SplitPanel extends SPanel {
+  /**
+   * Emits when the split handle has moved.
+   */
+  readonly handleMoved: ISignal<any, void> = new Signal<any, void>(this);
+
+  handleEvent(event: Event): void {
+    super.handleEvent(event);
+
+    if (event.type === 'mouseup') {
+      (this.handleMoved as Signal<any, void>).emit(void 0);
+    }
+  }
+}

+ 16 - 107
packages/settingeditor-extension/src/tableeditor.ts

@@ -23,47 +23,27 @@ import {
 /**
  * The class name added to all table editors.
  */
-const TABLE_EDITOR_CLASS = 'jp-TableEditor';
+const TABLE_EDITOR_CLASS = 'jp-SettingsTableEditor';
 
 /**
  * The class name added to the table wrapper to handle overflow.
  */
-const TABLE_EDITOR_WRAPPER_CLASS = 'jp-TableEditor-wrapper';
-
-/**
- * The class name added to the table add button cells.
- */
-const TABLE_EDITOR_ADD_CLASS = 'jp-TableEditor-add';
+const TABLE_EDITOR_WRAPPER_CLASS = 'jp-SettingsTableEditor-wrapper';
 
 /**
  * The class name added to the table key cells.
  */
-const TABLE_EDITOR_KEY_CLASS = 'jp-TableEditor-key';
+const TABLE_EDITOR_KEY_CLASS = 'jp-SettingsTableEditor-key';
 
 /**
  * The class name added to the table default value cells.
  */
-const TABLE_EDITOR_VALUE_CLASS = 'jp-TableEditor-value';
+const TABLE_EDITOR_VALUE_CLASS = 'jp-SettingsTableEditor-value';
 
 /**
  * The class name added to the table type cells.
  */
-const TABLE_EDITOR_TYPE_CLASS = 'jp-TableEditor-type';
-
-/**
- * The class name added to buttons.
- */
-const TABLE_EDITOR_BUTTON_CLASS = 'jp-TableEditor-button';
-
-/**
- * The class name for the add icon used to add individual preferences.
- */
-const TABLE_EDITOR_ADD_ICON_CLASS = 'jp-AddIcon';
-
-/**
- * The class name added to active items.
- */
-const ACTIVE_CLASS = 'jp-mod-active';
+const TABLE_EDITOR_TYPE_CLASS = 'jp-SettingsTableEditor-type';
 
 
 /**
@@ -80,6 +60,13 @@ class TableEditor extends Widget {
     this._onSaveError = options.onSaveError;
   }
 
+  /**
+   * Tests whether the settings have been modified and need saving.
+   */
+  get isDirty(): boolean {
+    return false; // TODO: remove placeholder.
+  }
+
   /**
    * The plugin settings.
    */
@@ -90,44 +77,11 @@ class TableEditor extends Widget {
     if (this._settings) {
       this._settings.changed.disconnect(this._onSettingsChanged, this);
     }
-
     this._settings = settings;
-    this._settings.changed.connect(this._onSettingsChanged, this);
-    this.update();
-  }
-
-  /**
-   * Handle the DOM events for the plugin fieldset class.
-   *
-   * @param event - The DOM event sent to the class.
-   *
-   * #### Notes
-   * This method implements the DOM `EventListener` interface and is
-   * called in response to events on the fieldset's DOM node. It should
-   * not be called directly by user code.
-   */
-  handleEvent(event: Event): void {
-    switch (event.type) {
-    case 'click':
-      this._evtClick(event as MouseEvent);
-      break;
-    default:
-      return;
+    if (this._settings) {
+      this._settings.changed.connect(this._onSettingsChanged, this);
     }
-  }
-
-  /**
-   * Handle `'after-attach'` messages.
-   */
-  protected onAfterAttach(msg: Message): void {
-    this.node.addEventListener('click', this);
-  }
-
-  /**
-   * Handle `before-detach` messages for the widget.
-   */
-  protected onBeforeDetach(msg: Message): void {
-    this.node.removeEventListener('click', this);
+    this.update();
   }
 
   /**
@@ -145,39 +99,6 @@ class TableEditor extends Widget {
     }
   }
 
-  /**
-   * Handle the `'click'` event for the plugin fieldset.
-   *
-   * @param event - The DOM event sent to the widget
-   */
-  private _evtClick(event: MouseEvent): void {
-    const attribute = 'data-property';
-    const root = this.node;
-    let target = event.target as HTMLElement;
-
-    while (target && target.parentElement !== root) {
-      const active = target.classList.contains(ACTIVE_CLASS);
-
-      if (active && target.hasAttribute(attribute)) {
-        event.preventDefault();
-        this._onPropertyAdded(target.getAttribute(attribute));
-        target.classList.remove(ACTIVE_CLASS);
-        return;
-      }
-      target = target.parentElement;
-    }
-  }
-
-  /**
-   * Handle a property addition.
-   */
-  private _onPropertyAdded(property: string): void {
-    const settings = this._settings;
-
-    settings.save({ ...settings.user, [property]: settings.default(property) })
-      .catch(this._onSaveError);
-  }
-
   /**
    * Handle setting changes.
    */
@@ -218,13 +139,12 @@ namespace Private {
    */
   export
   function populateTable(node: HTMLElement, settings: ISettingRegistry.ISettings): void {
-    const { plugin, schema, user } = settings;
+    const { plugin, schema } = settings;
     const fields: { [property: string]: VirtualElement } = Object.create(null);
     const properties = schema.properties || { };
     const title = `(${plugin}) ${schema.description}`;
     const label = `Fields - ${schema.title || plugin}`;
     const headers = h.tr(
-      h.th({ className: TABLE_EDITOR_ADD_CLASS }, ''),
       h.th({ className: TABLE_EDITOR_KEY_CLASS }, 'Key'),
       h.th({ className: TABLE_EDITOR_VALUE_CLASS }, 'Default'),
       h.th({ className: TABLE_EDITOR_TYPE_CLASS }, 'Type'));
@@ -232,22 +152,11 @@ namespace Private {
     Object.keys(properties).forEach(property => {
       const field = properties[property];
       const { type } = field;
-      const exists = property in user;
       const defaultValue = settings.default(property);
       const value = JSON.stringify(defaultValue) || '';
       const valueTitle = JSON.stringify(defaultValue, null, 4);
-      const addButton = TABLE_EDITOR_BUTTON_CLASS + ' ' +
-        TABLE_EDITOR_ADD_ICON_CLASS;
-      const buttonCell = exists ? h.td({ className: TABLE_EDITOR_ADD_CLASS })
-        : h.td({
-            className: `${TABLE_EDITOR_ADD_CLASS} ${ACTIVE_CLASS}`,
-            dataset: { property }
-          }, h.div({
-            className: addButton
-          }));
 
       fields[property] = h.tr(
-        buttonCell,
         h.td({
           className: TABLE_EDITOR_KEY_CLASS,
           title: field.title || property

+ 79 - 59
packages/settingeditor-extension/style/settingeditor.css

@@ -5,10 +5,11 @@
 
 
 :root {
+  --jp-private-settingeditor-banner-height: 24px;
+  --jp-private-settingeditor-key-width: 150px;
   --jp-private-settingeditor-legend-height: 16px;
   --jp-private-settingeditor-row-height: 16px;
-  --jp-private-settingeditor-add-width: 16px;
-  --jp-private-settingeditor-key-width: 150px;
+  --jp-private-settingeditor-switcher-height: 32px;
   --jp-private-settingeditor-type-width: 75px;
 }
 
@@ -32,9 +33,12 @@
 }
 
 
-#setting-editor .p-SplitPanel {
-  height: 100%;
-  width: 100%;
+#setting-editor > .p-Widget {
+  position: absolute;
+  top: var(--jp-toolbar-micro-height);
+  bottom: 0;
+  left: 0;
+  right: 0;
 }
 
 
@@ -71,19 +75,61 @@
 }
 
 
-#setting-editor ul.jp-PluginList {
+#setting-editor .jp-PluginList {
+  min-width: 150px;
+  width: 150px;
+}
+
+
+#setting-editor .jp-PluginList-switcher {
+  height: var(--jp-private-settingeditor-switcher-height);
+  position: absolute;
+  top: 1px;
+  left: 0;
+  right: 0;
+}
+
+
+#setting-editor .jp-PluginList-switcher button {
+  background: var(--jp-layout-color2);
+  color: var(--jp-ui-font-color2);
+  border: 0;
+  border-bottom: 1px solid var(--jp-ui-font-color3);
+  margin: 0;
+  padding: 0;
+  height: var(--jp-private-settingeditor-switcher-height);
+  width: 50%;
+}
+
+
+#setting-editor .jp-PluginList-switcher button:first-child {
+  border-right: 1px solid var(--jp-ui-font-color3);
+}
+
+
+#setting-editor .jp-PluginList-switcher button:disabled {
+  background: var(--jp-layout-color1);
+  color: var(--jp-ui-font-color0);
+}
+
+
+#setting-editor .jp-PluginList ul {
   background-color: var(--jp-layout-color1);
   color: var(--jp-ui-font-color1);
   font-size: var(--jp-ui-font-size1);
   list-style-type: none;
   margin: 0;
   padding: 0;
-  min-width: 150px;
-  width: 150px;
+  overflow-y: auto;
+  position: absolute;
+  top: var(--jp-private-settingeditor-switcher-height);
+  bottom: 0;
+  left: 0;
+  right: 0;
 }
 
 
-#setting-editor ul.jp-PluginList li {
+#setting-editor .jp-PluginList li {
   border: 1px solid transparent;
   overflow: hidden;
   padding: 2px 0 5px 5px;
@@ -92,13 +138,13 @@
 }
 
 
-#setting-editor ul.jp-PluginList li:hover {
+#setting-editor .jp-PluginList li:hover {
   background-color: var(--jp-layout-color2);
   border: 1px solid var(--jp-border-color2);
 }
 
 
-#setting-editor ul.jp-PluginList li.jp-mod-selected {
+#setting-editor .jp-PluginList li.jp-mod-selected {
   background-color: var(--jp-brand-color1);
   color: white;
   border: 1px solid var(--jp-brand-color1);
@@ -120,34 +166,37 @@
 }
 
 
-#setting-editor .jp-PluginEditor {
-  padding: 10px;
+#setting-editor .jp-SettingsRawEditor .jp-SettingsRawEditor-banner {
+  color: var(--jp-ui-font-color0);
+  font-size: var(--jp-ui-font-size1);
+  height: var(--jp-private-settingeditor-banner-height);
+  max-height: var(--jp-private-settingeditor-banner-height);
+  padding-top: 5px;
+  text-align: center;
 }
 
 
-#setting-editor .jp-PluginEditor .jp-JSONEditor-host {
-  height: 100%;
-  margin-left: 0;
-  margin-right: 0;
+#setting-editor .jp-SettingsRawEditor .jp-JSONEditor-host {
+  background-color: var(--jp-layout-color1);
   overflow: auto;
 }
 
 
-#setting-editor .jp-PluginEditor .jp-JSONEditor-header {
+#setting-editor .jp-SettingsRawEditor .jp-JSONEditor-header {
   position: absolute;
   right: 0;
   z-index: 9999;
 }
 
 
-#setting-editor .jp-TableEditor {
+#setting-editor .jp-SettingsTableEditor {
   border: 1px solid var(--jp-brand-color1);
   margin: 0;
   padding: 0;
 }
 
 
-#setting-editor .jp-TableEditor legend {
+#setting-editor .jp-SettingsTableEditor legend {
   color: var(--jp-brand-color1);
   font-size: 70%;
   font-weight: bold;
@@ -156,7 +205,7 @@
 }
 
 
-#setting-editor .jp-TableEditor-wrapper {
+#setting-editor .jp-SettingsTableEditor-wrapper {
   position: absolute;
   top: var(--jp-private-settingeditor-legend-height);
   bottom: 0;
@@ -164,7 +213,7 @@
 }
 
 
-#setting-editor .jp-TableEditor table {
+#setting-editor .jp-SettingsTableEditor table {
   table-layout: fixed;
   color: var(--jp-ui-font-color1);
   font-size: var(--jp-ui-font-size1);
@@ -174,14 +223,14 @@
 }
 
 
-#setting-editor .jp-TableEditor tr {
+#setting-editor .jp-SettingsTableEditor tr {
   color: var(--jp-ui-font-color2);
   height: var( --jp-private-settingeditor-row-height);
   overflow: hidden;
 }
 
 
-#setting-editor .jp-TableEditor th {
+#setting-editor .jp-SettingsTableEditor th {
   background-color: var(--jp-layout-color3);
   border: 1px solid transparent;
   font-weight: bold;
@@ -189,65 +238,36 @@
 }
 
 
-#setting-editor .jp-TableEditor td {
+#setting-editor .jp-SettingsTableEditor td {
   border: 1px solid transparent;
   height: var( --jp-private-settingeditor-row-height);
 }
 
 
-#setting-editor .jp-TableEditor th.jp-TableEditor-add {
-  width: var(--jp-private-settingeditor-add-width);
-}
-
-
-#setting-editor .jp-TableEditor th.jp-TableEditor-key {
+#setting-editor .jp-SettingsTableEditor th.jp-SettingsTableEditor-key {
   width: var(--jp-private-settingeditor-key-width);
 }
 
 
-#setting-editor .jp-TableEditor th.jp-TableEditor-type {
+#setting-editor .jp-SettingsTableEditor th.jp-SettingsTableEditor-type {
   width: var(--jp-private-settingeditor-type-width);
 }
 
 
-#setting-editor .jp-TableEditor td.jp-TableEditor-add {
-  cursor: pointer;
-  text-align: center;
-}
-
-
-#setting-editor .jp-TableEditor td.jp-TableEditor-add.jp-mod-active:hover {
-  border: 1px solid var(--jp-toolbar-border-color);
-}
-
-
-#setting-editor .jp-TableEditor td.jp-TableEditor-key {
+#setting-editor .jp-SettingsTableEditor td.jp-SettingsTableEditor-key {
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
 }
 
 
-#setting-editor .jp-TableEditor td.jp-TableEditor-value {
+#setting-editor .jp-SettingsTableEditor td.jp-SettingsTableEditor-value {
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
 }
 
 
-#setting-editor .jp-TableEditor td.jp-TableEditor-type {
+#setting-editor .jp-SettingsTableEditor td.jp-SettingsTableEditor-type {
   text-align: right;
 }
-
-
-#setting-editor .jp-TableEditor .jp-TableEditor-button {
-  display: inline-block;
-  background-position: center;
-  background-repeat: no-repeat;
-  background-size: 8px;
-  border: 1px solid var(--jp-layout-color3);
-  border-radius: 8px;
-  width: 8px;
-  height: 8px;
-  margin-top: 3px;
-}