Bug 1354532 - Part 3 - Add a new Library panel subview that lists all downloads that are not in progress. r?paolo draft
authorMike de Boer <mdeboer@mozilla.com>
Tue, 11 Jul 2017 12:23:35 +0200
changeset 606721 ec27c5015577faf4b82a170cfd0df1885f2f14b5
parent 606720 b236c72b51e738c02073cbe6c79cd99564077eeb
child 606722 0f336a4287370ebf86eb258b98186a3fe755081a
push id67791
push usermdeboer@mozilla.com
push dateTue, 11 Jul 2017 10:33:03 +0000
reviewerspaolo
bugs1354532
milestone56.0a1
Bug 1354532 - Part 3 - Add a new Library panel subview that lists all downloads that are not in progress. r?paolo A new Downloads panelview, which extends 'DownloadsViewUI.BaseDownloadsPlacesView', is implemented using the 'PlacesPanelview' as a base, just like the Bookmarks subview in the Library panel. It uses the same controller as the other views and the same shell constructs too. For the special style subview buttons a new binding called 'toolbarbutton-download' was created. MozReview-Commit-ID: 5W6j8ALigcT
browser/base/content/browser.css
browser/components/customizableui/content/panelUI.css
browser/components/customizableui/content/panelUI.inc.xul
browser/components/downloads/DownloadsPanelSubView.jsm
browser/components/downloads/DownloadsViewUI.jsm
browser/components/downloads/content/downloads.js
browser/components/downloads/content/downloadsOverlay.xul
browser/components/downloads/moz.build
browser/components/places/content/browserPlacesViews.js
browser/locales/en-US/chrome/browser/downloads/downloads.properties
browser/themes/shared/customizableui/panelUI.inc.css
browser/themes/shared/icons/delete.svg
browser/themes/shared/icons/folder.svg
browser/themes/shared/jar.inc.mn
browser/themes/shared/menupanel.inc.css
toolkit/content/widgets/toolbarbutton.xml
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -481,17 +481,18 @@ toolbar:not(#TabsToolbar) > #personal-bo
 #reload-button[disabled]:not(:-moz-window-inactive) > .toolbarbutton-icon {
   opacity: 1 !important;
 }
 
 #PanelUI-feeds > .feed-toolbarbutton:-moz-locale-dir(rtl) {
   direction: rtl;
 }
 
-#panelMenu_bookmarksMenu > .bookmark-item {
+#panelMenu_bookmarksMenu > .bookmark-item,
+#panelMenu_downloadsMenu > .bookmark-item {
   max-width: none;
 }
 
 #main-window:-moz-lwtheme {
   background-repeat: no-repeat;
   background-position: top right;
 }
 
--- a/browser/components/customizableui/content/panelUI.css
+++ b/browser/components/customizableui/content/panelUI.css
@@ -55,9 +55,21 @@ photonpanelmultiview[transitioning] {
 }
 
 .panel-viewcontainer.offscreen,
 .panel-viewcontainer.offscreen > .panel-viewstack {
   margin: 0;
   padding: 0;
 }
 
+.subviewbutton.download {
+  -moz-binding: url("chrome://global/content/bindings/toolbarbutton.xml#toolbarbutton-download");
+}
+
+.subviewbutton.download[openLabel]:hover > .toolbarbutton-text > .status-full,
+.subviewbutton.download:-moz-any(:not(:hover),[buttonover]) > .toolbarbutton-text > .status-open,
+.subviewbutton.download:-moz-any(:not(:hover),:hover:not([buttonover])) > .toolbarbutton-text > .status-show,
+.subviewbutton.download:not(:hover) > .show-button,
+.subviewbutton.download > .show-button > .toolbarbutton-text {
+  display: none;
+}
+
 /* END photon adjustments */
--- a/browser/components/customizableui/content/panelUI.inc.xul
+++ b/browser/components/customizableui/content/panelUI.inc.xul
@@ -742,12 +742,40 @@
                        label="&historyMenu.label;"
                        closemenu="none"
                        oncommand="PanelUI.showSubView('PanelUI-history', this)"/>
         <toolbarbutton id="appMenu-library-remotetabs-button"
                        class="subviewbutton subviewbutton-iconic subviewbutton-nav"
                        label="&appMenuRemoteTabs.label;"
                        closemenu="none"
                        oncommand="PanelUI.showSubView('PanelUI-remotetabs', this)"/>
+        <toolbarbutton id="appMenu-library-downloads-button"
+                       class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+                       label="&downloads.label;"
+                       closemenu="none"
+                       oncommand="DownloadsPanelSubView.show(this);"/>
+      </vbox>
+    </panelview>
+    <panelview id="PanelUI-downloads" class="PanelUI-subView">
+      <vbox class="panel-subview-body">
+        <commandset id="downloadCommands"/>
+        <toolbarbutton id="appMenu-library-downloads-show-button"
+                       class="subviewbutton subviewbutton-iconic"
+                       label="Show Downloads Folder"
+                       closemenu="none"
+                       oncommand="DownloadsPanelSubView.onShowDownloads(this);"/>
+        <toolbarbutton id="appMenu-library-downloads-clear-button"
+                       class="subviewbutton subviewbutton-iconic"
+                       label="Clear Downloads"
+                       closemenu="none"
+                       command="downloadsCmd_clearDownloads"/>
+        <toolbarseparator/>
+        <toolbaritem id="panelMenu_downloadsMenu"
+                     orient="vertical"
+                     smoothscroll="false"
+                     flatList="true"
+                     tooltip="bhTooltip">
+          <!-- downloads menu items will go here -->
+        </toolbaritem>
       </vbox>
     </panelview>
   </photonpanelmultiview>
 </panel>
