Bug 1354532 - Part 1 - Implement a new Downloads view as part of the Library widget. r=Paolo
☠☠ backed out by 7b74ef52385d ☠ ☠
authorMike de Boer <mdeboer@mozilla.com>
Wed, 06 Sep 2017 16:23:00 +0200
changeset 428666 f1d18c741b2caddeb79714a25a2a7f34914ce1b8
parent 428665 4fa9f7339c5dd4034004a25d022b17140f03e65c
child 428667 63909748b3ce21c442c37c3889ffeae0abd43ba5
push id7761
push userjlund@mozilla.com
push dateFri, 15 Sep 2017 00:19:52 +0000
treeherdermozilla-beta@c38455951db4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersPaolo
bugs1354532
milestone57.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1354532 - Part 1 - Implement a new Downloads view as part of the Library widget. r=Paolo MozReview-Commit-ID: AqH8Zj8XCQl
browser/base/content/browser.js
browser/components/customizableui/content/panelUI.inc.xul
browser/components/downloads/DownloadsSubview.jsm
browser/components/downloads/DownloadsViewUI.jsm
browser/components/downloads/content/allDownloadsViewOverlay.js
browser/components/downloads/content/download.xml
browser/components/downloads/content/downloads.css
browser/components/downloads/content/downloads.js
browser/components/downloads/content/downloadsOverlay.xul
browser/components/downloads/moz.build
browser/locales/en-US/chrome/browser/browser.dtd
browser/locales/en-US/chrome/browser/downloads/downloads.dtd
browser/locales/en-US/chrome/browser/downloads/downloads.properties
browser/themes/shared/customizableui/panelUI.inc.css
browser/themes/shared/icons/folder.svg
browser/themes/shared/jar.inc.mn
browser/themes/shared/menupanel.inc.css
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -103,16 +103,17 @@ XPCOMUtils.defineLazyScriptGetter(this, 
                                   "chrome://browser/content/browser-sync.js");
 XPCOMUtils.defineLazyScriptGetter(this, "gBrowserThumbnails",
                                   "chrome://browser/content/browser-thumbnails.js");
 XPCOMUtils.defineLazyScriptGetter(this, ["setContextMenuContentData",
                                          "openContextMenu", "nsContextMenu"],
                                   "chrome://browser/content/nsContextMenu.js");
 XPCOMUtils.defineLazyScriptGetter(this, ["DownloadsPanel",
                                          "DownloadsOverlayLoader",
+                                         "DownloadsSubview",
                                          "DownloadsView", "DownloadsViewUI",
                                          "DownloadsViewController",
                                          "DownloadsSummary", "DownloadsFooter",
                                          "DownloadsBlockedSubview"],
                                   "chrome://browser/content/downloads/downloads.js");
 XPCOMUtils.defineLazyScriptGetter(this, ["DownloadsButton",
                                          "DownloadsIndicatorView"],
                                   "chrome://browser/content/downloads/indicator.js");
--- a/browser/components/customizableui/content/panelUI.inc.xul
+++ b/browser/components/customizableui/content/panelUI.inc.xul
@@ -713,16 +713,21 @@
                        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="&libraryDownloads.label;"
+                       closemenu="none"
+                       oncommand="DownloadsSubview.show(this);"/>
       </vbox>
     </panelview>
 
     <panelview id="PanelUI-bookmarkingTools" class="PanelUI-subView">
       <vbox class="panel-subview-body">
         <toolbarbutton id="panelMenu_toggleBookmarksMenu"
                        label="&addBookmarksMenu.label;"
                        label-checked="&removeBookmarksMenu.label;"
