Bug 1354532 - Part 1 - Implement a new Downloads view as part of the Library widget. r?Paolo draft
authorMike de Boer <mdeboer@mozilla.com>
Wed, 06 Sep 2017 16:23:00 +0200
changeset 659995 54af3eb6e08b07e8d88dbe9036a72812ae24d47a
parent 659873 c959327c6b75cd4930a6ea087583c38b805e7524
child 659996 7c93a4e34cdaf97832e62287ad145f6804e54b50
child 660012 2a8f35a6f41150e006052990596102697a46c502
push id78269
push usermdeboer@mozilla.com
push dateWed, 06 Sep 2017 14:28:20 +0000
reviewersPaolo
bugs1354532
milestone57.0a1
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");
+}