new file mode 100644
--- /dev/null
+++ b/browser/components/downloads/DownloadsPanelSubView.jsm
@@ -0,0 +1,193 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "DownloadsPanelSubView",
+];
+
+const { interfaces: Ci, utils: Cu } = Components;
+
+// DownloadsViewUI is used right away, so no need to lazify the import.
+Cu.import("resource:///modules/DownloadsViewUI.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+                                  "resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Downloads",
+                                  "resource://gre/modules/Downloads.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "DownloadsCommon",
+                                  "resource:///modules/DownloadsCommon.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+
+let gPanelViewInstances = new WeakMap();
+
+class DownloadsPanelSubView extends DownloadsViewUI.BaseDownloadsPlacesView {
+  constructor(event) {
+    let document = event.target.ownerDocument
+    let window = document.defaultView;
+    super(window);
+
+    this.context = "panelDownloadsContextMenu";
+    this.controller = new DownloadsViewUI.DownloadsPlacesViewController(this);
+    this.showLabel = DownloadsCommon.strings[AppConstants.platform == "macosx" ?
+      "showMacLabel" : "showLabel"];
+    this.openLabel = DownloadsCommon.strings.openFileLabel;
+
+    let query = "place:transition=" + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD +
+      "&sort=" + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
+    let panelview = event.target;
+    this._panelMenuView = new window.PlacesPanelview(document.getElementById("panelMenu_downloadsMenu"),
+      panelview, query, this);
+    this.controller.register();
+    window.DownloadsView.goUpdateCommands();
+
+    event.detail.addBlocker(new Promise(resolve => {
+      window.DownloadsOverlayLoader.ensureOverlayLoaded(window.DownloadsPanel.kDownloadsOverlay, () => {
+        let contextMenu = document.getElementById(this.context);
+        if (!contextMenu) {
+          contextMenu = document.getElementById("downloadsContextMenu").cloneNode(true);
+          contextMenu.setAttribute("closemenu", "none");
+          contextMenu.setAttribute("id", this.context);
+          contextMenu.setAttribute("onpopupshowing",
+            "this._view = PlacesUIUtils.getViewForNode(document.popupNode);" +
+            "return this._view.buildContextMenu(this);");
+          contextMenu.setAttribute("onpopuphiding", "this._view.destroyContextMenu();");
+          let clearButton = contextMenu.querySelector("menuitem[command='downloadsCmd_clearDownloads'");
+          clearButton.hidden = false;
+          clearButton.previousSibling.hidden = true;
+        }
+        panelview.appendChild(contextMenu);
+        resolve();
+      });
+    }));
+  }
+
+  destructor(event) {
+    if (!this._panelMenuView)
+      return;
+
+    this.controller.terminate();
+    this._panelMenuView.uninit();
+    delete this._panelMenuView;
+    event.target.removeEventListener("ViewHiding", DownloadsPanelSubView.handleEvent);
+    super.destructor();
+    gPanelViewInstances.delete(this);
+  }
+
+  get result() {
+    return this._panelMenuView.result;
+  }
+
+  get selectedNode() {
+    let node = this._panelMenuView._contextMenuShown ?
+      this._panelMenuView._contextMenuShown.triggerNode : null;
+    if (node && node._placesNode)
+      return node;
+    return null;
+  }
+
+  get selectedNodes() {
+    let selectedNode = this.selectedNode;
+    return selectedNode ? [selectedNode] : [];
+  }
+
+  onInsertElement(element, placesNode) {
+    this._addDownloadData(null, placesNode, false, null, element);
+
+    element._shell.element = element;
+    element._shell.ensureActive();
+    element.classList.add("download", "download-state");
+    element.setAttribute("image", element._shell.image);
+    let fullStatus = element._shell.statusLabels.fullStatus;
+    element.setAttribute("fullStatus", fullStatus);
+    element.setAttribute("tooltiptext", fullStatus);
+    if (element._shell.isCommandEnabled("downloadsCmd_show")) {
+      element.setAttribute("openLabel", this.openLabel);
+      element.setAttribute("showLabel", this.showLabel);
+    }
+  }
+
+  onRemoveElement(element, placesNode) {
+    this._removeHistoryDownloadFromView(placesNode);
+  }
+
+  _removeElement(element) {
+    element.remove();
+    this.window.goUpdateCommand("downloadsCmd_clearDownloads");
+  }
+
+  onCommand(button) {
+    // This will only be invoked when the button is selected using keyboard
+    // navigation.
+    if (!button.classList.contains("show-button"))
+      return;
+
+    let command = "downloadsCmd_open";
+    if (button._shell.isCommandEnabled(command))
+      button._shell.doCommand(command);
+  }
+
+  onClick(target, button) {
+    let command = "downloadsCmd_open";
+    let retval = true;
+    if (target.classList.contains("show-button")) {
+      command = "downloadsCmd_show";
+      retval = false;
+    }
+
+    if (button._shell.isCommandEnabled(command))
+      button._shell.doCommand(command);
+    return retval;
+  }
+
+  onBuildContextMenu() {
+    this.window.DownloadsView.goUpdateCommands();
+  }
+
+  downloadsCmd_clearDownloads() {}
+
+  static show(anchor) {
+    let document = anchor.ownerDocument;
+    let window = document.defaultView;
+    let view = document.getElementById("PanelUI-downloads");
+    view.addEventListener("ViewShowing", DownloadsPanelSubView.handleEvent);
+    view.addEventListener("ViewHiding", DownloadsPanelSubView.handleEvent);
+    view.addEventListener("destructed", DownloadsPanelSubView.handleEvent);
+    anchor.setAttribute("closemenu", "none");
+    window.PanelUI.showSubView("PanelUI-downloads", anchor);
+  }
+
+  static async onShowDownloads() {
+    // Retrieve the user's default download directory.
+    let preferredDir = await Downloads.getPreferredDownloadsDirectory();
+    DownloadsCommon.showDirectory(new FileUtils.File(preferredDir));
+  }
+
+  static handleEvent(event) {
+    switch (event.type) {
+      case "ViewShowing":
+        DownloadsPanelSubView.onShowing(event);
+        break;
+      case "ViewHiding":
+        DownloadsPanelSubView.onHiding(event);
+        break;
+    }
+  }
+
+  static onShowing(event) {
+    let panelview = event.target;
+    panelview.removeEventListener("ViewShowing", DownloadsPanelSubView.handleEvent);
+    gPanelViewInstances.set(panelview, new DownloadsPanelSubView(event));
+  }
+
+  static onHiding(event) {
+    let instance = gPanelViewInstances.get(event.target);
+    if (!instance)
+      return;
+    instance.destructor(event);
+  }
+}
--- a/browser/components/downloads/DownloadsViewUI.jsm
+++ b/browser/components/downloads/DownloadsViewUI.jsm
@@ -183,19 +183,21 @@ this.DownloadsViewUI.BaseDownloadsPlaces
    * @param [optional] aNewest
    *        @see onDownloadAdded. Ignored for history downloads.
    * @param [optional] aDocumentFragment
    *        To speed up the appending of multiple elements to the end of the
    *        list which are coming in a single batch (i.e. invalidateContainer),
    *        a document fragment may be passed to which the new elements would
    *        be appended. It's the caller's job to ensure the fragment is merged
    *        to the richlistbox at the end.
+   * @param [optional] element
+   *        Allow an existing element to be (re-)used, instead of creating a new
+   *        richlistitem.
    */
-  _addDownloadData(sessionDownload, aPlacesNode, aNewest = false,
-                   aDocumentFragment = null) {
+  _addDownloadData(sessionDownload, aPlacesNode, aNewest = false, aDocumentFragment = null, element = null) {
     let downloadURI = aPlacesNode ? aPlacesNode.uri
                                   : sessionDownload.source.url;
     let shellsForURI = this._downloadElementsShellsForURI.get(downloadURI);
     if (!shellsForURI) {
       shellsForURI = new Set();
       this._downloadElementsShellsForURI.set(downloadURI, shellsForURI);
     }
 
@@ -255,17 +257,17 @@ this.DownloadsViewUI.BaseDownloadsPlaces
       let historyDownload = null;
       if (aPlacesNode) {
         let metaData = this._cachedPlacesMetaData.get(aPlacesNode.uri) ||
                        this._getPlacesMetaDataFor(aPlacesNode.uri);
         historyDownload = new DownloadsViewUI.HistoryDownload(aPlacesNode);
         historyDownload.updateFromMetaData(metaData);
       }
       let shell = new DownloadsViewUI.HistoryDownloadElementShell(sessionDownload,
-        historyDownload, this.window);
+        historyDownload, this.window, element);
       shell.element._placesNode = aPlacesNode;
       newOrUpdatedShell = shell;
       shellsForURI.add(shell);
       if (sessionDownload) {
         this._viewItemsForDownloads.set(sessionDownload, shell);
       }
     } else if (aPlacesNode) {
       // We are updating information for a history download for which we have
@@ -278,52 +280,68 @@ this.DownloadsViewUI.BaseDownloadsPlaces
       //    download with no associated session download. We have exactly one
       //    download element shell in this case, but the metdata cannot have
       //    changed, just the reference to the Places node object is different.
       // So, we update all the node references and keep the metadata intact.
       for (let shell of shellsForURI) {
         if (!shell.historyDownload) {
           // Create the element to host the metadata when needed.
           shell.historyDownload = new DownloadsViewUI.HistoryDownload(aPlacesNode);
+          // If this shell was created with a placeholder element, which is how
+          // it's used by the 'DownloadsPanelSubView', swap it out with the element
+          // that's passed in.
+          if (!shell.element.parentNode && element) {
+            // Make sure the clean up the placeholder element.
+            if (this._lastSessionDownloadElement == shell.element)
+              this._lastSessionDownloadElement = element;
+            shell.element.remove();
+            shell.element = element;
+            element._shell = shell;
+          }
         }
         shell.element._placesNode = aPlacesNode;
       }
     }
 
     if (newOrUpdatedShell) {
       if (aNewest) {
-        this._richlistbox.insertBefore(newOrUpdatedShell.element,
-                                       this._richlistbox.firstChild);
+        if (this._richlistbox) {
+          this._richlistbox.insertBefore(newOrUpdatedShell.element,
+                                         this._richlistbox.firstChild);
+          // Some operations like retrying an history download move an element to
+          // the top of the richlistbox, along with other session downloads.
+          // More generally, if a new download is added, should be made visible.
+          this._richlistbox.ensureElementIsVisible(newOrUpdatedShell.element);
+        }
         if (!this._lastSessionDownloadElement) {
           this._lastSessionDownloadElement = newOrUpdatedShell.element;
         }
-        // Some operations like retrying an history download move an element to
-        // the top of the richlistbox, along with other session downloads.
-        // More generally, if a new download is added, should be made visible.
-        this._richlistbox.ensureElementIsVisible(newOrUpdatedShell.element);
       } else if (sessionDownload) {
-        let before = this._lastSessionDownloadElement ?
-          this._lastSessionDownloadElement.nextSibling : this._richlistbox.firstChild;
-        this._richlistbox.insertBefore(newOrUpdatedShell.element, before);
+        if (this._richlistbox) {
+          let before = this._lastSessionDownloadElement ?
+            this._lastSessionDownloadElement.nextSibling : this._richlistbox.firstChild;
+          this._richlistbox.insertBefore(newOrUpdatedShell.element, before);
+        }
         this._lastSessionDownloadElement = newOrUpdatedShell.element;
-      } else {
+      } else if (this._richlistbox) {
         let appendTo = aDocumentFragment || this._richlistbox;
         appendTo.appendChild(newOrUpdatedShell.element);
       }
 
       if (this.searchTerm) {
         newOrUpdatedShell.element.hidden =
           !newOrUpdatedShell.element._shell.matchesSearchTerm(this.searchTerm);
       }
     }
 
     // If aDocumentFragment is defined this is a batch change, so it's up to
     // the caller to append the fragment and activate the visible shells.
     if (!aDocumentFragment) {
-      this._ensureVisibleElementsAreActive();
+      if (this._ensureVisibleElementsAreActive)
+        this._ensureVisibleElementsAreActive();
       this.window.goUpdateCommand("downloadsCmd_clearDownloads");
     }
   }
 
   _removeHistoryDownloadFromView(aPlacesNode) {
     let downloadURI = aPlacesNode.uri;
     let shellsForURI = this._downloadElementsShellsForURI.get(downloadURI);
     if (shellsForURI) {
@@ -371,19 +389,25 @@ this.DownloadsViewUI.BaseDownloadsPlaces
       let url = shell.historyDownload.source.url;
       let metaData = this._getPlacesMetaDataFor(url);
       shell.historyDownload.updateFromMetaData(metaData);
       shell.sessionDownload = null;
       // Move it below the session-download items;
       if (this._lastSessionDownloadElement == shell.element) {
         this._lastSessionDownloadElement = shell.element.previousSibling;
       } else {
-        let before = this._lastSessionDownloadElement ?
-          this._lastSessionDownloadElement.nextSibling : this._richlistbox.firstChild;
-        this._richlistbox.insertBefore(shell.element, before);
+        let before = null;
+        if (this._lastSessionDownloadElement) {
+          before = this._lastSessionDownloadElement.nextSibling;
+        } else if (this._richlistbox) {
+          before = this._richlistbox.firstChild;
+        }
+        if (before) {
+          this._richlistbox.insertBefore(shell.element, before);
+        }
       }
     }
   }
 
   get searchTerm() {
     return this._searchTerm;
   }
 