new file mode 100644
--- /dev/null
+++ b/browser/components/downloads/DownloadsSubview.jsm
@@ -0,0 +1,399 @@
+/* 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 = [
+  "DownloadsSubview",
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+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, "DownloadsViewUI",
+                                  "resource:///modules/DownloadsViewUI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
+
+let gPanelViewInstances = new WeakMap();
+const kEvents = ["ViewShowing", "ViewHiding", "click", "command"];
+XPCOMUtils.defineLazyGetter(this, "kButtonLabels", () => {
+  return {
+    show: DownloadsCommon.strings[AppConstants.platform == "macosx" ? "showMacLabel" : "showLabel"],
+    open: DownloadsCommon.strings.openFileLabel,
+    retry: DownloadsCommon.strings.retryLabel,
+  };
+});
+
+class DownloadsSubview extends DownloadsViewUI.BaseView {
+  constructor(panelview) {
+    super();
+    this.document = panelview.ownerDocument;
+    this.window = panelview.ownerGlobal;
+
+    this.context = "panelDownloadsContextMenu";
+
+    this.panelview = panelview;
+    this.container = this.document.getElementById("panelMenu_downloadsMenu");
+    while (this.container.lastChild) {
+      this.container.lastChild.remove();
+    }
+    this.panelview.addEventListener("click", DownloadsSubview.onClick);
+    this.panelview.addEventListener("ViewHiding", DownloadsSubview.onViewHiding);
+
+    this._viewItemsForDownloads = new WeakMap();
+
+    let contextMenu = this.document.getElementById(this.context);
+    if (!contextMenu) {
+      contextMenu = this.document.getElementById("downloadsContextMenu").cloneNode(true);
+      contextMenu.setAttribute("closemenu", "none");
+      contextMenu.setAttribute("id", this.context);
+      contextMenu.removeAttribute("onpopupshown");
+      contextMenu.setAttribute("onpopupshowing",
+        "DownloadsSubview.updateContextMenu(document.popupNode, this);");
+      contextMenu.setAttribute("onpopuphidden", "DownloadsSubview.onContextMenuHidden(this);")
+      let clearButton = contextMenu.querySelector("menuitem[command='downloadsCmd_clearDownloads'");
+      clearButton.hidden = false;
+      clearButton.previousSibling.hidden = true;
+    }
+    this.panelview.appendChild(contextMenu);
+    this.container.setAttribute("context", this.context);
+
+    this._downloadsData = DownloadsCommon.getData(this.window, true, true, true);
+    this._downloadsData.addView(this);
+  }
+
+  destructor(event) {
+    this.panelview.removeEventListener("click", DownloadsSubview.onClick);
+    this.panelview.removeEventListener("ViewHiding", DownloadsSubview.onViewHiding);
+    this._downloadsData.removeView(this);
+    gPanelViewInstances.delete(this);
+  }
+
+  /**
+   * DataView handler; invoked when a batch of downloads is being passed in -
+   * usually when this instance is added as a view in the constructor.
+   */
+  onDownloadBatchStarting() {
+    this.batchFragment = this.document.createDocumentFragment();
+    this.window.clearTimeout(this._batchTimeout);
+  }
+
+  /**
+   * DataView handler; invoked when the view stopped feeding its current list of
+   * downloads.
+   */
+  onDownloadBatchEnded() {
+    let {window} = this;
+    window.clearTimeout(this._batchTimeout);
+    let waitForMs = 200;
+    if (this.batchFragment.childElementCount) {
+      // Prepend the batch fragment.
+      this.container.insertBefore(this.batchFragment, this.container.firstChild || null);
+      waitForMs = 0;
+    }
+    // Wait a wee bit to dispatch the event, because another batch may start
+    // right away.
+    this._batchTimeout = window.setTimeout(() =>
+      this.panelview.dispatchEvent(new window.CustomEvent("DownloadsLoaded")), waitForMs);
+    this.batchFragment = null;
+  }
+
+  /**
+   * DataView handler; invoked when a new download is added to the list.
+   *
+   * @param {Download} download
+   * @param {DOMNode}  [options.insertBefore]
+   */
+  onDownloadAdded(download, { insertBefore } = {}) {
+    let shell = new DownloadsSubview.Button(download, this.document);
+    this._viewItemsForDownloads.set(download, shell);
+    // Triggger the code that update all attributes to match the downloads'
+    // current state.
+    shell.onChanged();
+
+    // Since newest downloads are displayed at the top, either prepend the new
+    // element or insert it after the one indicated by the insertBefore option.
+    if (insertBefore) {
+      this._viewItemsForDownloads.get(insertBefore)
+          .element.insertAdjacentElement("afterend", shell.element);
+    } else {
+      (this.batchFragment || this.container).prepend(shell.element);
+    }
+  }
+
+  /**
+   * DataView Handler; invoked when the state of a download changed.
+   *
+   * @param {Download} download
+   */
+  onDownloadChanged(download) {
+    this._viewItemsForDownloads.get(download).onChanged();
+  }
+
+  /**
+   * DataView handler; invoked when a download is removed.
+   *
+   * @param {Download} download
+   */
+  onDownloadRemoved(download) {
+    this._viewItemsForDownloads.get(download).element.remove();
+  }
+
+  // ----- Static methods. -----
+
+  /**
+   * Perform all tasks necessary to be able to show a Downloads Subview.
+   *
+   * @param  {DOMWindow} window  Global window object.
+   * @return {Promise}   Will resolve when all tasks are done.
+   */
+  static init(window) {
+    return new Promise(resolve =>
+      window.DownloadsOverlayLoader.ensureOverlayLoaded(window.DownloadsPanel.kDownloadsOverlay, resolve));
+  }
+
+  /**
+   * Show the Downloads subview panel and listen for events that will trigger
+   * building the dynamic part of the view.
+   *
+   * @param {DOMNode} anchor The button that was commanded to trigger this function.
+   */
+  static async show(anchor) {
+    let document = anchor.ownerDocument;
+    let window = anchor.ownerGlobal;
+    await DownloadsSubview.init(window);
+
+    let panelview = document.getElementById("PanelUI-downloads");
+    anchor.setAttribute("closemenu", "none");
+    gPanelViewInstances.set(panelview, new DownloadsSubview(panelview));
+
+    // Since the DownloadsLists are propagated asynchronously, we need to wait a
+    // little to get the view propagated.
+    panelview.addEventListener("ViewShowing", event => {
+      event.detail.addBlocker(new Promise(resolve => {
+        panelview.addEventListener("DownloadsLoaded", resolve, { once: true });
+      }));
+    }, { once: true });
+
+    window.PanelUI.showSubView("PanelUI-downloads", anchor);
+  }
+
+  /**
+   * Handler method; reveal the users' download directory using the OS specific
+   * method.
+   */
+  static async onShowDownloads() {
+    // Retrieve the user's default download directory.
+    let preferredDir = await Downloads.getPreferredDownloadsDirectory();
+    DownloadsCommon.showDirectory(new FileUtils.File(preferredDir));
+  }
+
+  /**
+   * Handler method; clear the list downloads finished and old(er) downloads,
+   * just like in the Library.
+   *
+   * @param {DOMNode} button Button that was clicked to call this method.
+   */
+  static onClearDownloads(button) {
+    let instance = gPanelViewInstances.get(button.closest("panelview"));
+    if (!instance)
+      return;
+    instance._downloadsData.removeFinished();
+    Cc["@mozilla.org/browser/download-history;1"]
+      .getService(Ci.nsIDownloadHistory)
+      .removeAllDownloads();
+  }
+
+  /**
+   * Just before showing the context menu, anchored to a download item, we need
+   * to set the right properties to make sure the right menu-items are visible.
+   *
+   * @param {DOMNode} button The Button the context menu will be anchored to.
+   * @param {DOMNode} menu   The context menu.
+   */
+  static updateContextMenu(button, menu) {
+    while (!button._shell) {
+      button = button.parentNode;
+    }
+    menu.setAttribute("state", button.getAttribute("state"));
+    if (button.hasAttribute("exists"))
+      menu.setAttribute("exists", button.getAttribute("exists"));
+    else
+      menu.removeAttribute("exists");
+    menu.classList.toggle("temporary-block", button.classList.contains("temporary-block"));
+    menu.querySelector("menuitem[command='downloadsCmd_clearDownloads'").disabled =
+      !DownloadsSubview.canClearDownloads(button);
+    // The menu anchorNode property is not available long enough to be used elsewhere,
+    // so tack it another property name.
+    menu._anchorNode = button;
+  }
+
+  /**
+   * Right after the context menu was hidden, perform a bit of cleanup.
+   *
+   * @param {DOMNode} menu The context menu.
+   */
+  static onContextMenuHidden(menu) {
+    delete menu._anchorNode;
+  }
+
+  /**
+   * Static version of DownloadsSubview#canClearDownloads().
+   *
+   * @param {DOMNode} button Button that we'll use to find the right
+   *                         DownloadsSubview instance.
+   */
+  static canClearDownloads(button) {
+    let instance = gPanelViewInstances.get(button.closest("panelview"));
+    if (!instance)
+      return false;
+    return instance.canClearDownloads(instance.container);
+  }
+
+  /**
+   * Handler method; invoked when the Downloads panel is hidden and should be
+   * torn down & cleaned up.
+   *
+   * @param {DOMEvent} event
+   */
+  static onViewHiding(event) {
+    let instance = gPanelViewInstances.get(event.target);
+    if (!instance)
+      return;
+    instance.destructor(event);
+  }
+
+  /**
+   * Handler method; invoked when anything is clicked inside the Downloads panel.
+   * Depending on the context, it will find the appropriate command to invoke.
+   *
+   * We don't have a command dispatcher registered for this view, so we don't go
+   * through the goDoCommand path like we do for the other views.
+   *
+   * @param {DOMMouseEvent} event
+   */
+  static onClick(event) {
+    // Middle clicks fall through and are regarded as left clicks.
+    if (event.button > 1)
+      return;
+
+    let button = event.originalTarget;
+    if (!button.hasAttribute || button.classList.contains("subviewbutton-back"))
+      return;
+
+    let command = "downloadsCmd_open";
+    if (button.classList.contains("action-button")) {
+      button = button.parentNode;
+      command = button.hasAttribute("showLabel") ? "downloadsCmd_show" : "downloadsCmd_retry";
+    } else if (button.localName == "menuitem") {
+      command = button.getAttribute("command");
+      button = button.parentNode._anchorNode;
+    }
+    while (button && !button._shell && button != this.panelview &&
+           (!button.hasAttribute || !button.hasAttribute("oncommand"))) {
+      button = button.parentNode;
+    }
+
+    // We don't need to do anything when no button was clicked, like a separator
+    // or a blank panel area. Also, when 'oncommand' is set, the button will invoke
+    // its own, custom command handler.
+    if (!button || button == this.panelview || button.hasAttribute("oncommand"))
+      return;
+
+    if (command == "downloadsCmd_clearDownloads") {
+      DownloadsSubview.onClearDownloads(button);
+    } else if (button._shell.isCommandEnabled(command)) {
+      button._shell[command]();
+    }
+  }
+}
+
+DownloadsSubview.Button = class extends DownloadsViewUI.DownloadElementShell {
+  constructor(download, document) {
+    super();
+    this.download = download;
+
+    this.element = document.createElement("toolbarbutton");
+    this.element._shell = this;
+
+    this.element.classList.add("subviewbutton", "subviewbutton-iconic", "download",
+      "download-state");
+  }
+
+  get browserWindow() {
+    return this.element.ownerGlobal;
+  }
+
+  /**
+   * Handle state changes of a download.
+   */
+  onStateChanged() {
+    // Since the state changed, we may need to check the target file again.
+    this._targetFileChecked = false;
+
+    this._updateState();
+  }
+
+  /**
+   * Handler method; invoked when any state attribute of a download changed.
+   */
+  onChanged() {
+    // TODO: implement "file moved or missing" check - bug 1395615.
+    let newState = DownloadsCommon.stateOfDownload(this.download);
+    if (this._downloadState !== newState) {
+      this._downloadState = newState;
+      this.onStateChanged();
+    } else {
+      this._updateState();
+    }
+
+    // This cannot be placed within onStateChanged because when a download goes
+    // from hasBlockedData to !hasBlockedData it will still remain in the same state.
+    this.element.classList.toggle("temporary-block",
+                                  !!this.download.hasBlockedData);
+  }
+
+  /**
+   * Update the DOM representation of this download to match the current, recently
+   * updated, state.
+   */
+  _updateState() {
+    super._updateState();
+    this.element.setAttribute("label", this.element.getAttribute("displayName"));
+    this.element.setAttribute("tooltiptext", this.element.getAttribute("fullStatus"));
+
+    if (this.isCommandEnabled("downloadsCmd_show")) {
+      this.element.setAttribute("openLabel", kButtonLabels.open);
+      this.element.setAttribute("showLabel", kButtonLabels.show);
+    } else if (this.isCommandEnabled("downloadsCmd_retry")) {
+      this.element.setAttribute("retryLabel", kButtonLabels.retry);
+    }
+
+    this._updateVisibility();
+  }
+
+  _updateVisibility() {
+    let state = this.element.getAttribute("state");
+    // This view only show completed and failed downloads.
+    this.element.hidden = !(state == DownloadsCommon.DOWNLOAD_FINISHED ||
+      state == DownloadsCommon.DOWNLOAD_FAILED);
+  }
+
+  /**
+   * Command handler; copy the download URL to the OS general clipboard.
+   */
+  downloadsCmd_copyLocation() {
+    let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"]
+                      .getService(Ci.nsIClipboardHelper);
+    clipboard.copyString(this.download.source.url);
+  }
+};
--- a/browser/components/downloads/DownloadsViewUI.jsm
+++ b/browser/components/downloads/DownloadsViewUI.jsm
@@ -18,31 +18,52 @@ const { classes: Cc, interfaces: Ci, uti
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Downloads",
                                   "resource://gre/modules/Downloads.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "DownloadUtils",
                                   "resource://gre/modules/DownloadUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "DownloadsCommon",
                                   "resource:///modules/DownloadsCommon.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+                                  "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
+                                  "resource:///modules/RecentWindow.jsm");
 
 this.DownloadsViewUI = {
   /**
    * Returns true if the given string is the name of a command that can be
    * handled by the Downloads user interface, including standard commands.
    */
   isCommandName(name) {
     return name.startsWith("cmd_") || name.startsWith("downloadsCmd_");
   },
 };
 
