浏览代码

Merge pull request #181 from blink1073/filebrowser-options

Clean up File Browser interfaces
A. Darian 8 年之前
父节点
当前提交
73dd960c5f

+ 10 - 2
examples/filebrowser/src/index.ts

@@ -98,8 +98,16 @@ function createApp(sessionManager: SessionManager, kernelspecs: IKernel.ISpecMod
     canStartKernel: true
   });
 
-  let fbModel = new FileBrowserModel(contentsManager, sessionManager, kernelspecs);
-  let fbWidget = new FileBrowserWidget(fbModel, docManager, opener);
+  let fbModel = new FileBrowserModel({
+    contentsManager,
+    sessionManager,
+    kernelspecs
+  });
+  let fbWidget = new FileBrowserWidget({
+    model: fbModel,
+    manager: docManager,
+    opener
+  });
 
   let panel = new SplitPanel();
   panel.id = 'main';

+ 50 - 9
src/filebrowser/browser.ts

@@ -82,16 +82,22 @@ class FileBrowserWidget extends Widget {
    *
    * @param model - The file browser view model.
    */
-  constructor(model: FileBrowserModel, manager: DocumentManager, opener: IWidgetOpener) {
+  constructor(options: FileBrowserWidget.IOptions) {
     super();
     this.addClass(FILE_BROWSER_CLASS);
-    this._model = model;
-    this._model.refreshed.connect(this._handleRefresh, this);
-    this._crumbs = new BreadCrumbs(model);
-    this._buttons = new FileButtons(model, manager, opener);
-    this._listing = new DirListing(model, manager, opener);
-    this._manager = manager;
-    this._opener = opener;
+    let model = this._model = options.model;
+    let manager = this._manager = options.manager;
+    let opener = this._opener = options.opener;
+
+    model.refreshed.connect(this._handleRefresh, this);
+    this._crumbs = new BreadCrumbs({ model });
+    this._buttons = new FileButtons({ model, manager, opener });
+    this._listing = new DirListing({
+      model,
+      manager,
+      opener,
+      renderer: options.renderer
+    });
 
     model.fileChanged.connect((fbModel, args) => {
       if (args.newValue) {
@@ -151,7 +157,7 @@ class FileBrowserWidget extends Widget {
    */
   open(): void {
     let foundDir = false;
-    let items = this._model.sortedItems;
+    let items = this._model.items;
     for (let item of items) {
       if (!this._listing.isSelected(item.name)) {
         continue;
@@ -311,3 +317,38 @@ class FileBrowserWidget extends Widget {
   private _manager: DocumentManager = null;
   private _opener: IWidgetOpener = null;
 }
+
+
+/**
+ * The namespace for the `FileBrowserWidget` class statics.
+ */
+export
+namespace FileBrowserWidget {
+  /**
+   * An options object for initializing a file browser widget.
+   */
+  export
+  interface IOptions {
+    /**
+     * A file browser model instance.
+     */
+    model: FileBrowserModel;
+
+    /**
+     * A document manager instance.
+     */
+    manager: DocumentManager;
+
+    /**
+     * A widget opener function.
+     */
+    opener: IWidgetOpener;
+
+    /**
+     * An optional renderer for the directory listing area.
+     *
+     * The default is a shared instance of `DirListing.Renderer`.
+     */
+    renderer?: DirListing.IRenderer;
+  }
+}

+ 33 - 4
src/filebrowser/buttons.ts

@@ -93,10 +93,10 @@ class FileButtons extends Widget {
    *
    * @param model - The file browser view model.
    */
-  constructor(model: FileBrowserModel, manager: DocumentManager, opener: IWidgetOpener) {
+  constructor(options: FileButtons.IOptions) {
     super();
     this.addClass(FILE_BUTTONS_CLASS);
-    this._model = model;
+    this._model = options.model;
 
     this._buttons.create.onmousedown = this._onCreateButtonPressed;
     this._buttons.upload.onclick = this._onUploadButtonClicked;
@@ -108,8 +108,8 @@ class FileButtons extends Widget {
     node.appendChild(this._buttons.upload);
     node.appendChild(this._buttons.refresh);
 
-    this._manager = manager;
-    this._opener = opener;
+    this._manager = options.manager;
+    this._opener = options.opener;
   }
 
   /**
@@ -243,6 +243,35 @@ class FileButtons extends Widget {
 }
 
 
+
+/**
+ * The namespace for the `FileButtons` class statics.
+ */
+export
+namespace FileButtons {
+  /**
+   * An options object for initializing a file buttons widget.
+   */
+  export
+  interface IOptions {
+    /**
+     * A file browser model instance.
+     */
+    model: FileBrowserModel;
+
+    /**
+     * A document manager instance.
+     */
+    manager: DocumentManager;
+
+    /**
+     * A widget opener function.
+     */
+    opener: IWidgetOpener;
+  }
+}
+
+
 /**
  * The namespace for the `FileButtons` private data.
  */

+ 21 - 2
src/filebrowser/crumbs.ts

@@ -52,9 +52,9 @@ class BreadCrumbs extends Widget {
    *
    * @param model - The file browser view model.
    */
-  constructor(model: FileBrowserModel) {
+  constructor(options: BreadCrumbs.IOptions) {
     super();
-    this._model = model;
+    this._model = options.model;
     this.addClass(BREADCRUMB_CLASS);
     this._crumbs = Private.createCrumbs();
     this._crumbSeps = Private.createCrumbSeparators();
@@ -263,6 +263,25 @@ class BreadCrumbs extends Widget {
 }
 
 
+
+/**
+ * The namespace for the `BreadCrumbs` class statics.
+ */
+export
+namespace BreadCrumbs {
+  /**
+   * An options object for initializing a bread crumb widget.
+   */
+  export
+  interface IOptions {
+    /**
+     * A file browser model instance.
+     */
+    model: FileBrowserModel;
+  }
+}
+
+
 /**
  * The namespace for the crumbs private data.
  */

+ 1 - 1
src/filebrowser/dialogs.ts

@@ -303,7 +303,7 @@ class CreateNewHandler extends Widget {
    */
   protected inputChanged(): void {
     let path = this.input.value;
-    for (let item of this._model.sortedItems) {
+    for (let item of this._model.items) {
       if (item.path === path) {
         this.addClass(FILE_CONFLICT_CLASS);
         return;

+ 433 - 204
src/filebrowser/listing.ts

@@ -178,133 +178,34 @@ class DirListing extends Widget {
    */
   static createNode(): HTMLElement {
     let node = document.createElement('div');
+    let header = document.createElement('div');
     let content = document.createElement('ul');
-    let header = this.createHeaderNode();
     content.className = CONTENT_CLASS;
+    header.className = HEADER_CLASS;
     node.appendChild(header);
     node.appendChild(content);
     node.tabIndex = 1;
     return node;
   }
 
-  /**
-   * Create the header node for a dir listing.
-   *
-   * @returns A new DOM node to use as the dir listing header.
-   *
-   * #### Notes
-   * This method may be reimplemented to create custom headers.
-   */
-  static createHeaderNode(): HTMLElement {
-    let node = document.createElement('div');
-    let name = createItemNode('Name');
-    let modified = createItemNode('Last Modified');
-    node.className = HEADER_CLASS;
-    name.classList.add(NAME_ID_CLASS);
-    name.classList.add(SELECTED_CLASS);
-    modified.classList.add(MODIFIED_ID_CLASS);
-    node.appendChild(name);
-    node.appendChild(modified);
-    return node;
-
-    function createItemNode(label: string): HTMLElement {
-      let node = document.createElement('div');
-      let text = document.createElement('span');
-      let icon = document.createElement('span');
-      node.className = HEADER_ITEM_CLASS;
-      text.className = HEADER_ITEM_TEXT_CLASS;
-      icon.className = HEADER_ITEM_ICON_CLASS;
-      text.textContent = label;
-      node.appendChild(text);
-      node.appendChild(icon);
-      return node;
-    }
-  }
-
-  /**
-   * Create a new item node for a dir listing.
-   *
-   * @returns A new DOM node to use as a content item.
-   *
-   * #### Notes
-   * This method may be reimplemented to create custom items.
-   */
-  static createItemNode(): HTMLElement {
-    let node = document.createElement('li');
-    let icon = document.createElement('span');
-    let text = document.createElement('span');
-    let modified = document.createElement('span');
-    node.className = ITEM_CLASS;
-    icon.className = ITEM_ICON_CLASS;
-    text.className = ITEM_TEXT_CLASS;
-    modified.className = ITEM_MODIFIED_CLASS;
-    node.appendChild(icon);
-    node.appendChild(text);
-    node.appendChild(modified);
-    return node;
-  }
-
-  /**
-   * Update an item node to reflect the current state of a model.
-   *
-   * @param node - A node created by a call to [[createItemNode]].
-   *
-   * @param model - The model object to use for the item state.
-   *
-   * #### Notes
-   * This is called automatically when the item should be updated.
-   *
-   * If the [[createItemNode]] method is reimplemented, this method
-   * should also be reimplemented so that the item state is properly
-   * updated.
-   */
-  static updateItemNode(node: HTMLElement, model: IContentsModel) {
-    let icon = node.firstChild as HTMLElement;
-    let text = icon.nextSibling as HTMLElement;
-    let modified = text.nextSibling as HTMLElement;
-
-    let type: string;
-    switch (model.type) {
-    case 'directory':
-      type = FOLDER_TYPE_CLASS;
-      break;
-    case 'notebook':
-      type = NOTEBOOK_TYPE_CLASS;
-      break;
-    default:
-      type = FILE_TYPE_CLASS;
-      break;
-    }
-
-    let modText = '';
-    let modTitle = '';
-    if (model.last_modified) {
-      let time = moment(model.last_modified).fromNow();
-      modText = time === 'a few seconds ago' ? 'seconds ago' : time;
-      modTitle = moment(model.last_modified).format('YYYY-MM-DD HH:mm');
-    }
-
-    node.className = `${ITEM_CLASS} ${type}`;
-    text.textContent = model.name;
-    modified.textContent = modText;
-    modified.title = modTitle;
-  }
-
   /**
    * Construct a new file browser directory listing widget.
    *
    * @param model - The file browser view model.
    */
-  constructor(model: FileBrowserModel, manager: DocumentManager, opener: IWidgetOpener) {
+  constructor(options: DirListing.IOptions) {
     super();
     this.addClass(DIR_LISTING_CLASS);
-    this._model = model;
+    this._model = options.model;
     this._model.refreshed.connect(this._onModelRefreshed, this);
     this._model.pathChanged.connect(this._onPathChanged, this);
     this._editNode = document.createElement('input');
     this._editNode.className = EDITOR_CLASS;
-    this._manager = manager;
-    this._opener = opener;
+    this._manager = options.manager;
+    this._opener = options.opener;
+    this._renderer = options.renderer || DirListing.defaultRenderer;
+    let headerNode = utils.findElement(this.node, HEADER_CLASS);
+    this._renderer.populateHeaderNode(headerNode);
   }
 
   /**
@@ -359,6 +260,41 @@ class DirListing extends Widget {
     return utils.findElement(this.node, CONTENT_CLASS);
   }
 
+  /**
+   * The renderer instance used by the directory listing.
+   *
+   * #### Notes
+   * This is a read-only property.
+   */
+  get renderer(): DirListing.IRenderer {
+    return this._renderer;
+  }
+
+  /**
+   * The the sorted content items.
+   */
+  get sortedItems(): IContentsModel[] {
+    return this._sortedModels;
+  }
+
+  /**
+   * The current sort state.
+   *
+   * #### Notes
+   * This is a read-only property.
+   */
+  get sortState(): DirListing.ISortState {
+    return this._sortState;
+  }
+
+  /**
+   * Sort the items using a sort condition.
+   */
+  sort(state: DirListing.ISortState): void {
+    this._sortedModels = Private.sort(this.model.items, state);
+    this.update();
+  }
+
   /**
    * Rename the first currently selected item.
    */
@@ -420,7 +356,7 @@ class DirListing extends Widget {
     if (this._softSelection) {
       names.push(this._softSelection);
     } else {
-      let items = this._model.sortedItems;
+      let items = this._model.items;
       for (let item of items) {
         if (this._selection[item.name]) {
           names.push(item.name);
@@ -480,7 +416,7 @@ class DirListing extends Widget {
    */
   shutdownKernels(): Promise<void> {
     let promises: Promise<void>[] = [];
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     let paths = items.map(item => item.path);
     for (let sessionId of this._model.sessionIds) {
       let index = paths.indexOf(sessionId.notebook.path);
@@ -504,22 +440,26 @@ class DirListing extends Widget {
   selectNext(keepExisting = false): void {
     let index = -1;
     let selected = Object.keys(this._selection);
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     if (selected.length === 1 || keepExisting) {
       // Select the next item.
       let name = selected[selected.length - 1];
-      index = arrays.findIndex(items, (value, index) => value.name === name);
+      index = arrays.findIndex(items, (value) => value.name === name);
       index += 1;
-      if (index === this._items.length) index = 0;
+      if (index === this._items.length) {
+        index = 0;
+      }
     } else if (selected.length === 0) {
       // Select the first item.
       index = 0;
     } else {
       // Select the last selected item.
       let name = selected[selected.length - 1];
-      index = arrays.findIndex(items, (value, index) => value.name === name);
+      index = arrays.findIndex(items, (value) => value.name === name);
+    }
+    if (index !== -1) {
+      this._selectItem(index, keepExisting);
     }
-    if (index !== -1) this._selectItem(index, keepExisting);
   }
 
   /**
@@ -530,22 +470,26 @@ class DirListing extends Widget {
   selectPrevious(keepExisting = false): void {
     let index = -1;
     let selected = Object.keys(this._selection);
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     if (selected.length === 1 || keepExisting) {
       // Select the previous item.
       let name = selected[0];
-      index = arrays.findIndex(items, (value, index) => value.name === name);
+      index = arrays.findIndex(items, (value) => value.name === name);
       index -= 1;
-      if (index === -1) index = this._items.length - 1;
+      if (index === -1) {
+        index = this._items.length - 1;
+      }
     } else if (selected.length === 0) {
       // Select the last item.
       index = this._items.length - 1;
     } else {
       // Select the first selected item.
       let name = selected[0];
-      index = arrays.findIndex(items, (value, index) => value.name === name);
+      index = arrays.findIndex(items, (value) => value.name === name);
+    }
+    if (index !== -1) {
+      this._selectItem(index, keepExisting);
     }
-    if (index !== -1) this._selectItem(index, keepExisting);
   }
 
   /**
@@ -646,10 +590,10 @@ class DirListing extends Widget {
    */
   protected onUpdateRequest(msg: Message): void {
     // Fetch common variables.
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     let nodes = this._items;
     let content = utils.findElement(this.node, CONTENT_CLASS);
-    let subtype = this.constructor as typeof DirListing;
+    let renderer = this._renderer;
 
     this.removeClass(MULTI_SELECTED_CLASS);
     this.removeClass(SELECTED_CLASS);
@@ -662,14 +606,15 @@ class DirListing extends Widget {
 
     // Add any missing item nodes.
     while (nodes.length < items.length) {
-      let node = subtype.createItemNode();
+      let node = renderer.createItemNode();
+      node.classList.add(ITEM_CLASS);
       nodes.push(node);
       content.appendChild(node);
     }
 
     // Update the node states to match the model contents.
     for (let i = 0, n = items.length; i < n; ++i) {
-      subtype.updateItemNode(nodes[i], items[i]);
+      renderer.updateItemNode(nodes[i], items[i]);
       if (this._selection[items[i].name]) {
         nodes[i].classList.add(SELECTED_CLASS);
         if (this._isCut && this._model.path === this._prevPath) {
@@ -709,41 +654,10 @@ class DirListing extends Widget {
 
     let header = this.headerNode;
     if (header.contains(target)) {
-
-      let children = header.getElementsByClassName(HEADER_ITEM_CLASS);
-      let name = children[0] as HTMLElement;
-      let modified = children[1] as HTMLElement;
-
-      if (name.contains(target)) {
-        if (this._model.sortKey === 'name') {
-          let flag = !this._model.sortAscending;
-          this._model.sortAscending = flag;
-          if (flag) name.classList.remove(DESCENDING_CLASS);
-          else name.classList.add(DESCENDING_CLASS);
-        } else {
-          this._model.sortKey = 'name';
-          this._model.sortAscending = true;
-          name.classList.remove(DESCENDING_CLASS);
-        }
-        name.classList.add(SELECTED_CLASS);
-        modified.classList.remove(SELECTED_CLASS);
-        modified.classList.remove(DESCENDING_CLASS);
-      } else if (modified.contains(target)) {
-        if (this._model.sortKey === 'last_modified') {
-          let flag = !this._model.sortAscending;
-          this._model.sortAscending = flag;
-          if (flag) modified.classList.remove(DESCENDING_CLASS);
-          else modified.classList.add(DESCENDING_CLASS);
-        } else {
-          this._model.sortKey = 'last_modified';
-          this._model.sortAscending = true;
-          modified.classList.remove(DESCENDING_CLASS);
-        }
-        modified.classList.add(SELECTED_CLASS);
-        name.classList.remove(SELECTED_CLASS);
-        name.classList.remove(DESCENDING_CLASS);
+      let state = this.renderer.handleHeaderClick(header, event);
+      if (state) {
+        this.sort(state);
       }
-      this.update();
       return;
     }
 
@@ -791,7 +705,7 @@ class DirListing extends Widget {
       return;
     }
     this._softSelection = '';
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     let selected = Object.keys(this._selection);
     if (selected.indexOf(items[index].name) === -1) {
       this._softSelection = items[index].name;
@@ -897,7 +811,7 @@ class DirListing extends Widget {
     }
 
     let model = this._model;
-    let item = model.sortedItems[i];
+    let item = this.sortedItems[i];
     if (item.type === 'directory') {
       model.cd(item.name).catch(error =>
         showErrorMessage(this, 'Open directory', error)
@@ -925,7 +839,7 @@ class DirListing extends Widget {
       if (index === -1) {
         return;
       }
-      let item = this._model.sortedItems[index];
+      let item = this.sortedItems[index];
       let target = this._items[index];
       if (!target.classList.contains(FOLDER_TYPE_CLASS)) {
         return;
@@ -946,7 +860,9 @@ class DirListing extends Widget {
     event.preventDefault();
     event.stopPropagation();
     let dropTarget = utils.findElement(this.node, utils.DROP_TARGET_CLASS);
-    if (dropTarget) dropTarget.classList.remove(utils.DROP_TARGET_CLASS);
+    if (dropTarget) {
+      dropTarget.classList.remove(utils.DROP_TARGET_CLASS);
+    }
   }
 
   /**
@@ -957,7 +873,9 @@ class DirListing extends Widget {
     event.stopPropagation();
     event.dropAction = event.proposedAction;
     let dropTarget = utils.findElement(this.node, utils.DROP_TARGET_CLASS);
-    if (dropTarget) dropTarget.classList.remove(utils.DROP_TARGET_CLASS);
+    if (dropTarget) {
+      dropTarget.classList.remove(utils.DROP_TARGET_CLASS);
+    }
     let index = utils.hitTestNodes(this._items, event.clientX, event.clientY);
     this._items[index].classList.add(utils.DROP_TARGET_CLASS);
   }
@@ -989,7 +907,7 @@ class DirListing extends Widget {
 
     // Get the path based on the target node.
     let index = this._items.indexOf(target);
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     let path = items[index].name + '/';
 
     // Move all of the items.
@@ -1031,7 +949,7 @@ class DirListing extends Widget {
     let selectedNames = Object.keys(this._selection);
     let source = this._items[index];
     let model = this._model;
-    let items = model.sortedItems;
+    let items = this.sortedItems;
     let item: IContentsModel = null;
 
     // If the source node is not selected, use just that node.
@@ -1040,20 +958,15 @@ class DirListing extends Widget {
       selectedNames = [item.name];
     } else if (selectedNames.length === 1) {
       let name = selectedNames[0];
-      item = arrays.find(items, (value, index) => value.name === name);
+      item = arrays.find(items, (value) => value.name === name);
     }
 
     // Create the drag image.
-    let dragImage = source.cloneNode(true) as HTMLElement;
-    dragImage.removeChild(dragImage.lastChild);
-    if (selectedNames.length > 1) {
-      let text = utils.findElement(dragImage, ITEM_TEXT_CLASS);
-      text.textContent = '(' + selectedNames.length + ')';
-    }
+    let dragImage = this.renderer.createDragImage(source, selectedNames.length);
 
     // Set up the drag event.
     this._drag = new Drag({
-      dragImage: dragImage,
+      dragImage,
       mimeData: new MimeData(),
       supportedActions: DropActions.Move,
       proposedAction: DropAction.Move
@@ -1086,12 +999,12 @@ class DirListing extends Widget {
    */
   private _handleFileSelect(event: MouseEvent): void {
     // Fetch common variables.
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     let index = utils.hitTestNodes(this._items, event.clientX, event.clientY);
 
     clearTimeout(this._selectTimer);
 
-    if (index == -1) {
+    if (index === -1) {
       return;
     }
 
@@ -1132,7 +1045,7 @@ class DirListing extends Widget {
    */
   private _handleMultiSelect(selected: string[], index: number): void {
     // Find the "nearest selected".
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     let nearestIndex = -1;
     for (let i = 0; i < this._items.length; i++) {
       if (i === index) {
@@ -1168,7 +1081,7 @@ class DirListing extends Widget {
    * Get the currently selected items.
    */
   private _getSelectedItems(): IContentsModel[] {
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     if (!this._softSelection) {
       return items.filter(item => this._selection[item.name]);
     }
@@ -1180,7 +1093,7 @@ class DirListing extends Widget {
    */
   private _copy(): void {
     this._clipboard = [];
-    for (var item of this._getSelectedItems()) {
+    for (let item of this._getSelectedItems()) {
       if (item.type !== 'directory') {
         // Store the absolute path of the item.
         this._clipboard.push('/' + item.path);
@@ -1207,19 +1120,20 @@ class DirListing extends Widget {
    * Allow the user to rename item on a given row.
    */
   private _doRename(): Promise<string> {
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     let name = this._softSelection || Object.keys(this._selection)[0];
-    let index = arrays.findIndex(items, (value, index) => value.name === name);
+    let index = arrays.findIndex(items, (value) => value.name === name);
     let row = this._items[index];
-    let text = utils.findElement(row, ITEM_TEXT_CLASS);
-    let original = text.textContent;
+    let item = items[index];
+    let nameNode = this.renderer.getNameNode(row);
+    let original = item.name;
+    this._editNode.value = original;
 
-    return Private.doRename(row, text, this._editNode).then(changed => {
-      if (!changed) {
-        return original;
+    return Private.doRename(nameNode, this._editNode).then(newName => {
+      if (newName === original) {
+        return;
       }
-      let newPath = text.textContent;
-      return this._model.rename(original, newPath).catch(error => {
+      return this._model.rename(original, newName).catch(error => {
         if (error.xhr) {
           error.message = `${error.xhr.status}: error.statusText`;
         }
@@ -1228,18 +1142,16 @@ class DirListing extends Widget {
           let options = {
             title: 'Overwrite file?',
             host: this.parent.node,
-            body: `"${newPath}" already exists, overwrite?`,
+            body: `"${newName}" already exists, overwrite?`,
             okText: 'OVERWRITE'
           };
           return showDialog(options).then(button => {
             if (button.text === 'OVERWRITE') {
-              return this._model.delete(newPath).then(() => {
-                return this._model.rename(original, newPath).then(() => {
+              return this._model.delete(newName).then(() => {
+                return this._model.rename(original, newName).then(() => {
                   this._model.refresh();
                 });
               });
-            } else {
-              text.textContent = original;
             }
           });
         }
@@ -1248,7 +1160,7 @@ class DirListing extends Widget {
         return original;
       }).then(() => {
         this._model.refresh();
-        return text.textContent;
+        return newName;
       });
     });
   }
@@ -1258,7 +1170,7 @@ class DirListing extends Widget {
    */
   private _selectItem(index: number, keepExisting: boolean) {
     // Selected the given row(s)
-    let items = this._model.sortedItems;
+    let items = this.sortedItems;
     if (!keepExisting) {
       this._selection = Object.create(null);
     }
@@ -1272,14 +1184,17 @@ class DirListing extends Widget {
    * Handle the `refreshed` signal from the model.
    */
   private _onModelRefreshed(): void {
+    // Update the selection.
     let existing = Object.keys(this._selection);
     this._selection = Object.create(null);
-    for (let name of this._model.names) {
+    for (let item of this._model.items) {
+      let name = item.name;
       if (existing.indexOf(name) !== -1) {
         this._selection[name] = true;
       }
     }
-    this.update();
+    // Update the sorted items.
+    this.sort(this.sortState);
   }
 
   /**
@@ -1288,11 +1203,15 @@ class DirListing extends Widget {
   private _onPathChanged(): void {
     // Reset the selection.
     this._selection = Object.create(null);
+    // Update the sorted items.
+    this.sort(this.sortState);
   }
 
   private _model: FileBrowserModel = null;
   private _editNode: HTMLInputElement = null;
   private _items: HTMLElement[] = [];
+  private _sortedModels: IContentsModel[] = null;
+  private _sortState: DirListing.ISortState = { direction: 'ascending', key: 'name' };
   private _drag: Drag = null;
   private _dragData: { pressX: number, pressY: number, index: number } = null;
   private _selectTimer = -1;
@@ -1304,6 +1223,299 @@ class DirListing extends Widget {
   private _manager: DocumentManager = null;
   private _opener: IWidgetOpener = null;
   private _selection: { [key: string]: boolean; } = Object.create(null);
+  private _renderer: DirListing.IRenderer = null;
+}
+
+
+/**
+ * The namespace for the `DirListing` class statics.
+ */
+export
+namespace DirListing {
+  /**
+   * An options object for initializing a file browser directory listing.
+   */
+  export
+  interface IOptions {
+    /**
+     * A file browser model instance.
+     */
+    model: FileBrowserModel;
+
+    /**
+     * A document manager instance.
+     */
+    manager: DocumentManager;
+
+    /**
+     * A widget opener function.
+     */
+    opener: IWidgetOpener;
+
+    /**
+     * A renderer for file items.
+     *
+     * The default is a shared `Renderer` instance.
+     */
+    renderer?: IRenderer;
+  }
+
+  /**
+   * A sort state.
+   */
+  export
+  interface ISortState {
+    /**
+     * The direction of sort.
+     */
+    direction: 'ascending' | 'descending';
+
+    /**
+     * The sort key.
+     */
+    key: 'name' | 'last_modified';
+  }
+
+  /**
+   * The render interface for file browser listing options.
+   */
+  export
+  interface IRenderer {
+    /**
+     * Populate and empty header node for a dir listing.
+     *
+     * @param node - The header node to populate.
+     */
+    populateHeaderNode(node: HTMLElement): void;
+
+    /**
+     * Handle a header click.
+     *
+     * @param node - A node populated by [[populateHeaderNode]].
+     *
+     * @param event - A click event on the node.
+     *
+     * @returns The sort state of the header after the click event.
+     */
+    handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState;
+
+    /**
+     * Create a new item node for a dir listing.
+     *
+     * @returns A new DOM node to use as a content item.
+     */
+    createItemNode(): HTMLElement;
+
+    /**
+     * Update an item node to reflect the current state of a model.
+     *
+     * @param node - A node created by [[createItemNode]].
+     *
+     * @param model - The model object to use for the item state.
+     */
+    updateItemNode(node: HTMLElement, model: IContentsModel): void;
+
+    /**
+     * Get the node containing the file name.
+     *
+     * @param node - A node created by [[createItemNode]].
+     *
+     * @returns The node containing the file name.
+     */
+    getNameNode(node: HTMLElement): HTMLElement;
+
+    /**
+     * Create an appropriate drag image for an item.
+     *
+     * @param node - A node created by [[createItemNode]].
+     *
+     * @param count - The number of items being dragged.
+     *
+     * @returns An element to use as the drag image.
+     */
+    createDragImage(node: HTMLElement, count: number): HTMLElement;
+  }
+
+  /**
+   * The default implementation of an `IRenderer`.
+   */
+  export
+  class Renderer implements IRenderer {
+    /**
+     * Populate and empty header node for a dir listing.
+     *
+     * @param node - The header node to populate.
+     */
+    populateHeaderNode(node: HTMLElement): void {
+      let name = this._createHeaderItemNode('Name');
+      let modified = this._createHeaderItemNode('Last Modified');
+      name.classList.add(NAME_ID_CLASS);
+      name.classList.add(SELECTED_CLASS);
+      modified.classList.add(MODIFIED_ID_CLASS);
+      node.appendChild(name);
+      node.appendChild(modified);
+    }
+
+    /**
+     * Handle a header click.
+     *
+     * @param node - A node populated by [[populateHeaderNode]].
+     *
+     * @param event - A click event on the node.
+     *
+     * @returns The sort state of the header after the click event.
+     */
+    handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState {
+      let name = utils.findElement(node, NAME_ID_CLASS);
+      let modified = utils.findElement(node, MODIFIED_ID_CLASS);
+      let state: ISortState = { direction: 'ascending', key: 'name' };
+      let target = event.target as HTMLElement;
+      if (name.contains(target)) {
+        if (name.classList.contains(SELECTED_CLASS)) {
+          if (!name.classList.contains(DESCENDING_CLASS)) {
+            state.direction = 'descending';
+            name.classList.add(DESCENDING_CLASS);
+          } else {
+            name.classList.remove(DESCENDING_CLASS);
+          }
+        } else {
+          name.classList.remove(DESCENDING_CLASS);
+        }
+        name.classList.add(SELECTED_CLASS);
+        modified.classList.remove(SELECTED_CLASS);
+        modified.classList.remove(DESCENDING_CLASS);
+        return state;
+      }
+      if (modified.contains(target)) {
+        state.key = 'last_modified';
+        if (modified.classList.contains(SELECTED_CLASS)) {
+          if (!modified.classList.contains(DESCENDING_CLASS)) {
+            state.direction = 'descending';
+            modified.classList.add(DESCENDING_CLASS);
+          } else {
+            modified.classList.remove(DESCENDING_CLASS);
+          }
+        } else {
+          modified.classList.remove(DESCENDING_CLASS);
+        }
+        modified.classList.add(SELECTED_CLASS);
+        name.classList.remove(SELECTED_CLASS);
+        name.classList.remove(DESCENDING_CLASS);
+        return state;
+      }
+      return void 0;
+    }
+
+    /**
+     * Create a new item node for a dir listing.
+     *
+     * @returns A new DOM node to use as a content item.
+     */
+    createItemNode(): HTMLElement {
+      let node = document.createElement('li');
+      let icon = document.createElement('span');
+      let text = document.createElement('span');
+      let modified = document.createElement('span');
+      icon.className = ITEM_ICON_CLASS;
+      text.className = ITEM_TEXT_CLASS;
+      modified.className = ITEM_MODIFIED_CLASS;
+      node.appendChild(icon);
+      node.appendChild(text);
+      node.appendChild(modified);
+      return node;
+    }
+
+    /**
+     * Update an item node to reflect the current state of a model.
+     *
+     * @param node - A node created by [[createItemNode]].
+     *
+     * @param model - The model object to use for the item state.
+     */
+    updateItemNode(node: HTMLElement, model: IContentsModel): void {
+      let icon = utils.findElement(node, ITEM_ICON_CLASS);
+      let text = utils.findElement(node, ITEM_TEXT_CLASS);
+      let modified = utils.findElement(node, ITEM_MODIFIED_CLASS);
+
+      icon.className = ITEM_ICON_CLASS;
+      switch (model.type) {
+      case 'directory':
+        icon.classList.add(FOLDER_TYPE_CLASS);
+        break;
+      case 'notebook':
+        icon.classList.add(NOTEBOOK_TYPE_CLASS);
+        break;
+      default:
+        icon.classList.add(FILE_TYPE_CLASS);
+        break;
+      }
+
+      let modText = '';
+      let modTitle = '';
+      if (model.last_modified) {
+        let time = moment(model.last_modified).fromNow();
+        modText = time === 'a few seconds ago' ? 'seconds ago' : time;
+        modTitle = moment(model.last_modified).format('YYYY-MM-DD HH:mm');
+      }
+
+      text.textContent = model.name;
+      modified.textContent = modText;
+      modified.title = modTitle;
+    }
+
+    /**
+     * Get the node containing the file name.
+     *
+     * @param node - A node created by [[createItemNode]].
+     *
+     * @returns The node containing the file name.
+     */
+    getNameNode(node: HTMLElement): HTMLElement {
+      return utils.findElement(node, ITEM_TEXT_CLASS);
+    }
+
+    /**
+     * Create a drag image for an item.
+     *
+     * @param node - A node created by [[createItemNode]].
+     *
+     * @param count - The number of items being dragged.
+     *
+     * @returns An element to use as the drag image.
+     */
+    createDragImage(node: HTMLElement, count: number): HTMLElement {
+      let dragImage = node.cloneNode(true) as HTMLElement;
+      let modified = utils.findElement(node, ITEM_MODIFIED_CLASS);
+      dragImage.removeChild(modified as HTMLElement);
+      if (count > 1) {
+        let nameNode = utils.findElement(node, ITEM_TEXT_CLASS);
+        nameNode.textContent = '(' + count + ')';
+      }
+      return dragImage;
+    }
+
+    /**
+     * Create a node for a header item.
+     */
+    private _createHeaderItemNode(label: string): HTMLElement {
+      let node = document.createElement('div');
+      let text = document.createElement('span');
+      let icon = document.createElement('span');
+      node.className = HEADER_ITEM_CLASS;
+      text.className = HEADER_ITEM_TEXT_CLASS;
+      icon.className = HEADER_ITEM_ICON_CLASS;
+      text.textContent = label;
+      node.appendChild(text);
+      node.appendChild(icon);
+      return node;
+    }
+  }
+
+  /**
+   * The default `IRenderer` instance.
+   */
+  export
+  const defaultRenderer = new Renderer();
 }
 
 
@@ -1317,10 +1529,10 @@ namespace Private {
    * @returns Boolean indicating whether the name changed.
    */
   export
-  function doRename(parent: HTMLElement, text: HTMLElement, edit: HTMLInputElement): Promise<boolean> {
+  function doRename(text: HTMLElement, edit: HTMLInputElement): Promise<string> {
     let changed = true;
+    let parent = text.parentElement as HTMLElement;
     parent.replaceChild(edit, text);
-    edit.value = text.textContent;
     edit.focus();
     let index = edit.value.lastIndexOf('.');
     if (index === -1) {
@@ -1329,14 +1541,10 @@ namespace Private {
       edit.setSelectionRange(0, index);
     }
 
-    return new Promise<boolean>((resolve, reject) => {
+    return new Promise<string>((resolve, reject) => {
       edit.onblur = () => {
         parent.replaceChild(text, edit);
-        if (text.textContent === edit.value) {
-          changed = false;
-        }
-        if (changed) text.textContent = edit.value;
-        resolve(changed);
+        resolve(edit.value);
       };
       edit.onkeydown = (event: KeyboardEvent) => {
         switch (event.keyCode) {
@@ -1356,6 +1564,27 @@ namespace Private {
     });
   }
 
+  /**
+   * Sort a list of items by sort state as a new array.
+   */
+  export
+  function sort(items: IContentsModel[], state: DirListing.ISortState) : IContentsModel[] {
+    let output = items.slice();
+    if (state.key === 'last_modified') {
+      output.sort((a, b) => {
+        let valA = new Date(a.last_modified).getTime();
+        let valB = new Date(b.last_modified).getTime();
+        return valB - valA;
+      });
+    }
+
+    // Reverse the order if descending.
+    if (state.direction === 'descending') {
+      output.reverse();
+    }
+    return output;
+  }
+
   /**
    * Scroll an element into view if needed.
    *

+ 35 - 88
src/filebrowser/model.ts

@@ -30,10 +30,10 @@ class FileBrowserModel implements IDisposable {
   /**
    * Construct a new file browser view model.
    */
-  constructor(contentsManager: IContentsManager, sessionManager: ISession.IManager, specs: IKernel.ISpecModels) {
-    this._contentsManager = contentsManager;
-    this._sessionManager = sessionManager;
-    this._specs = specs;
+  constructor(options: FileBrowserModel.IOptions) {
+    this._contentsManager = options.contentsManager;
+    this._sessionManager = options.sessionManager;
+    this._specs = options.kernelspecs;
     this._model = { path: '', name: '/', type: 'directory', content: [] };
     this.cd();
   }
@@ -70,14 +70,10 @@ class FileBrowserModel implements IDisposable {
   }
 
   /**
-   * Get a read-only, unsorted list of file names in the current path.
-   *
-   * #### Notes
-   * This is a read-only property.
-   *
+   * Get a read-only list of the items in the current path.
    */
-  get names(): string[] {
-    return this._unsortedNames.slice();
+  get items(): IContentsModel[] {
+    return this._model.content ? this._model.content.slice() : [];
   }
 
   /**
@@ -104,46 +100,6 @@ class FileBrowserModel implements IDisposable {
     return this._specs;
   }
 
-  /**
-   * Get whether the items are sorted in ascending order.
-   */
-  get sortAscending(): boolean {
-    return this._ascending;
-  }
-
-  /**
-   * Set whether the items are sorted in ascending order.
-   */
-  set sortAscending(value: boolean) {
-    this._ascending = value;
-    this._sort();
-  }
-
-  /**
-   * Get which key the items are sorted on.
-   */
-  get sortKey(): string {
-    return this._sortKey;
-  }
-
-  /**
-   * Set which key the items are sorted on.
-   */
-  set sortKey(value: string) {
-    this._sortKey = value;
-    this._sort();
-  }
-
-  /**
-   * Get the sorted list of items.
-   *
-   * #### Notes
-   * This is a read-only property and should be treated as immutable.
-   */
-  get sortedItems(): IContentsModel[] {
-    return this._model.content;
-  }
-
   /**
    * Dispose of the resources held by the view model.
    */
@@ -167,11 +123,6 @@ class FileBrowserModel implements IDisposable {
     let oldValue = this.path;
     return this._contentsManager.get(newValue, {}).then(contents => {
       this._model = contents;
-      let content = contents.content as IContentsModel[];
-      this._unsortedNames = content.map((value, index) => value.name);
-      if (this._sortKey !== 'name' || !this._ascending) {
-        this._sort();
-      }
       return this._findSessions();
     }).then(() => {
       if (oldValue !== newValue) {
@@ -363,35 +314,6 @@ class FileBrowserModel implements IDisposable {
     });
   }
 
-  /**
-   * Sort the model items.
-   */
-  private _sort(): void {
-    if (!this._unsortedNames) {
-      return;
-    }
-    let items = this._model.content.slice() as IContentsModel[];
-    if (this._sortKey === 'name') {
-      items.sort((a, b) => {
-        let indexA = this._unsortedNames.indexOf(a.name);
-        let indexB = this._unsortedNames.indexOf(b.name);
-        return indexA - indexB;
-      });
-    } else if (this._sortKey === 'last_modified') {
-      items.sort((a, b) => {
-        let valA = new Date(a.last_modified).getTime();
-        let valB = new Date(b.last_modified).getTime();
-        return valB - valA;
-      });
-    }
-
-    // Reverse the order if descending.
-    if (!this._ascending) {
-      items.reverse();
-    }
-    this._model.content = items;
-  }
-
   /**
    * Perform the actual upload.
    */
@@ -464,13 +386,38 @@ class FileBrowserModel implements IDisposable {
   private _sessions: ISession.IModel[] = [];
   private _sessionManager: ISession.IManager = null;
   private _model: IContentsModel;
-  private _sortKey = 'name';
-  private _ascending = true;
-  private _unsortedNames: string[] = [];
   private _specs: IKernel.ISpecModels = null;
 }
 
 
+/**
+ * The namespace for the `FileBrowserModel` class statics.
+ */
+export
+namespace FileBrowserModel {
+  /**
+   * An options object for initializing a file browser.
+   */
+  export
+  interface IOptions {
+    /**
+     * A contents manager instance.
+     */
+    contentsManager: IContentsManager;
+
+    /**
+     * A session manager instance.
+     */
+    sessionManager: ISession.IManager;
+
+    /**
+     * The kernelspec models.
+     */
+    kernelspecs: IKernel.ISpecModels;
+  }
+}
+
+
 /**
  * The namespace for the file browser model private data.
  */

+ 10 - 2
src/filebrowser/plugin.ts

@@ -102,8 +102,16 @@ function activateFileBrowser(app: Application, provider: JupyterServices, regist
     kernelspecs: provider.kernelspecs,
     opener
   });
-  let model = new FileBrowserModel(contents, sessions, provider.kernelspecs);
-  let widget = new FileBrowserWidget(model, docManager, opener);
+  let model = new FileBrowserModel({
+    contentsManager: contents,
+    sessionManager: sessions,
+    kernelspecs: provider.kernelspecs
+  });
+  let widget = new FileBrowserWidget({
+    model,
+    manager: docManager,
+    opener
+  });
   let menu = createMenu(widget);
 
   // Add a context menu to the dir listing.

+ 3 - 3
src/filebrowser/theme.css

@@ -201,17 +201,17 @@
 **/
 
 
-.jp-DirListing-item.jp-type-folder .jp-DirListing-itemIcon:before {
+.jp-type-folder.jp-DirListing-itemIcon:before {
   content: "\f114";
 }
 
 
-.jp-DirListing-item.jp-type-file .jp-DirListing-itemIcon:before {
+.jp-type-file.jp-DirListing-itemIcon:before {
   content: "\f016";
 }
 
 
-.jp-DirListing-item.jp-type-notebook .jp-DirListing-itemIcon:before {
+.jp-type-notebook.jp-DirListing-itemIcon:before {
   content: "\f02d";
 }