@@ -394,17 +418,18 @@ this.DownloadsViewUI.BaseDownloadsPlaces
       }
       this._ensureVisibleElementsAreActive();
     }
     return this._searchTerm = aValue;
   }
 
   onDataLoadStarting() {}
   onDataLoadCompleted() {
-    this._ensureInitialSelection();
+    if (this._ensureInitialSelection)
+      this._ensureInitialSelection();
   }
 
   onDownloadAdded(download, newest) {
     this._addDownloadData(download, null, newest);
   }
 
   onDownloadStateChanged(download) {
     this._viewItemsForDownloads.get(download).onStateChanged();
@@ -928,19 +953,24 @@ this.DownloadsViewUI.HistoryDownload.pro
  *
  * The caller is also responsible for forwarding status notifications for
  * session downloads, calling the onStateChanged and onChanged methods.
  *
  * @param [optional] aSessionDownload
  *        The session download, required if aHistoryDownload is not set.
  * @param [optional] aHistoryDownload
  *        The history download, required if aSessionDownload is not set.
+ * @param aWindow
+ *        Window object that this shell is created in.
+ * @param [optional] aElement
+ *        Allow an existing element to be (re-)used, instead of creating a new
+ *        richlistitem.
  */
-this.DownloadsViewUI.HistoryDownloadElementShell = function(aSessionDownload, aHistoryDownload, aWindow) {
-  this.element = aWindow.document.createElement("richlistitem");
+this.DownloadsViewUI.HistoryDownloadElementShell = function(aSessionDownload, aHistoryDownload, aWindow, aElement) {
+  this.element = aElement || aWindow.document.createElement("richlistitem");
   this.element._shell = this;
 
   this.element.classList.add("download");
   this.element.classList.add("download-state");
 
   if (aSessionDownload) {
     this.sessionDownload = aSessionDownload;
   }
@@ -1259,54 +1289,61 @@ this.DownloadsViewUI.DownloadsPlacesView
     }
     // If this function returns true, other controllers won't get a chance to
     // process the command even if isCommandEnabled returns false, so it's
     // important to check if the list is focused here to handle common commands
     // like copy and paste correctly. The clear downloads command, instead, is
     // specific to the downloads list but can be invoked from the toolbar, so we
     // can just return true unconditionally.
     return aCommand == "downloadsCmd_clearDownloads" ||
-           this.view.window.document.activeElement == this.view._richlistbox;
+           !this.view._richlistbox || this.view.window.document.activeElement == this.view._richlistbox;
   }
 
   isCommandEnabled(aCommand) {
     switch (aCommand) {
       case "cmd_copy":
       case "downloadsCmd_openReferrer":
       case "downloadShowMenuItem":
-        return this.view._richlistbox.selectedItems.length == 1;
+        return this.view._richlistbox ? this.view._richlistbox.selectedItems.length == 1 :
+          this.view.selectedNodes.length == 1;
       case "cmd_selectAll":
-        return true;
+        return !!this.view._richlistbox;
       case "cmd_paste":
         return this._canDownloadClipboardURL();
       case "downloadsCmd_clearDownloads":
         return this._canClearDownloads();
       default:
-        return Array.every(this.view._richlistbox.selectedItems,
-                           element => element._shell.isCommandEnabled(aCommand));
+        return Array.every(this.view._richlistbox ? this.view._richlistbox.selectedItems :
+          this.view.selectedNodes, element => element._shell.isCommandEnabled(aCommand));
     }
   }
 
   _canClearDownloads() {
     // Downloads can be cleared if there's at least one removable download in
     // the list (either a history download or a completed session download).
     // Because history downloads are always removable and are listed after the
     // session downloads, check from bottom to top.
-    for (let elt = this.view._richlistbox.lastChild; elt; elt = elt.previousSibling) {
+    let elements = this.view._richlistbox ? this.view._richlistbox.childNodes :
+      this.view._panelMenuView._rootElt.childNodes;
+    for (let i = elements.length - 1; i >= 0; --i) {
+      let elt = elements[i];
+      if (!elt._shell)
+        continue;
       // Stopped, paused, and failed downloads with partial data are removed.
       let download = elt._shell.download;
       if (download.stopped && !(download.canceled && download.hasPartialData)) {
         return true;
       }
     }
     return false;
   }
 
   _copySelectedDownloadsToClipboard() {
-    let urls = this.view._richlistbox.selectedItems.map(element => element._shell.download.source.url);
+    let urls = (this.view._richlistbox ? this.view._richlistbox.selectedItems :
+      this.view.selectedNodes).map(element => element._shell.download.source.url);
 
     Cc["@mozilla.org/widget/clipboardhelper;1"]
       .getService(Ci.nsIClipboardHelper)
       .copyString(urls.join("\n"));
   }
 
   _getURLFromClipboardData() {
     let trans = Cc["@mozilla.org/widget/transferable;1"].
@@ -1354,31 +1391,33 @@ this.DownloadsViewUI.DownloadsPlacesView
       this[aCommand]();
       return;
     }
 
     // Cloning the nodelist into an array to get a frozen list of selected items.
     // Otherwise, the selectedItems nodelist is live and doCommand may alter the
     // selection while we are trying to do one particular action, like removing
     // items from history.
-    let selectedElements = [...this.view._richlistbox.selectedItems];
+    let selectedElements = [...this.view._richlistbox ?
+      this.view._richlistbox.selectedItems : this.view.selectedNodes];
     for (let element of selectedElements) {
       element._shell.doCommand(aCommand);
     }
   }
 
   // nsIController
   onEvent() {}
 
   cmd_copy() {
     this._copySelectedDownloadsToClipboard();
   }
 
   cmd_selectAll() {
-    this.view._richlistbox.selectAll();
+    if (this.view._richlistbox)
+      this.view._richlistbox.selectAll();
   }
 
   cmd_paste() {
     this._downloadURLFromClipboard();
   }
 
   downloadsCmd_clearDownloads() {
     this.view._downloadsData.removeFinished();
@@ -1388,33 +1427,38 @@ this.DownloadsViewUI.DownloadsPlacesView
         .removeAllDownloads();
     }
     // There may be no selection or focus change as a result
     // of these change, and we want the command updated immediately.
     this.view.window.goUpdateCommand("downloadsCmd_clearDownloads");
   }
 
   buildContextMenu(contextMenu, download) {
+    if (!download) {
+      download = contextMenu.triggerNode._shell.download;
+    }
+
     contextMenu.setAttribute("state",
                              DownloadsCommon.stateOfDownload(download));
     contextMenu.setAttribute("exists", "true");
     contextMenu.classList.toggle("temporary-block",
                                  !!download.hasBlockedData);
 
     if (!download.stopped) {
       // The hasPartialData property of a download may change at any time after
       // it has started, so ensure we update the related command now.
       this.view.window.goUpdateCommand("downloadsCmd_pauseResume");
     }
   }
 
   setDataTransfer(aEvent) {
     // TODO Bug 831358: Support d&d for multiple selection.
     // For now, we just drag the first element.
-    let selectedItem = this.view._richlistbox.selectedItem;
+    let selectedItem = this.view._richlistbox ? this.view._richlistbox.selectedItem :
+      this.view.selectedNode;
     if (!selectedItem) {
       return;
     }
 
     let targetPath = selectedItem._shell.download.target.path;
     if (!targetPath) {
       return;
     }
--- a/browser/components/downloads/content/downloads.js
+++ b/browser/components/downloads/content/downloads.js
@@ -65,16 +65,18 @@
 var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "DownloadsCommon",
                                   "resource:///modules/DownloadsCommon.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "DownloadsViewUI",
                                   "resource:///modules/DownloadsViewUI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "DownloadsPanelSubView",
+                                  "resource:///modules/DownloadsPanelSubView.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                   "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
--- a/browser/components/downloads/content/downloadsOverlay.xul
+++ b/browser/components/downloads/content/downloadsOverlay.xul
@@ -71,16 +71,20 @@
 
         <menuitem command="cmd_delete"
                   class="downloadRemoveFromHistoryMenuItem"
                   label="&cmd.removeFromHistory.label;"
                   accesskey="&cmd.removeFromHistory.accesskey;"/>
         <menuitem command="downloadsCmd_clearList"
                   label="&cmd.clearList2.label;"
                   accesskey="&cmd.clearList2.accesskey;"/>
+        <menuitem command="downloadsCmd_clearDownloads"
+                  hidden="true"
+                  label="&cmd.clearDownloads.label;"
+                  accesskey="&cmd.clearDownloads.accesskey;"/>
       </menupopup>
 
       <panelmultiview id="downloadsPanel-multiView"
                       mainViewId="downloadsPanel-mainView">
 
         <panelview id="downloadsPanel-mainView">
           <vbox class="panel-view-body-unscrollable">
             <richlistbox id="downloadsListBox"
--- a/browser/components/downloads/moz.build
+++ b/browser/components/downloads/moz.build
@@ -8,14 +8,15 @@ with Files('*'):
     BUG_COMPONENT = ('Firefox', 'Downloads Panel')
 
 BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
 
 JAR_MANIFESTS += ['jar.mn']
 
 EXTRA_JS_MODULES += [
     'DownloadsCommon.jsm',
+    'DownloadsPanelSubView.jsm',
     'DownloadsTaskbar.jsm',
     'DownloadsViewUI.jsm',
 ]
 
 with Files('**'):
     BUG_COMPONENT = ('Firefox', 'Downloads Panel')
--- a/browser/components/places/content/browserPlacesViews.js
+++ b/browser/components/places/content/browserPlacesViews.js
@@ -13,17 +13,17 @@ Components.utils.import("resource://gre/
  * menu views.
  */
 function PlacesViewBase(aPlace, aOptions = {}) {
   if ("rootElt" in aOptions)
     this._rootElt = aOptions.rootElt;
   if ("viewElt" in aOptions)
     this._viewElt = aOptions.viewElt;
   this.options = aOptions;
-  this._controller = new PlacesController(this);
+  this._controller = aOptions.controller || new PlacesController(this);
   this.place = aPlace;
   this._viewElt.controllers.appendController(this._controller);
 }
 
 PlacesViewBase.prototype = {
   // The xul element that holds the entire view.
   _viewElt: null,
   get viewElt() {
@@ -224,16 +224,18 @@ PlacesViewBase.prototype = {
 
     return new InsertionPoint(PlacesUtils.getConcreteItemId(container),
                               index, orientation, tagName);
   },
 
   buildContextMenu: function PVB_buildContextMenu(aPopup) {
     this._contextMenuShown = aPopup;
     window.updateCommands("places");
+    if (this.options.onBuildContextMenu)
+      this.options.onBuildContextMenu();
     return this.controller.buildContextMenu(aPopup);
   },
 
   destroyContextMenu: function PVB_destroyContextMenu(aPopup) {
     this._contextMenuShown = null;
   },
 
   _cleanPopup: function PVB_cleanPopup(aPopup, aDelay) {
@@ -586,16 +588,19 @@ PlacesViewBase.prototype = {
       parentElt.removeChild(elt);
 
       // Figure out if we need to show the "<Empty>" menu-item.
       // TODO Bug 517701: This doesn't seem to handle the case of an empty
       // root.
       if (parentElt._startMarker.nextSibling == parentElt._endMarker)
         this._setEmptyPopupStatus(parentElt, true);
     }
+
+    if (this.options.onRemoveElement)
+      this.options.onRemoveElement(elt, aPlacesNode);
   },
 
   nodeHistoryDetailsChanged:
   function PVB_nodeHistoryDetailsChanged(aPlacesNode, aTime, aCount) {
     if (aPlacesNode.parent &&
         this.controller.hasCachedLivemarkInfo(aPlacesNode.parent)) {
       // Find the node in the parent.
       let popup = this._getDOMNodeForPlacesNode(aPlacesNode.parent);
@@ -1972,49 +1977,52 @@ PlacesPanelMenuView.prototype = {
     }
 
     for (let i = 0; i < this._resultNode.childCount; ++i) {
       this._insertNewItem(this._resultNode.getChild(i), null);
     }
   }
 };
 
-class PlacesPanelview extends PlacesViewBase {
+this.PlacesPanelview = class extends PlacesViewBase {
   constructor(container, panelview, place, options = {}) {
     options.rootElt = container;
     options.viewElt = panelview;
     super(place, options);
     this._viewElt._placesView = this;
     // We're simulating a popup show, because a panelview may only be shown when
     // its containing popup is already shown.
     this._onPopupShowing({ originalTarget: this._viewElt });
     this._addEventListeners(window, ["unload"]);
-    this._rootElt.setAttribute("context", "placesContext");
+    this._rootElt.setAttribute("context", this.options.context || "placesContext");
   }
 
   get events() {
     if (this._events)
       return this._events;
-    return this._events = ["command", "destructed", "dragend", "dragstart",
+    return this._events = ["command", "click", "destructed", "dragend", "dragstart",
       "ViewHiding", "ViewShowing", "ViewShown"];
   }
 
   get panel() {
     return this.panelMultiView.parentNode;
   }
 
   get panelMultiView() {
     return this._viewElt.panelMultiView;
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "command":
         this._onCommand(event);
         break;
+      case "click":
+        this._onClick(event);
+        break;
       case "destructed":
         this._onDestructed(event);
         break;
       case "dragend":
         this._onDragEnd(event);
         break;
       case "dragstart":
         this._onDragStart(event);
@@ -2031,20 +2039,46 @@ class PlacesPanelview extends PlacesView
       case "ViewShown":
         this._onViewShown(event);
         break;
     }
   }
 
   _onCommand(event) {
     let button = event.originalTarget;
-    if (!button._placesNode)
+    let placesNode = button._placesNode || button.parentNode._placesNode
+    if (!placesNode)
+      return;
+
+    if (this.options.onCommand) {
+      this.options.onCommand(button, placesNode, event);
+    } else {
+      PlacesUIUtils.openNodeWithEvent(placesNode, event);
+    }
+  }
+
+  _onClick(event) {
+    if (!this.options.onClick || event.button !== 0)
       return;
 
-    PlacesUIUtils.openNodeWithEvent(button._placesNode, event);
+    let target = event.originalTarget;
+    let button = target;
+    // Ignore clicks from context menus.
+    if (this._contextMenuShown && button.localName == "menuitem")
+      return;
+
+    while (button.parentNode && button.parentNode != this._rootElt && !button.classList.contains("bookmark-item"))
+      button = button.parentNode;
+    if (!button.parentNode || !button.classList.contains("bookmark-item"))
+      return;
+    if (this.options.onClick(target, button, event) === false) {
+      event.stopPropagation();
+      event.preventDefault();
+      this.panel.hidePopup();
+    }
   }
 
   _onDestructed(event) {
     // The panelmultiview is ephemeral, so let's keep an eye out when the root
     // element is showing again.
     this._removeEventListeners(event.target, this.events);
     this._addEventListeners(this._viewElt, ["ViewShowing"]);
   }
@@ -2066,16 +2100,17 @@ class PlacesPanelview extends PlacesView
     event.stopPropagation();
   }
 
   uninit(event) {
     this._removeEventListeners(this.panelMultiView, this.events);
     this._removeEventListeners(this._viewElt, ["ViewShowing"]);
     this._removeEventListeners(window, ["unload"]);
     super.uninit(event);
+    this._viewElt = this._rootElt = null;
   }
 
   _createDOMNodeForPlacesNode(placesNode) {
     this._domNodes.delete(placesNode);
 
     let element;
     let type = placesNode.type;
     if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) {
@@ -2093,16 +2128,19 @@ class PlacesPanelview extends PlacesView
       if (icon)
         element.setAttribute("image", icon);
     }
 
     element._placesNode = placesNode;
     if (!this._domNodes.has(placesNode))
       this._domNodes.set(placesNode, element);
 
+    if (this.options.onInsertElement)
+      this.options.onInsertElement(element, placesNode);
+
     return element;
   }
 
   _setEmptyPopupStatus(panelview, empty = false) {
     if (!panelview._emptyMenuitem) {
       let label = PlacesUIUtils.getString("bookmarksMenuEmptyFolder");
       panelview._emptyMenuitem = document.createElement("toolbarbutton");
       panelview._emptyMenuitem.setAttribute("label", label);
--- a/browser/locales/en-US/chrome/browser/downloads/downloads.properties
+++ b/browser/locales/en-US/chrome/browser/downloads/downloads.properties
@@ -97,8 +97,18 @@ fileExecutableSecurityWarningTitle=Open 
 fileExecutableSecurityWarningDontAsk=Don’t ask me this again
 
 # LOCALIZATION NOTE (otherDownloads3):
 # This is displayed in an item at the bottom of the Downloads Panel when
 # there are more downloads than can fit in the list in the panel. Use a
 # semi-colon list of plural forms.
 # See: http://developer.mozilla.org/en/Localization_and_Plurals
 otherDownloads3=%1$S file downloading;%1$S files downloading
+
+# LOCALIZATION NOTE (showLabel, showMacLabel):
+# This is displayed when you hover a download item in the Library widget view.
+# showMacLabel is only shown on Mac OSX.
+showLabel=Open Containing Folder
+showMacLabel=Open In Finder
+# LOCALIZATION NOTE (openFileLabel):
+# Displayed when hovering a complete download, indicates that it's possible to
+# open the file using an app available in the system.
+openFileLabel=Open File
--- a/browser/themes/shared/customizableui/panelUI.inc.css
+++ b/browser/themes/shared/customizableui/panelUI.inc.css
@@ -2132,13 +2132,52 @@ photonpanelmultiview#customizationui-wid
 }
 
 /* This is explicitly overriding the overflow properties set above. */
 photonpanelmultiview .cui-widget-panelview {
   overflow-x: visible;
   overflow-y: visible;
 }
 
+#customizationui-widget-multiview #appMenu-libraryView {
+  min-width: 320px !important;
+}
+
 photonpanelmultiview #panelMenu_pocket {
   display: none;
 }
 
+.subviewbutton.download {
+  -moz-box-align: start;
+  min-height: 48px;
+}
+
+.subviewbutton.download > .toolbarbutton-icon,
+.subviewbutton.download > .toolbarbutton-text > label {
+  margin: 4px 0 0;
+}
+
+.subviewbutton.download > .toolbarbutton-text > .status-text {
+  color: GrayText;
+  font-size: .7em;
+}
+
+.subviewbutton.download > .show-button {
+  -moz-context-properties: fill;
+  fill: currentColor;
+  list-style-image: url("chrome://browser/skin/find.svg");
+  /* Measurement to vertically center this button: 1 line of text and half of 4px top margin. */
+  margin: calc(1em + 2px) 0 0;
+  padding: 0;
+}
+
+.subviewbutton.download:not([openLabel]) > .show-button {
+  fill: GrayText;
+  opacity: .5;
+}
+
+.subviewbutton.download[openLabel] > .show-button@buttonStateHover@ {
+  fill: #0a84ff;
+  outline: 1px solid #0a84ff;
+  -moz-outline-radius: 3px;
+}
+
 /* END photon adjustments */
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/icons/delete.svg
@@ -0,0 +1,9 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+  <path fill="context-fill" d="M6.5 12a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5z"/>
+  <path fill="context-fill" d="M8.5 12a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5z"/>
+  <path fill="context-fill" d="M10.5 12a.5.5 0 0 0 .5-.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 0 .5.5z"/>
+  <path fill="context-fill" d="M14 2h-3.05a2.5 2.5 0 0 0-4.9 0H3a1 1 0 0 0 0 2v9a3 3 0 0 0 3 3h5a3 3 0 0 0 3-3V4a1 1 0 0 0 0-2zM8.5 1a1.489 1.489 0 0 1 1.391 1H7.109A1.489 1.489 0 0 1 8.5 1zM12 13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4h7z"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/icons/folder.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+  <path fill="context-fill" d="M14 3H8.151L6.584 1.538A2 2 0 0 0 5.219 1H2a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5a2 2 0 0 -2-2zM5.219 3l1.072 1H2V3zM14 13H2V5h6v-.014c.05 0 .1.014.151.014H14z"/>
+</svg>
--- a/browser/themes/shared/jar.inc.mn
+++ b/browser/themes/shared/jar.inc.mn
@@ -144,19 +144,23 @@
 #ifndef MOZ_PHOTON_THEME
   skin/classic/browser/download.svg                   (../shared/icons/download.svg)
 #endif
   skin/classic/browser/edit-copy.svg                  (../shared/icons/edit-copy.svg)
   skin/classic/browser/edit-cut.svg                   (../shared/icons/edit-cut.svg)
   skin/classic/browser/edit-paste.svg                 (../shared/icons/edit-paste.svg)
 #ifdef MOZ_PHOTON_THEME
   skin/classic/browser/email-link.svg                 (../shared/icons/email-link.svg)
+  skin/classic/browser/delete.svg                     (../shared/icons/delete.svg)
 #endif
   skin/classic/browser/feed.svg                       (../shared/icons/feed.svg)
   skin/classic/browser/find.svg                       (../shared/icons/find.svg)
+#ifdef MOZ_PHOTON_THEME
+  skin/classic/browser/folder.svg                     (../shared/icons/folder.svg)
+#endif
   skin/classic/browser/forget.svg                     (../shared/icons/forget.svg)
   skin/classic/browser/forward.svg                    (../shared/icons/forward.svg)
   skin/classic/browser/fullscreen.svg                 (../shared/icons/fullscreen.svg)
   skin/classic/browser/fullscreen-enter.svg           (../shared/icons/fullscreen-enter.svg)
   skin/classic/browser/fullscreen-exit.svg            (../shared/icons/fullscreen-exit.svg)
   skin/classic/browser/help.svg                       (../shared/icons/help.svg)
   skin/classic/browser/history.svg                    (../shared/icons/history.svg)
   skin/classic/browser/home.svg                       (../shared/icons/home.svg)
--- a/browser/themes/shared/menupanel.inc.css
+++ b/browser/themes/shared/menupanel.inc.css
@@ -285,8 +285,20 @@ toolbarpaletteitem[place="palette"] > #z
 }
 
 %ifdef MOZ_PHOTON_THEME
 #bookmarks-menu-button[cui-areatype="menu-panel"],
 toolbarpaletteitem[place="palette"] > #bookmarks-menu-button {
   list-style-image: url("chrome://browser/skin/bookmark-star-on-tray.svg");
 }
 %endif
+
+#appMenu-library-downloads-button {
+  list-style-image: url("chrome://browser/skin/downloads/download-icons.svg#arrow-with-bar");
+}
+
+#appMenu-library-downloads-show-button {
+  list-style-image: url("chrome://browser/skin/folder.svg");
+}
+
+#appMenu-library-downloads-clear-button {
+  list-style-image: url("chrome://browser/skin/delete.svg");
+}
--- a/toolkit/content/widgets/toolbarbutton.xml
+++ b/toolkit/content/widgets/toolbarbutton.xml
@@ -108,9 +108,24 @@
       <xul:label class="toolbarbutton-text" crop="right" flex="1"
                  xbl:inherits="value=label,accesskey,crop,dragover-top,wrap"/>
       <xul:label class="toolbarbutton-multiline-text" flex="1"
                  xbl:inherits="xbl:text=label,accesskey,wrap"/>
       <xul:dropmarker anonid="dropmarker" type="menu"
                       class="toolbarbutton-menu-dropmarker" xbl:inherits="disabled,label"/>
     </content>
   </binding>
+
+  <binding id="toolbarbutton-download"
+           extends="chrome://global/content/bindings/button.xml#menu-button-base">
+    <content>
+      <children includes="observes|template|menupopup|panel|tooltip"/>
+      <xul:image class="toolbarbutton-icon" xbl:inherits="validate,src=image,label,consumeanchor"/>
+      <xul:vbox class="toolbarbutton-text" flex="1">
+        <xul:label crop="end" xbl:inherits="value=label,accesskey,crop,wrap"/>
+        <xul:label class="status-text status-full" crop="end" xbl:inherits="value=fullStatus"/>
+        <xul:label class="status-text status-open" crop="end" xbl:inherits="value=openLabel"/>
+        <xul:label class="status-text status-show" crop="end" xbl:inherits="value=showLabel"/>
+      </xul:vbox>
+      <xul:toolbarbutton anonid="button" class="show-button" xbl:inherits="tooltiptext=showLabel"/>
+    </content>
+  </binding>
 </bindings>