+this.DownloadsViewUI.BaseView = class {
+  canClearDownloads(nodeContainer) {
+    // 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 = nodeContainer.lastChild; elt; elt = elt.previousSibling) {
+      // 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;
+  }
+};
+
 /**
  * A download element shell is responsible for handling the commands and the
  * displayed data for a single element that uses the "download.xml" binding.
  *
  * The information to display is obtained through the associated Download object
  * from the JavaScript API for downloads, and commands are executed using a
  * combination of Download methods and DownloadsCommon.jsm helper functions.
  *
@@ -85,16 +106,20 @@ this.DownloadsViewUI.DownloadElementShel
    */
   get displayName() {
     if (!this.download.target.path) {
       return this.download.source.url;
     }
     return OS.Path.basename(this.download.target.path);
   },
 
+  get browserWindow() {
+    return RecentWindow.getMostRecentBrowserWindow();
+  },
+
   /**
    * The progress element for the download, or undefined in case the XBL binding
    * has not been applied yet.
    */
   get _progressElement() {
     if (!this.__progressElement) {
       // If the element is not available now, we will try again the next time.
       this.__progressElement =
@@ -363,41 +388,89 @@ this.DownloadsViewUI.DownloadElementShel
       case "downloadsCmd_confirmBlock":
       case "downloadsCmd_chooseUnblock":
       case "downloadsCmd_chooseOpen":
       case "downloadsCmd_unblock":
       case "downloadsCmd_unblockAndOpen":
         return this.download.hasBlockedData;
       case "downloadsCmd_cancel":
         return this.download.hasPartialData || !this.download.stopped;
+      case "downloadsCmd_open":
+        // This property is false if the download did not succeed.
+        return this.download.target.exists;
+      case "downloadsCmd_show":
+        // TODO: Bug 827010 - Handle part-file asynchronously.
+        if (this.download.target.partFilePath) {
+          let partFile = new FileUtils.File(this.download.target.partFilePath);
+          if (partFile.exists()) {
+            return true;
+          }
+        }
+
+        // This property is false if the download did not succeed.
+        return this.download.target.exists;
+      case "cmd_delete":
+        // We don't want in-progress downloads to be removed accidentally.
+        return this.download.stopped;
     }
-    return false;
+    return DownloadsViewUI.isCommandName(aCommand) && !!this[aCommand];
+  },
+
+  doCommand(aCommand) {
+    if (DownloadsViewUI.isCommandName(aCommand)) {
+      this[aCommand]();
+    }
   },
 
   downloadsCmd_cancel() {
     // This is the correct way to avoid race conditions when cancelling.
     this.download.cancel().catch(() => {});
     this.download.removePartialData().catch(Cu.reportError);
   },
 
-  downloadsCmd_retry() {
-    // Errors when retrying are already reported as download failures.
-    this.download.start().catch(() => {});
+  downloadsCmd_confirmBlock() {
+    this.download.confirmBlock().catch(Cu.reportError);
+  },
+
+  downloadsCmd_open() {
+    let file = new FileUtils.File(this.download.target.path);
+    DownloadsCommon.openDownloadedFile(file, null, this.element.ownerGlobal);
+  },
+
+  downloadsCmd_openReferrer() {
+    this.element.ownerGlobal.openURL(this.download.source.referrer);
   },
 
   downloadsCmd_pauseResume() {
     if (this.download.stopped) {
       this.download.start();
     } else {
       this.download.cancel();
     }
   },
 
-  downloadsCmd_confirmBlock() {
-    this.download.confirmBlock().catch(Cu.reportError);
+  downloadsCmd_show() {
+    let file = new FileUtils.File(this.download.target.path);
+    DownloadsCommon.showDownloadedFile(file);
+  },
+
+  downloadsCmd_retry() {
+    if (this.download.start) {
+      // Errors when retrying are already reported as download failures.
+      this.download.start().catch(() => {});
+      return;
+    }
+
+    let window = this.browserWindow || this.element.ownerGlobal;
+    let document = window.document;
+
+    // Do not suggest a file name if we don't know the original target.
+    let targetPath = this.download.target.path ?
+                     OS.Path.basename(this.download.target.path) : null;
+    window.DownloadURL(this.download.source.url, targetPath, document);
   },
 
   cmd_delete() {
     (async () => {
       // Remove the associated history element first, if any, so that the views
       // that combine history and session downloads won't resurrect the history
       // download into the view just before it is deleted permanently.
       try {
--- a/browser/components/downloads/content/allDownloadsViewOverlay.js
+++ b/browser/components/downloads/content/allDownloadsViewOverlay.js
@@ -115,75 +115,20 @@ HistoryDownloadElementShell.prototype = 
   },
   _downloadState: null,
 
   isCommandEnabled(aCommand) {
     // The only valid command for inactive elements is cmd_delete.
     if (!this.active && aCommand != "cmd_delete") {
       return false;
     }
-    switch (aCommand) {
-      case "downloadsCmd_open":
-        // This property is false if the download did not succeed.
-        return this.download.target.exists;
-      case "downloadsCmd_show":
-        // TODO: Bug 827010 - Handle part-file asynchronously.
-        if (this.download.target.partFilePath) {
-          let partFile = new FileUtils.File(this.download.target.partFilePath);
-          if (partFile.exists()) {
-            return true;
-          }
-        }
-
-        // This property is false if the download did not succeed.
-        return this.download.target.exists;
-      case "cmd_delete":
-        // We don't want in-progress downloads to be removed accidentally.
-        return this.download.stopped;
-    }
     return DownloadsViewUI.DownloadElementShell.prototype
                           .isCommandEnabled.call(this, aCommand);
   },
 
-  doCommand(aCommand) {
-    if (DownloadsViewUI.isCommandName(aCommand)) {
-      this[aCommand]();
-    }
-  },
-
-  downloadsCmd_retry() {
-    if (this.download.start) {
-      DownloadsViewUI.DownloadElementShell.prototype
-                     .downloadsCmd_retry.call(this);
-      return;
-    }
-
-    let browserWin = RecentWindow.getMostRecentBrowserWindow();
-    let initiatingDoc = browserWin ? browserWin.document : document;
-
-    // Do not suggest a file name if we don't know the original target.
-    let targetPath = this.download.target.path ?
-                     OS.Path.basename(this.download.target.path) : null;
-    DownloadURL(this.download.source.url, targetPath, initiatingDoc);
-  },
-
-  downloadsCmd_open() {
-    let file = new FileUtils.File(this.download.target.path);
-    DownloadsCommon.openDownloadedFile(file, null, window);
-  },
-
-  downloadsCmd_show() {
-    let file = new FileUtils.File(this.download.target.path);
-    DownloadsCommon.showDownloadedFile(file);
-  },
-
-  downloadsCmd_openReferrer() {
-    openURL(this.download.source.referrer);
-  },
-
   downloadsCmd_unblock() {
     this.confirmUnblock(window, "unblock");
   },
 
   downloadsCmd_chooseUnblock() {
     this.confirmUnblock(window, "chooseUnblock");
   },
 
@@ -293,16 +238,18 @@ function DownloadsPlacesView(aRichListBo
   }, true);
   // Resizing the window may change items visibility.
   window.addEventListener("resize", () => {
     this._ensureVisibleElementsAreActive();
   }, true);
 }
 
 DownloadsPlacesView.prototype = {
+  __proto__: DownloadsViewUI.BaseView.prototype,
+
   get associatedElement() {
     return this._richlistbox;
   },
 
   get active() {
     return this._active;
   },
   set active(val) {
@@ -571,38 +518,23 @@ DownloadsPlacesView.prototype = {
       case "downloadsCmd_openReferrer":
       case "downloadShowMenuItem":
         return this._richlistbox.selectedItems.length == 1;
       case "cmd_selectAll":
         return true;
       case "cmd_paste":
         return this._canDownloadClipboardURL();
       case "downloadsCmd_clearDownloads":
-        return this._canClearDownloads();
+        return this.canClearDownloads(this._richlistbox);
       default:
         return Array.every(this._richlistbox.selectedItems,
                            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._richlistbox.lastChild; elt; elt = elt.previousSibling) {
-      // 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 = Array.map(this._richlistbox.selectedItems,
                          element => element._shell.download.source.url);
 
     Cc["@mozilla.org/widget/clipboardhelper;1"]
       .getService(Ci.nsIClipboardHelper)
       .copyString(urls.join("\n"));
   },
--- a/browser/components/downloads/content/download.xml
+++ b/browser/components/downloads/content/download.xml
@@ -119,9 +119,25 @@
         <xul:label class="toolbarbutton-badge" xbl:inherits="value=badge" top="0" end="0" crop="none"/>
       </xul:stack>
       <xul:label class="toolbarbutton-text" crop="right" flex="1"
                  xbl:inherits="value=label,accesskey,crop,wrap"/>
       <xul:label class="toolbarbutton-multiline-text" flex="1"
                  xbl:inherits="xbl:text=label,accesskey,wrap"/>
     </content>
   </binding>
+
+  <binding id="download-subview-toolbarbutton"
+           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-retry" crop="end" xbl:inherits="value=retryLabel"/>
+        <xul:label class="status-text status-show" crop="end" xbl:inherits="value=showLabel"/>
+      </xul:vbox>
+      <xul:toolbarbutton anonid="button" class="action-button"/>
+    </content>
+  </binding>
 </bindings>
--- a/browser/components/downloads/content/downloads.css
+++ b/browser/components/downloads/content/downloads.css
@@ -202,8 +202,38 @@ richlistitem.download button {
 #downloadsPanel-multiView > .panel-viewcontainer > .panel-viewstack > .panel-mainview {
   max-width: unset;
 }
 
 /* Show the "show blocked info" button. */
 #downloadsPanel-mainView .download-state[state="8"] .downloadShowBlockedInfo {
   display: inline;
 }
+
+/* DownloadsSubview styles: */
+
+.subviewbutton.download {
+  -moz-binding: url("chrome://browser/content/downloads/download.xml#download-subview-toolbarbutton");
+}
+
+/* When a Download is hovered that has an [openLabel] attribute set, which means
+   that the file exists and can be opened, hide the status label.
+   When a Download is hovered - specifically on the secondary action button - that
+   has a [retryLabel] attribute set, which means that the file does not exist and
+   the download failed earlier, hide the status label. */
+.subviewbutton.download:hover:-moz-any([openLabel],[retryLabel][buttonover]) > .toolbarbutton-text > .status-full,
+/* When a Download is not hovered at all or the secondary action button is hovered,
+   hide the 'Open File' status label. */
+.subviewbutton.download:-moz-any(:not(:hover),[buttonover]) > .toolbarbutton-text > .status-open,
+/* When a Download is not hovered at all, or when it's hovered but specifically
+   not the secondary action button or when the [retryLabel] is not set, hide the
+   'Retry Downloads' label. */
+.subviewbutton.download:-moz-any(:not(:hover),:hover:not([buttonover]),:not([retryLabel])) > .toolbarbutton-text > .status-retry,
+/* When a Download is not hovered at all, or when it's hovered but specifically
+   not the secondary action button or when the file does not exist, hide the
+   'Open Containing Folder' label. */
+.subviewbutton.download:-moz-any(:not(:hover),:hover:not([buttonover]),:not([exists])) > .toolbarbutton-text > .status-show,
+/* When a Download is not hovered at all, hide the secondary action button. */
+.subviewbutton.download:not(:hover) > .action-button,
+/* Always hide the label of the secondary action button. */
+.subviewbutton.download > .action-button > .toolbarbutton-text {
+  display: none;
+}
--- a/browser/components/downloads/content/downloads.js
+++ b/browser/components/downloads/content/downloads.js
@@ -63,16 +63,18 @@
 "use strict";
 
 var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "DownloadsViewUI",
                                   "resource:///modules/DownloadsViewUI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "DownloadsSubview",
+                                  "resource:///modules/DownloadsSubview.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
@@ -99,16 +99,19 @@
 
         <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;"/>
       </menupopup>
 
       <photonpanelmultiview id="downloadsPanel-multiView"
                             mainViewId="downloadsPanel-mainView">
 
         <panelview id="downloadsPanel-mainView">
           <vbox class="panel-view-body-unscrollable">
             <richlistbox id="downloadsListBox"
@@ -176,13 +179,35 @@
             <button id="downloadsPanel-blockedSubview-deleteButton"
                     class="downloadsPanelFooterButton"
                     oncommand="DownloadsBlockedSubview.confirmBlock();"
                     default="true"
                     flex="1"/>
           </hbox>
         </panelview>
 
+        <panelview id="PanelUI-downloads" class="PanelUI-subView">
+          <vbox class="panel-subview-body">
+            <toolbarbutton id="appMenu-library-downloads-show-button"
+                           class="subviewbutton subviewbutton-iconic"
+                           label="&cmd.showDownloads.label;"
+                           closemenu="none"
+                           oncommand="DownloadsSubview.onShowDownloads(this);"/>
+            <toolbarseparator/>
+            <toolbaritem id="panelMenu_downloadsMenu"
+                         orient="vertical"
+                         smoothscroll="false"
+                         flatList="true"
+                         tooltip="bhTooltip">
+              <!-- downloads menu items will go here -->
+            </toolbaritem>
+          </vbox>
+          <toolbarbutton id="PanelUI-downloadsMore"
+                         class="panel-subview-footer subviewbutton"
+                         label="&downloadsHistory.label;"
+                         oncommand="BrowserDownloadsUI(); CustomizableUI.hidePanelForNode(this);"/>
+        </panelview>
+
       </photonpanelmultiview>
 
     </panel>
   </popupset>
 </overlay>
--- 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',
+    'DownloadsSubview.jsm',
     'DownloadsTaskbar.jsm',
     'DownloadsViewUI.jsm',
 ]
 
 with Files('**'):
     BUG_COMPONENT = ('Firefox', 'Downloads Panel')
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -256,16 +256,19 @@ These should match what Safari and other
 
 <!ENTITY toolsMenu.label              "Tools">
 <!ENTITY toolsMenu.accesskey          "T">
 
 <!ENTITY keywordfield.label           "Add a Keyword for this Search…">
 <!ENTITY keywordfield.accesskey       "K">
 
 <!ENTITY downloads.label              "Downloads">
+<!-- LOCALIZATION NOTE (libraryDownloads.label): This label is similar to
+  -  downloads.label, but used in the Library panel. -->
+<!ENTITY libraryDownloads.label       "Downloads">
 <!ENTITY downloads.accesskey          "D">
 <!ENTITY downloads.commandkey         "j">
 <!ENTITY downloadsUnix.commandkey     "y">
 <!ENTITY addons.label                 "Add-ons">
 <!ENTITY addons.accesskey             "A">
 <!ENTITY addons.commandkey            "A">
 
 <!ENTITY webDeveloperMenu.label       "Web Developer">
--- a/browser/locales/en-US/chrome/browser/downloads/downloads.dtd
+++ b/browser/locales/en-US/chrome/browser/downloads/downloads.dtd
@@ -51,16 +51,17 @@
      cmd.showMac.accesskey):
      The show and showMac commands are never shown together, thus they can share
      the same access key (though the two access keys can also be different).
      -->
 <!ENTITY cmd.show.label                   "Open Containing Folder">
 <!ENTITY cmd.show.accesskey               "F">
 <!ENTITY cmd.showMac.label                "Show In Finder">
 <!ENTITY cmd.showMac.accesskey            "F">
+<!ENTITY cmd.showDownloads.label          "Show Downloads Folder">
 <!ENTITY cmd.retry.label                  "Retry">
 <!ENTITY cmd.goToDownloadPage.label       "Go To Download Page">
 <!ENTITY cmd.goToDownloadPage.accesskey   "G">
 <!ENTITY cmd.copyDownloadLink.label       "Copy Download Link">
 <!ENTITY cmd.copyDownloadLink.accesskey   "L">
 <!ENTITY cmd.removeFromHistory.label      "Remove From History">
 <!ENTITY cmd.removeFromHistory.accesskey  "e">
 <!ENTITY cmd.clearList2.label             "Clear Preview Panel">
--- a/browser/locales/en-US/chrome/browser/downloads/downloads.properties
+++ b/browser/locales/en-US/chrome/browser/downloads/downloads.properties
@@ -97,8 +97,22 @@ 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
+# LOCALIZATION NOTE (retryLabel):
+# Displayed when hovering a download which is able to be retried by users,
+# indicates that it's possible to download this file again.
+retryLabel=Retry Download
--- a/browser/themes/shared/customizableui/panelUI.inc.css
+++ b/browser/themes/shared/customizableui/panelUI.inc.css
@@ -2041,9 +2041,50 @@ photonpanelmultiview .cui-widget-panelvi
   overflow-x: visible;
   overflow-y: visible;
 }
 
 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 > .action-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 minus half of 4px top margin. */
+  margin: calc(1em - 2px) 0 0;
+  padding: 4px;
+}
+
+.subviewbutton.download[retryLabel] > .action-button {
+  list-style-image: url("chrome://browser/skin/reload.svg");
+}
+
+.subviewbutton.download:not([openLabel]):not([retryLabel]) > .action-button {
+  fill: GrayText;
+  opacity: .5;
+}
+
+.subviewbutton.download:-moz-any([openLabel],[retryLabel]) > .action-button@buttonStateHover@ {
+  background-color: var(--arrowpanel-dimmed-further);
+}
+
+.subviewbutton.download:-moz-any([openLabel],[retryLabel]) > .action-button@buttonStateActive@ {
+  background-color: var(--arrowpanel-dimmed-even-further);
+}
+
 /* END photon adjustments */
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
@@ -133,16 +133,17 @@
   skin/classic/browser/device-tablet.svg              (../shared/icons/device-tablet.svg)
   skin/classic/browser/device-desktop.svg             (../shared/icons/device-desktop.svg)
   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)
   skin/classic/browser/email-link.svg                 (../shared/icons/email-link.svg)
   skin/classic/browser/feed.svg                       (../shared/icons/feed.svg)
   skin/classic/browser/find.svg                       (../shared/icons/find.svg)
+  skin/classic/browser/folder.svg                     (../shared/icons/folder.svg)
   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-exit.svg            (../shared/icons/fullscreen-exit.svg)
   skin/classic/browser/history.svg                    (../shared/icons/history.svg)
   skin/classic/browser/home.svg                       (../shared/icons/home.svg)
   skin/classic/browser/library.svg                    (../shared/icons/library.svg)
   skin/classic/browser/library-bookmark-animation.svg (../shared/icons/library-bookmark-animation.svg)
--- a/browser/themes/shared/menupanel.inc.css
+++ b/browser/themes/shared/menupanel.inc.css
@@ -122,8 +122,15 @@ toolbarpaletteitem[place="palette"] > #b
 #appMenuRestoreLastSession {
   list-style-image: url("chrome://browser/skin/restore-session.svg");
 }
 
 #appMenuRecentlyClosedWindows {
   list-style-image: url(chrome://browser/skin/window.svg);
 }
 
+#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");
+}