Bug 1354532 - Part 1 - Refactor all relevant parts in allDownloadsViewOverlay.js into reusable components in DownloadsViewUI.jsm. r?paolo draft
authorMike de Boer <mdeboer@mozilla.com>
Tue, 11 Jul 2017 12:23:30 +0200
changeset 606719 beb010a98fdbd0bdb8c14cb0c19f856ed253b7a8
parent 606556 0e41d07a703f19224f60b01577b2cbb5708046c9
child 606720 b236c72b51e738c02073cbe6c79cd99564077eeb
push id67791
push usermdeboer@mozilla.com
push dateTue, 11 Jul 2017 10:33:03 +0000
reviewerspaolo
bugs1354532
milestone56.0a1
Bug 1354532 - Part 1 - Refactor all relevant parts in allDownloadsViewOverlay.js into reusable components in DownloadsViewUI.jsm. r?paolo 1. Moved all the generics from allDownloadsViewOverlay.js, like 'HistoryDownloadElementShell' to DownloadsViewUI.jsm. 2. Special mention: all the nsIController specific code was factored into a new class: 'DownloadsViewUI.DownloadsPlacesViewController' and can be used by multiple library views to control command handling and drag & drop. 3. Special mention: a base class called 'DownloadsViewUI.BaseDownloadsPlacesView' was introduced which implements the session downloads monitoring and places metadata cache so multiple library views can use them. MozReview-Commit-ID: 7txXWitygEG
browser/components/downloads/DownloadsViewUI.jsm
browser/components/downloads/content/allDownloadsViewOverlay.js
browser/components/downloads/content/allDownloadsViewOverlay.xul
--- a/browser/components/downloads/DownloadsViewUI.jsm
+++ b/browser/components/downloads/DownloadsViewUI.jsm
@@ -10,37 +10,420 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = [
   "DownloadsViewUI",
 ];
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
+Cu.import("resource://gre/modules/Services.jsm");
 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, "NetUtil",
+                                  "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+                                  "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
+                                  "resource:///modules/RecentWindow.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 
+const DESTINATION_FILE_URI_ANNO  = "downloads/destinationFileURI";
+const DOWNLOAD_META_DATA_ANNO    = "downloads/metaData";
+
 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.BaseDownloadsPlacesView = class {
+  constructor(window) {
+    this.init(window);
+  }
+
+  init(window, active = true) {
+    this.window = window;
+
+    // Map download URLs to download element shells regardless of their type
+    this._downloadElementsShellsForURI = new Map();
+
+    // Map download data items to their element shells.
+    this._viewItemsForDownloads = new WeakMap();
+
+    this.__cachedPlacesMetaData = null;
+
+    // Points to the last session download element. We keep track of this
+   // in order to keep all session downloads above past downloads.
+    this._lastSessionDownloadElement = null;
+
+    this._searchTerm = "";
+
+    this._active = active;
+
+    // Register as a downloads view. The places data will be initialized by
+    // the places setter.
+    this._downloadsData = DownloadsCommon.getData(window.opener || window);
+    this._downloadsData.addView(this);
+
+    // Get the Download button out of the attention state since we're about to
+    // view all downloads.
+    DownloadsCommon.getIndicatorData(window).attention = DownloadsCommon.ATTENTION_NONE;
+  }
+
+  destructor() {
+    this._downloadsData.removeView(this);
+  }
+
+  /**
+   * This cache exists in order to optimize the load of the Downloads View, when
+   * Places annotations for history downloads must be read. In fact, annotations
+   * are stored in a single table, and reading all of them at once is much more
+   * efficient than an individual query.
+   *
+   * When this property is first requested, it reads the annotations for all the
+   * history downloads and stores them indefinitely.
+   *
+   * The historical annotations are not expected to change for the duration of
+   * the session, except in the case where a session download is running for the
+   * same URI as a history download. To ensure we don't use stale data, URIs
+   * corresponding to session downloads are permanently removed from the cache.
+   * This is a very small mumber compared to history downloads.
+   *
+   * This property returns a Map from each download source URI found in Places
+   * annotations to an object with the format:
+   *
+   * { targetFileSpec, state, endTime, fileSize, ... }
+   *
+   * The targetFileSpec property is the value of "downloads/destinationFileURI",
+   * while the other properties are taken from "downloads/metaData". Any of the
+   * properties may be missing from the object.
+   */
+  get _cachedPlacesMetaData() {
+    if (!this.__cachedPlacesMetaData) {
+      this.__cachedPlacesMetaData = new Map();
+
+      // Read the metadata annotations first, but ignore invalid JSON.
+      for (let result of PlacesUtils.annotations.getAnnotationsWithName(
+                                                 DOWNLOAD_META_DATA_ANNO)) {
+        try {
+          this.__cachedPlacesMetaData.set(result.uri.spec,
+                                          JSON.parse(result.annotationValue));
+        } catch (ex) {}
+      }
+
+      // Add the target file annotations to the metadata.
+      for (let result of PlacesUtils.annotations.getAnnotationsWithName(
+                                                 DESTINATION_FILE_URI_ANNO)) {
+        let metaData = this.__cachedPlacesMetaData.get(result.uri.spec);
+        if (!metaData) {
+          metaData = {};
+          this.__cachedPlacesMetaData.set(result.uri.spec, metaData);
+        }
+        metaData.targetFileSpec = result.annotationValue;
+      }
+    }
+
+    return this.__cachedPlacesMetaData;
+  }
+
+  /**
+   * Reads current metadata from Places annotations for the specified URI, and
+   * returns an object with the format:
+   *
+   * { targetFileSpec, state, endTime, fileSize, ... }
+   *
+   * The targetFileSpec property is the value of "downloads/destinationFileURI",
+   * while the other properties are taken from "downloads/metaData". Any of the
+   * properties may be missing from the object.
+   */
+  _getPlacesMetaDataFor(spec) {
+    let metaData = {};
+
+    try {
+      let uri = NetUtil.newURI(spec);
+      try {
+        metaData = JSON.parse(PlacesUtils.annotations.getPageAnnotation(
+                                          uri, DOWNLOAD_META_DATA_ANNO));
+      } catch (ex) {}
+      metaData.targetFileSpec = PlacesUtils.annotations.getPageAnnotation(
+                                            uri, DESTINATION_FILE_URI_ANNO);
+    } catch (ex) {}
+
+    return metaData;
+  }
+
+  /**
+   * Given a data item for a session download, or a places node for a past
+   * download, updates the view as necessary.
+   *  1. If the given data is a places node, we check whether there are any
+   *     elements for the same download url. If there are, then we just reset
+   *     their places node. Otherwise we add a new download element.
+   *  2. If the given data is a data item, we first check if there's a history
+   *     download in the list that is not associated with a data item. If we
+   *     found one, we use it for the data item as well and reposition it
+   *     alongside the other session downloads. If we don't, then we go ahead
+   *     and create a new element for the download.
+   *
+   * @param [optional] sessionDownload
+   *        A Download object, or null for history downloads.
+   * @param [optional] aPlacesNode
+   *        The Places node for a history download, or null for session downloads.
+   * @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.
+   */
+  _addDownloadData(sessionDownload, aPlacesNode, aNewest = false,
+                   aDocumentFragment = 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);
+    }
+
+    // When a session download is attached to a shell, we ensure not to keep
+    // stale metadata around for the corresponding history download. This
+    // prevents stale state from being used if the view is rebuilt.
+    //
+    // Note that we will eagerly load the data in the cache at this point, even
+    // if we have seen no history download. The case where no history download
+    // will appear at all is rare enough in normal usage, so we can apply this
+    // simpler solution rather than keeping a list of cache items to ignore.
+    if (sessionDownload) {
+      this._cachedPlacesMetaData.delete(sessionDownload.source.url);
+    }
+
+    let newOrUpdatedShell = null;
+
+    // Trivial: if there are no shells for this download URI, we always
+    // need to create one.
+    let shouldCreateShell = shellsForURI.size == 0;
+
+    // However, if we do have shells for this download uri, there are
+    // few options:
+    // 1) There's only one shell and it's for a history download (it has
+    //    no data item). In this case, we update this shell and move it
+    //    if necessary
+    // 2) There are multiple shells, indicating multiple downloads for
+    //    the same download uri are running. In this case we create
+    //    another shell for the download (so we have one shell for each data
+    //    item).
+    //
+    // Note: If a cancelled session download is already in the list, and the
+    // download is retried, onDownloadAdded is called again for the same
+    // data item. Thus, we also check that we make sure we don't have a view item
+    // already.
+    if (!shouldCreateShell &&
+        sessionDownload && !this._viewItemsForDownloads.has(sessionDownload)) {
+      // If there's a past-download-only shell for this download-uri with no
+      // associated data item, use it for the new data item. Otherwise, go ahead
+      // and create another shell.
+      shouldCreateShell = true;
+      for (let shell of shellsForURI) {
+        if (!shell.sessionDownload) {
+          shouldCreateShell = false;
+          shell.sessionDownload = sessionDownload;
+          newOrUpdatedShell = shell;
+          this._viewItemsForDownloads.set(sessionDownload, shell);
+          break;
+        }
+      }
+    }
+
+    if (shouldCreateShell) {
+      // If we are adding a new history download here, it means there is no
+      // associated session download, thus we must read the Places metadata,
+      // because it will not be obscured by the session download.
+      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);
+      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
+      // at least one download element shell already. There are two cases:
+      // 1) There are one or more download element shells for this source URI,
+      //    each with an associated session download. We update the Places node
+      //    because we may need it later, but we don't need to read the Places
+      //    metadata until the last session download is removed.
+      // 2) Occasionally, we may receive a duplicate notification for a history
+      //    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);
+        }
+        shell.element._placesNode = aPlacesNode;
+      }
+    }
+
+    if (newOrUpdatedShell) {
+      if (aNewest) {
+        this._richlistbox.insertBefore(newOrUpdatedShell.element,
+                                       this._richlistbox.firstChild);
+        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);
+        this._lastSessionDownloadElement = newOrUpdatedShell.element;
+      } else {
+        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();
+      this.window.goUpdateCommand("downloadsCmd_clearDownloads");
+    }
+  }
+
+  _removeHistoryDownloadFromView(aPlacesNode) {
+    let downloadURI = aPlacesNode.uri;
+    let shellsForURI = this._downloadElementsShellsForURI.get(downloadURI);
+    if (shellsForURI) {
+      for (let shell of shellsForURI) {
+        if (shell.sessionDownload) {
+          shell.historyDownload = null;
+        } else {
+          this._removeElement(shell.element);
+          shellsForURI.delete(shell);
+          if (shellsForURI.size == 0)
+            this._downloadElementsShellsForURI.delete(downloadURI);
+        }
+      }
+    }
+  }
+
+  _removeSessionDownloadFromView(download) {
+    let shells = this._downloadElementsShellsForURI
+                     .get(download.source.url);
+    if (shells.size == 0) {
+      throw new Error("Should have had at leaat one shell for this uri");
+    }
+
+    let shell = this._viewItemsForDownloads.get(download);
+    if (!shells.has(shell)) {
+      throw new Error("Missing download element shell in shells list for url");
+    }
+
+    // If there's more than one item for this download uri, we can let the
+    // view item for this this particular data item go away.
+    // If there's only one item for this download uri, we should only
+    // keep it if it is associated with a history download.
+    if (shells.size > 1 || !shell.historyDownload) {
+      this._removeElement(shell.element);
+      shells.delete(shell);
+      if (shells.size == 0) {
+        this._downloadElementsShellsForURI.delete(download.source.url);
+      }
+    } else {
+      // We have one download element shell containing both a session download
+      // and a history download, and we are now removing the session download.
+      // Previously, we did not use the Places metadata because it was obscured
+      // by the session download. Since this is no longer the case, we have to
+      // read the latest metadata before removing the session download.
+      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);
+      }
+    }
+  }
+
+  get searchTerm() {
+    return this._searchTerm;
+  }
+
+  set searchTerm(aValue) {
+    if (this._searchTerm != aValue) {
+      for (let element of this._richlistbox.childNodes) {
+        element.hidden = !element._shell.matchesSearchTerm(aValue);
+      }
+      this._ensureVisibleElementsAreActive();
+    }
+    return this._searchTerm = aValue;
+  }
+
+  onDataLoadStarting() {}
+  onDataLoadCompleted() {
+    this._ensureInitialSelection();
+  }
+
+  onDownloadAdded(download, newest) {
+    this._addDownloadData(download, null, newest);
+  }
+
+  onDownloadStateChanged(download) {
+    this._viewItemsForDownloads.get(download).onStateChanged();
+  }
+
+  onDownloadChanged(download) {
+    this._viewItemsForDownloads.get(download).onChanged();
+  }
+
+  onDownloadRemoved(download) {
+    this._removeSessionDownloadFromView(download);
+  }
+};
+
 /**
  * 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.
  *
@@ -385,8 +768,621 @@ this.DownloadsViewUI.DownloadElementShel
       this.download.cancel();
     }
   },
 
   downloadsCmd_confirmBlock() {
     this.download.confirmBlock().catch(Cu.reportError);
   },
 };
+
+/**
+ * Represents a download from the browser history. It implements part of the
+ * interface of the Download object.
+ *
+ * @param aPlacesNode
+ *        The Places node from which the history download should be initialized.
+ * @param global
+ *        The global object where this object can find the `DownloadURL` method.
+ */
+this.DownloadsViewUI.HistoryDownload = function(aPlacesNode, global) {
+  // TODO (bug 829201): history downloads should get the referrer from Places.
+  this.source = {
+    url: aPlacesNode.uri,
+  };
+  this.target = {
+    path: undefined,
+    exists: false,
+    size: undefined,
+  };
+
+  // In case this download cannot obtain its end time from the Places metadata,
+  // use the time from the Places node, that is the start time of the download.
+  this.endTime = aPlacesNode.time / 1000;
+
+  this.global = global;
+}
+
+this.DownloadsViewUI.HistoryDownload.prototype = {
+  /**
+   * Pushes information from Places metadata into this object.
+   */
+  updateFromMetaData(metaData) {
+    try {
+      this.target.path = Cc["@mozilla.org/network/protocol;1?name=file"]
+                           .getService(Ci.nsIFileProtocolHandler)
+                           .getFileFromURLSpec(metaData.targetFileSpec).path;
+    } catch (ex) {
+      this.target.path = undefined;
+    }
+
+    if ("state" in metaData) {
+      this.succeeded = metaData.state == DownloadsCommon.DOWNLOAD_FINISHED;
+      this.canceled = metaData.state == DownloadsCommon.DOWNLOAD_CANCELED ||
+                      metaData.state == DownloadsCommon.DOWNLOAD_PAUSED;
+      this.endTime = metaData.endTime;
+
+      // Recreate partial error information from the state saved in history.
+      if (metaData.state == DownloadsCommon.DOWNLOAD_FAILED) {
+        this.error = { message: "History download failed." };
+      } else if (metaData.state == DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL) {
+        this.error = { becauseBlockedByParentalControls: true };
+      } else if (metaData.state == DownloadsCommon.DOWNLOAD_DIRTY) {
+        this.error = {
+          becauseBlockedByReputationCheck: true,
+          reputationCheckVerdict: metaData.reputationCheckVerdict || "",
+        };
+      } else {
+        this.error = null;
+      }
+
+      // Normal history downloads are assumed to exist until the user interface
+      // is refreshed, at which point these values may be updated.
+      this.target.exists = true;
+      this.target.size = metaData.fileSize;
+    } else {
+      // Metadata might be missing from a download that has started but hasn't
+      // stopped already. Normally, this state is overridden with the one from
+      // the corresponding in-progress session download. But if the browser is
+      // terminated abruptly and additionally the file with information about
+      // in-progress downloads is lost, we may end up using this state. We use
+      // the failed state to allow the download to be restarted.
+      //
+      // On the other hand, if the download is missing the target file
+      // annotation as well, it is just a very old one, and we can assume it
+      // succeeded.
+      this.succeeded = !this.target.path;
+      this.error = this.target.path ? { message: "Unstarted download." } : null;
+      this.canceled = false;
+
+      // These properties may be updated if the user interface is refreshed.
+      this.target.exists = false;
+      this.target.size = undefined;
+    }
+  },
+
+  /**
+   * History downloads are never in progress.
+   */
+  stopped: true,
+
+  /**
+   * No percentage indication is shown for history downloads.
+   */
+  hasProgress: false,
+
+  /**
+   * History downloads cannot be restarted using their partial data, even if
+   * they are indicated as paused in their Places metadata. The only way is to
+   * use the information from a persisted session download, that will be shown
+   * instead of the history download. In case this session download is not
+   * available, we show the history download as canceled, not paused.
+   */
+  hasPartialData: false,
+
+  /**
+   * This method mimicks the "start" method of session downloads, and is called
+   * when the user retries a history download.
+   *
+   * At present, we always ask the user for a new target path when retrying a
+   * history download. In the future we may consider reusing the known target
+   * path if the folder still exists and the file name is not already used,
+   * except when the user preferences indicate that the target path should be
+   * requested every time a new download is started.
+   */
+  start() {
+    let browserWin = RecentWindow.getMostRecentBrowserWindow();
+    let initiatingDoc = browserWin ? browserWin.document : this.global.document;
+
+    // Do not suggest a file name if we don't know the original target.
+    let leafName = this.target.path ? OS.Path.basename(this.target.path) : null;
+    this.global.DownloadURL(this.source.url, leafName, initiatingDoc);
+
+    return Promise.resolve();
+  },
+
+  /**
+   * This method mimicks the "refresh" method of session downloads, except that
+   * it cannot notify that the data changed to the Downloads View.
+   */
+  async refresh() {
+    try {
+      this.target.size = (await OS.File.stat(this.target.path)).size;
+      this.target.exists = true;
+    } catch (ex) {
+      // We keep the known file size from the metadata, if any.
+      this.target.exists = false;
+    }
+  },
+};
+
+/**
+ * A download element shell is responsible for handling the commands and the
+ * displayed data for a single download view element.
+ *
+ * The shell may contain a session download, a history download, or both.  When
+ * both a history and a session download are present, the session download gets
+ * priority and its information is displayed.
+ *
+ * On construction, a new richlistitem is created, and can be accessed through
+ * the |element| getter. The shell doesn't insert the item in a richlistbox, the
+ * caller must do it and remove the element when it's no longer needed.
+ *
+ * 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.
+ */
+this.DownloadsViewUI.HistoryDownloadElementShell = function(aSessionDownload, aHistoryDownload, aWindow) {
+  this.element = aWindow.document.createElement("richlistitem");
+  this.element._shell = this;
+
+  this.element.classList.add("download");
+  this.element.classList.add("download-state");
+
+  if (aSessionDownload) {
+    this.sessionDownload = aSessionDownload;
+  }
+  if (aHistoryDownload) {
+    this.historyDownload = aHistoryDownload;
+  }
+
+  this.window = aWindow;
+  this.controller = new DownloadsViewUI.DownloadsPlacesViewController(this, aWindow);
+}
+
+this.DownloadsViewUI.HistoryDownloadElementShell.prototype = {
+  __proto__: this.DownloadsViewUI.DownloadElementShell.prototype,
+
+  /**
+   * Manages the "active" state of the shell.  By default all the shells without
+   * a session download are inactive, thus their UI is not updated.  They must
+   * be activated when entering the visible area.  Session downloads are always
+   * active.
+   */
+  ensureActive() {
+    if (!this._active) {
+      this._active = true;
+      this.element.setAttribute("active", true);
+      this._updateUI();
+    }
+  },
+  get active() {
+    return !!this._active;
+  },
+
+  /**
+   * Overrides the base getter to return the Download or HistoryDownload object
+   * for displaying information and executing commands in the user interface.
+   */
+  get download() {
+    return this._sessionDownload || this._historyDownload;
+  },
+
+  _sessionDownload: null,
+  get sessionDownload() {
+    return this._sessionDownload;
+  },
+  set sessionDownload(aValue) {
+    if (this._sessionDownload != aValue) {
+      if (!aValue && !this._historyDownload) {
+        throw new Error("Should always have either a Download or a HistoryDownload");
+      }
+
+      this._sessionDownload = aValue;
+
+      this.ensureActive();
+      this._updateUI();
+    }
+    return aValue;
+  },
+
+  _historyDownload: null,
+  get historyDownload() {
+    return this._historyDownload;
+  },
+  set historyDownload(aValue) {
+    if (this._historyDownload != aValue) {
+      if (!aValue && !this._sessionDownload) {
+        throw new Error("Should always have either a Download or a HistoryDownload");
+      }
+
+      this._historyDownload = aValue;
+
+      // We don't need to update the UI if we had a session data item, because
+      // the places information isn't used in this case.
+      if (!this._sessionDownload) {
+        this._updateUI();
+      }
+    }
+    return aValue;
+  },
+
+  _updateUI() {
+    // There is nothing to do if the item has always been invisible.
+    if (!this.active) {
+      return;
+    }
+
+    // Since the state changed, we may need to check the target file again.
+    this._targetFileChecked = false;
+
+    this._updateState();
+  },
+
+  onStateChanged() {
+    this._updateState();
+
+    if (this.element.selected) {
+      this.window.DownloadsView.goUpdateCommands();
+    } else {
+      // If a state change occurs in an item that is not currently selected,
+      // this is the only command that may be affected.
+      this.window.goUpdateCommand("downloadsCmd_clearDownloads");
+    }
+  },
+
+  onChanged() {
+    // 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);
+    this._updateProgress();
+  },
+
+  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._sessionDownload && 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;
+      case "downloadsCmd_cancel":
+        return !!this._sessionDownload;
+    }
+    return DownloadsViewUI.DownloadElementShell.prototype
+                          .isCommandEnabled.call(this, aCommand);
+  },
+
+  doCommand(aCommand) {
+    if (DownloadsViewUI.isCommandName(aCommand)) {
+      this[aCommand]();
+    }
+  },
+
+  downloadsCmd_open() {
+    let file = new FileUtils.File(this.download.target.path);
+    DownloadsCommon.openDownloadedFile(file, null, this.window);
+  },
+
+  downloadsCmd_show() {
+    let file = new FileUtils.File(this.download.target.path);
+    DownloadsCommon.showDownloadedFile(file);
+  },
+
+  downloadsCmd_openReferrer() {
+    this.window.openURL(this.download.source.referrer);
+  },
+
+  cmd_delete() {
+    if (this._sessionDownload) {
+      DownloadsCommon.removeAndFinalizeDownload(this.download);
+    }
+    if (this._historyDownload) {
+      PlacesUtils.history.remove(this.download.source.url);
+    }
+  },
+
+  downloadsCmd_unblock() {
+    this.confirmUnblock(this.window, "unblock");
+  },
+
+  downloadsCmd_chooseUnblock() {
+    this.confirmUnblock(this.window, "chooseUnblock");
+  },
+
+  downloadsCmd_chooseOpen() {
+    this.confirmUnblock(this.window, "chooseOpen");
+  },
+
+  // Returns whether or not the download handled by this shell should
+  // show up in the search results for the given term.  Both the display
+  // name for the download and the url are searched.
+  matchesSearchTerm(aTerm) {
+    if (!aTerm) {
+      return true;
+    }
+    aTerm = aTerm.toLowerCase();
+    return this.displayName.toLowerCase().includes(aTerm) ||
+           this.download.source.url.toLowerCase().includes(aTerm);
+  },
+
+  // Handles return keypress on the element (the keypress listener is
+  // set in the DownloadsPlacesView object).
+  doDefaultCommand() {
+    let command = this.currentDefaultCommandName;
+    if (command && this.isCommandEnabled(command)) {
+      this.doCommand(command);
+    }
+  },
+
+  /**
+   * This method is called by the outer download view, after the controller
+   * commands have already been updated. In case we did not check for the
+   * existence of the target file already, we can do it now and then update
+   * the commands as needed.
+   */
+  onSelect() {
+    if (!this.active) {
+      return;
+    }
+
+    // If this is a history download for which no target file information is
+    // available, we cannot retrieve information about the target file.
+    if (!this.download.target.path) {
+      return;
+    }
+
+    // Start checking for existence.  This may be done twice if onSelect is
+    // called again before the information is collected.
+    if (!this._targetFileChecked) {
+      this._checkTargetFileOnSelect().catch(Cu.reportError);
+    }
+  },
+
+  async _checkTargetFileOnSelect() {
+    try {
+      await this.download.refresh();
+    } finally {
+      // Do not try to check for existence again if this failed once.
+      this._targetFileChecked = true;
+    }
+
+    // Update the commands only if the element is still selected.
+    if (this.element.selected) {
+      this.window.DownloadsView.goUpdateCommands();
+    }
+
+    // Ensure the interface has been updated based on the new values. We need to
+    // do this because history downloads can't trigger update notifications.
+    this._updateProgress();
+  },
+};
+
+this.DownloadsViewUI.DownloadsPlacesViewController = class {
+  constructor(view, global) {
+    this.view = view;
+    this.global = global;
+  }
+
+  terminate() {}
+
+  supportsCommand(aCommand) {
+    // Firstly, determine if this is a command that we can handle.
+    if (!DownloadsViewUI.isCommandName(aCommand)) {
+      return false;
+    }
+    if (!(aCommand in this) &&
+        !(aCommand in DownloadsViewUI.HistoryDownloadElementShell.prototype)) {
+      return false;
+    }
+    // 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.global.document.activeElement == this.view._richlistbox;
+  }
+
+  isCommandEnabled(aCommand) {
+    switch (aCommand) {
+      case "cmd_copy":
+      case "downloadsCmd_openReferrer":
+      case "downloadShowMenuItem":
+        return this.view._richlistbox.selectedItems.length == 1;
+      case "cmd_selectAll":
+        return true;
+      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));
+    }
+  }
+
+  _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) {
+      // 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);
+
+    Cc["@mozilla.org/widget/clipboardhelper;1"]
+      .getService(Ci.nsIClipboardHelper)
+      .copyString(urls.join("\n"));
+  }
+
+  _getURLFromClipboardData() {
+    let trans = Cc["@mozilla.org/widget/transferable;1"].
+                createInstance(Ci.nsITransferable);
+    trans.init(null);
+
+    let flavors = ["text/x-moz-url", "text/unicode"];
+    flavors.forEach(trans.addDataFlavor);
+
+    Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard);
+
+    // Getting the data or creating the nsIURI might fail.
+    try {
+      let data = {};
+      trans.getAnyTransferData({}, data, {});
+      let [url, name] = data.value.QueryInterface(Ci.nsISupportsString)
+                            .data.split("\n");
+      if (url) {
+        return [NetUtil.newURI(url).spec, name];
+      }
+    } catch (ex) {}
+
+    return ["", ""];
+  }
+
+  _canDownloadClipboardURL() {
+    let [url /* ,name */] = this._getURLFromClipboardData();
+    return url != "";
+  }
+
+  _downloadURLFromClipboard() {
+    let [url, name] = this._getURLFromClipboardData();
+    let browserWin = RecentWindow.getMostRecentBrowserWindow();
+    let initiatingDoc = browserWin ? browserWin.document : this.global.document;
+    this.global.DownloadURL(url, name, initiatingDoc);
+  }
+
+  doCommand(aCommand) {
+    // Commands may be invoked with keyboard shortcuts even if disabled.
+    if (!this.isCommandEnabled(aCommand)) {
+      return;
+    }
+
+    // If this command is not selection-specific, execute it.
+    if (aCommand in this) {
+      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];
+    for (let element of selectedElements) {
+      element._shell.doCommand(aCommand);
+    }
+  }
+
+  // nsIController
+  onEvent() {}
+
+  cmd_copy() {
+    this._copySelectedDownloadsToClipboard();
+  }
+
+  cmd_selectAll() {
+    this.view._richlistbox.selectAll();
+  }
+
+  cmd_paste() {
+    this._downloadURLFromClipboard();
+  }
+
+  downloadsCmd_clearDownloads() {
+    this.view._downloadsData.removeFinished();
+    if (this.view.result) {
+      Cc["@mozilla.org/browser/download-history;1"]
+        .getService(Ci.nsIDownloadHistory)
+        .removeAllDownloads();
+    }
+    // There may be no selection or focus change as a result
+    // of these change, and we want the command updated immediately.
+    this.global.goUpdateCommand("downloadsCmd_clearDownloads");
+  }
+
+  buildContextMenu(contextMenu, 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.global.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;
+    if (!selectedItem) {
+      return;
+    }
+
+    let targetPath = selectedItem._shell.download.target.path;
+    if (!targetPath) {
+      return;
+    }
+
+    // We must check for existence synchronously because this is a DOM event.
+    let file = new FileUtils.File(targetPath);
+    if (!file.exists()) {
+      return;
+    }
+
+    let dt = aEvent.dataTransfer;
+    dt.mozSetDataAt("application/x-moz-file", file, 0);
+    let url = Services.io.newFileURI(file).spec;
+    dt.setData("text/uri-list", url);
+    dt.setData("text/plain", url);
+    dt.effectAllowed = "copyMove";
+    dt.addElement(selectedItem);
+  }
+
+  hasCachedLivemarkInfo() {
+    return false;
+  }
+};
--- a/browser/components/downloads/content/allDownloadsViewOverlay.js
+++ b/browser/components/downloads/content/allDownloadsViewOverlay.js
@@ -2,452 +2,62 @@
  * 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/. */
 /* eslint-env mozilla/browser-window */
 
 var { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
-XPCOMUtils.defineLazyModuleGetter(this, "DownloadUtils",
-                                  "resource://gre/modules/DownloadUtils.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");
-XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
-                                  "resource://gre/modules/NetUtil.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");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
-const DESTINATION_FILE_URI_ANNO  = "downloads/destinationFileURI";
-const DOWNLOAD_META_DATA_ANNO    = "downloads/metaData";
-
-/**
- * Represents a download from the browser history. It implements part of the
- * interface of the Download object.
- *
- * @param aPlacesNode
- *        The Places node from which the history download should be initialized.
- */
-function HistoryDownload(aPlacesNode) {
-  // TODO (bug 829201): history downloads should get the referrer from Places.
-  this.source = {
-    url: aPlacesNode.uri,
-  };
-  this.target = {
-    path: undefined,
-    exists: false,
-    size: undefined,
-  };
-
-  // In case this download cannot obtain its end time from the Places metadata,
-  // use the time from the Places node, that is the start time of the download.
-  this.endTime = aPlacesNode.time / 1000;
-}
-
-HistoryDownload.prototype = {
-  /**
-   * Pushes information from Places metadata into this object.
-   */
-  updateFromMetaData(metaData) {
-    try {
-      this.target.path = Cc["@mozilla.org/network/protocol;1?name=file"]
-                           .getService(Ci.nsIFileProtocolHandler)
-                           .getFileFromURLSpec(metaData.targetFileSpec).path;
-    } catch (ex) {
-      this.target.path = undefined;
-    }
-
-    if ("state" in metaData) {
-      this.succeeded = metaData.state == DownloadsCommon.DOWNLOAD_FINISHED;
-      this.canceled = metaData.state == DownloadsCommon.DOWNLOAD_CANCELED ||
-                      metaData.state == DownloadsCommon.DOWNLOAD_PAUSED;
-      this.endTime = metaData.endTime;
-
-      // Recreate partial error information from the state saved in history.
-      if (metaData.state == DownloadsCommon.DOWNLOAD_FAILED) {
-        this.error = { message: "History download failed." };
-      } else if (metaData.state == DownloadsCommon.DOWNLOAD_BLOCKED_PARENTAL) {
-        this.error = { becauseBlockedByParentalControls: true };
-      } else if (metaData.state == DownloadsCommon.DOWNLOAD_DIRTY) {
-        this.error = {
-          becauseBlockedByReputationCheck: true,
-          reputationCheckVerdict: metaData.reputationCheckVerdict || "",
-        };
-      } else {
-        this.error = null;
-      }
-
-      // Normal history downloads are assumed to exist until the user interface
-      // is refreshed, at which point these values may be updated.
-      this.target.exists = true;
-      this.target.size = metaData.fileSize;
-    } else {
-      // Metadata might be missing from a download that has started but hasn't
-      // stopped already. Normally, this state is overridden with the one from
-      // the corresponding in-progress session download. But if the browser is
-      // terminated abruptly and additionally the file with information about
-      // in-progress downloads is lost, we may end up using this state. We use
-      // the failed state to allow the download to be restarted.
-      //
-      // On the other hand, if the download is missing the target file
-      // annotation as well, it is just a very old one, and we can assume it
-      // succeeded.
-      this.succeeded = !this.target.path;
-      this.error = this.target.path ? { message: "Unstarted download." } : null;
-      this.canceled = false;
-
-      // These properties may be updated if the user interface is refreshed.
-      this.target.exists = false;
-      this.target.size = undefined;
-    }
-  },
-
-  /**
-   * History downloads are never in progress.
-   */
-  stopped: true,
-
-  /**
-   * No percentage indication is shown for history downloads.
-   */
-  hasProgress: false,
-
-  /**
-   * History downloads cannot be restarted using their partial data, even if
-   * they are indicated as paused in their Places metadata. The only way is to
-   * use the information from a persisted session download, that will be shown
-   * instead of the history download. In case this session download is not
-   * available, we show the history download as canceled, not paused.
-   */
-  hasPartialData: false,
-
-  /**
-   * This method mimicks the "start" method of session downloads, and is called
-   * when the user retries a history download.
-   *
-   * At present, we always ask the user for a new target path when retrying a
-   * history download. In the future we may consider reusing the known target
-   * path if the folder still exists and the file name is not already used,
-   * except when the user preferences indicate that the target path should be
-   * requested every time a new download is started.
-   */
-  start() {
-    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 leafName = this.target.path ? OS.Path.basename(this.target.path) : null;
-    DownloadURL(this.source.url, leafName, initiatingDoc);
-
-    return Promise.resolve();
-  },
-
-  /**
-   * This method mimicks the "refresh" method of session downloads, except that
-   * it cannot notify that the data changed to the Downloads View.
-   */
-  async refresh() {
-    try {
-      this.target.size = (await OS.File.stat(this.target.path)).size;
-      this.target.exists = true;
-    } catch (ex) {
-      // We keep the known file size from the metadata, if any.
-      this.target.exists = false;
-    }
-  },
-};
-
-/**
- * A download element shell is responsible for handling the commands and the
- * displayed data for a single download view element.
- *
- * The shell may contain a session download, a history download, or both.  When
- * both a history and a session download are present, the session download gets
- * priority and its information is displayed.
- *
- * On construction, a new richlistitem is created, and can be accessed through
- * the |element| getter. The shell doesn't insert the item in a richlistbox, the
- * caller must do it and remove the element when it's no longer needed.
- *
- * 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.
- */
-function HistoryDownloadElementShell(aSessionDownload, aHistoryDownload) {
-  this.element = document.createElement("richlistitem");
-  this.element._shell = this;
-
-  this.element.classList.add("download");
-  this.element.classList.add("download-state");
-
-  if (aSessionDownload) {
-    this.sessionDownload = aSessionDownload;
-  }
-  if (aHistoryDownload) {
-    this.historyDownload = aHistoryDownload;
-  }
-}
-
-HistoryDownloadElementShell.prototype = {
-  __proto__: DownloadsViewUI.DownloadElementShell.prototype,
-
-  /**
-   * Manages the "active" state of the shell.  By default all the shells without
-   * a session download are inactive, thus their UI is not updated.  They must
-   * be activated when entering the visible area.  Session downloads are always
-   * active.
-   */
-  ensureActive() {
-    if (!this._active) {
-      this._active = true;
-      this.element.setAttribute("active", true);
-      this._updateUI();
-    }
-  },
-  get active() {
-    return !!this._active;
-  },
-
-  /**
-   * Overrides the base getter to return the Download or HistoryDownload object
-   * for displaying information and executing commands in the user interface.
-   */
-  get download() {
-    return this._sessionDownload || this._historyDownload;
-  },
-
-  _sessionDownload: null,
-  get sessionDownload() {
-    return this._sessionDownload;
-  },
-  set sessionDownload(aValue) {
-    if (this._sessionDownload != aValue) {
-      if (!aValue && !this._historyDownload) {
-        throw new Error("Should always have either a Download or a HistoryDownload");
-      }
-
-      this._sessionDownload = aValue;
-
-      this.ensureActive();
-      this._updateUI();
-    }
-    return aValue;
-  },
-
-  _historyDownload: null,
-  get historyDownload() {
-    return this._historyDownload;
-  },
-  set historyDownload(aValue) {
-    if (this._historyDownload != aValue) {
-      if (!aValue && !this._sessionDownload) {
-        throw new Error("Should always have either a Download or a HistoryDownload");
-      }
-
-      this._historyDownload = aValue;
-
-      // We don't need to update the UI if we had a session data item, because
-      // the places information isn't used in this case.
-      if (!this._sessionDownload) {
-        this._updateUI();
-      }
-    }
-    return aValue;
-  },
-
-  _updateUI() {
-    // There is nothing to do if the item has always been invisible.
-    if (!this.active) {
-      return;
-    }
-
-    // Since the state changed, we may need to check the target file again.
-    this._targetFileChecked = false;
-
-    this._updateState();
-  },
-
-  onStateChanged() {
-    this._updateState();
-
-    if (this.element.selected) {
-      goUpdateDownloadCommands();
-    } else {
-      // If a state change occurs in an item that is not currently selected,
-      // this is the only command that may be affected.
-      goUpdateCommand("downloadsCmd_clearDownloads");
-    }
-  },
-
-  onChanged() {
-    // 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);
-    this._updateProgress();
-  },
-
-  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._sessionDownload && 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;
-      case "downloadsCmd_cancel":
-        return !!this._sessionDownload;
-    }
-    return DownloadsViewUI.DownloadElementShell.prototype
-                          .isCommandEnabled.call(this, aCommand);
-  },
-
-  doCommand(aCommand) {
-    if (DownloadsViewUI.isCommandName(aCommand)) {
-      this[aCommand]();
-    }
-  },
-
-  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);
-  },
-
-  cmd_delete() {
-    if (this._sessionDownload) {
-      DownloadsCommon.removeAndFinalizeDownload(this.download);
-    }
-    if (this._historyDownload) {
-      PlacesUtils.history.remove(this.download.source.url);
-    }
-  },
-
-  downloadsCmd_unblock() {
-    this.confirmUnblock(window, "unblock");
-  },
-
-  downloadsCmd_chooseUnblock() {
-    this.confirmUnblock(window, "chooseUnblock");
-  },
-
-  downloadsCmd_chooseOpen() {
-    this.confirmUnblock(window, "chooseOpen");
-  },
-
-  // Returns whether or not the download handled by this shell should
-  // show up in the search results for the given term.  Both the display
-  // name for the download and the url are searched.
-  matchesSearchTerm(aTerm) {
-    if (!aTerm) {
-      return true;
-    }
-    aTerm = aTerm.toLowerCase();
-    return this.displayName.toLowerCase().includes(aTerm) ||
-           this.download.source.url.toLowerCase().includes(aTerm);
-  },
-
-  // Handles return keypress on the element (the keypress listener is
-  // set in the DownloadsPlacesView object).
-  doDefaultCommand() {
-    let command = this.currentDefaultCommandName;
-    if (command && this.isCommandEnabled(command)) {
-      this.doCommand(command);
-    }
-  },
-
-  /**
-   * This method is called by the outer download view, after the controller
-   * commands have already been updated. In case we did not check for the
-   * existence of the target file already, we can do it now and then update
-   * the commands as needed.
-   */
-  onSelect() {
-    if (!this.active) {
-      return;
-    }
-
-    // If this is a history download for which no target file information is
-    // available, we cannot retrieve information about the target file.
-    if (!this.download.target.path) {
-      return;
-    }
-
-    // Start checking for existence.  This may be done twice if onSelect is
-    // called again before the information is collected.
-    if (!this._targetFileChecked) {
-      this._checkTargetFileOnSelect().catch(Cu.reportError);
-    }
-  },
-
-  async _checkTargetFileOnSelect() {
-    try {
-      await this.download.refresh();
-    } finally {
-      // Do not try to check for existence again if this failed once.
-      this._targetFileChecked = true;
-    }
-
-    // Update the commands only if the element is still selected.
-    if (this.element.selected) {
-      goUpdateDownloadCommands();
-    }
-
-    // Ensure the interface has been updated based on the new values. We need to
-    // do this because history downloads can't trigger update notifications.
-    this._updateProgress();
-  },
-};
+var gController = null;
 
 /**
  * Relays commands from the download.xml binding to the selected items.
  */
-const DownloadsView = {
+const DownloadsView = window.DownloadsView = {
   onDownloadCommand(event, command) {
     goDoCommand(command);
   },
 
+  getControllerForCommand(command) {
+    return gController;
+  },
+
+  goUpdateCommands() {
+    if (!gController)
+      return;
+
+    let updateCommandsForObject = object => {
+      for (let name in object) {
+        if (DownloadsViewUI.isCommandName(name)) {
+          goSetCommandEnabled(name, gController.isCommandEnabled(name));
+        }
+      }
+    };
+
+    updateCommandsForObject(gController.view);
+    updateCommandsForObject(DownloadsViewUI.HistoryDownloadElementShell.prototype);
+  },
+
+  goDoCommand(command) {
+    if (gController && gController.isCommandEnabled(command))
+      gController.doCommand(command);
+  },
+
   onDownloadClick() {},
 };
 
 /**
  * A Downloads Places View is a places view designed to show a places query
  * for history downloads alongside the session downloads.
  *
  * As we don't use the places controller, some methods implemented by other
@@ -456,307 +66,53 @@ const DownloadsView = {
  * A richlistitem in this view can represent either a past download or a session
  * download, or both. Session downloads are shown first in the view, and as long
  * as they exist they "collapses" their history "counterpart" (So we don't show two
  * items for every download).
  */
 function DownloadsPlacesView(aRichListBox, aActive = true) {
   this._richlistbox = aRichListBox;
   this._richlistbox._placesView = this;
-  window.controllers.insertControllerAt(0, this);
 
-  // Map download URLs to download element shells regardless of their type
-  this._downloadElementsShellsForURI = new Map();
-
-  // Map download data items to their element shells.
-  this._viewItemsForDownloads = new WeakMap();
-
-  // Points to the last session download element. We keep track of this
-  // in order to keep all session downloads above past downloads.
-  this._lastSessionDownloadElement = null;
+  gController = new DownloadsViewUI.DownloadsPlacesViewController(this, window);
+  window.controllers.insertControllerAt(0, gController);
 
-  this._searchTerm = "";
+  this.init(window, aActive);
 
-  this._active = aActive;
-
-  // Register as a downloads view. The places data will be initialized by
-  // the places setter.
   this._initiallySelectedElement = null;
-  this._downloadsData = DownloadsCommon.getData(window.opener || window);
-  this._downloadsData.addView(this);
-
-  // Get the Download button out of the attention state since we're about to
-  // view all downloads.
-  DownloadsCommon.getIndicatorData(window).attention = DownloadsCommon.ATTENTION_NONE;
 
   // Make sure to unregister the view if the window is closed.
   window.addEventListener("unload", () => {
-    window.controllers.removeController(this);
-    this._downloadsData.removeView(this);
-    this.result = null;
+    window.controllers.removeController(gController);
+    this.destructor();
+    this.result = gController = null;
   }, true);
   // Resizing the window may change items visibility.
   window.addEventListener("resize", () => {
     this._ensureVisibleElementsAreActive();
   }, true);
 }
 
 DownloadsPlacesView.prototype = {
+  __proto__: DownloadsViewUI.BaseDownloadsPlacesView.prototype,
+
   get associatedElement() {
     return this._richlistbox;
   },
 
   get active() {
     return this._active;
   },
   set active(val) {
     this._active = val;
     if (this._active)
       this._ensureVisibleElementsAreActive();
     return this._active;
   },
 
-  /**
-   * This cache exists in order to optimize the load of the Downloads View, when
-   * Places annotations for history downloads must be read. In fact, annotations
-   * are stored in a single table, and reading all of them at once is much more
-   * efficient than an individual query.
-   *
-   * When this property is first requested, it reads the annotations for all the
-   * history downloads and stores them indefinitely.
-   *
-   * The historical annotations are not expected to change for the duration of
-   * the session, except in the case where a session download is running for the
-   * same URI as a history download. To ensure we don't use stale data, URIs
-   * corresponding to session downloads are permanently removed from the cache.
-   * This is a very small mumber compared to history downloads.
-   *
-   * This property returns a Map from each download source URI found in Places
-   * annotations to an object with the format:
-   *
-   * { targetFileSpec, state, endTime, fileSize, ... }
-   *
-   * The targetFileSpec property is the value of "downloads/destinationFileURI",
-   * while the other properties are taken from "downloads/metaData". Any of the
-   * properties may be missing from the object.
-   */
-  get _cachedPlacesMetaData() {
-    if (!this.__cachedPlacesMetaData) {
-      this.__cachedPlacesMetaData = new Map();
-
-      // Read the metadata annotations first, but ignore invalid JSON.
-      for (let result of PlacesUtils.annotations.getAnnotationsWithName(
-                                                 DOWNLOAD_META_DATA_ANNO)) {
-        try {
-          this.__cachedPlacesMetaData.set(result.uri.spec,
-                                          JSON.parse(result.annotationValue));
-        } catch (ex) {}
-      }
-
-      // Add the target file annotations to the metadata.
-      for (let result of PlacesUtils.annotations.getAnnotationsWithName(
-                                                 DESTINATION_FILE_URI_ANNO)) {
-        let metaData = this.__cachedPlacesMetaData.get(result.uri.spec);
-        if (!metaData) {
-          metaData = {};
-          this.__cachedPlacesMetaData.set(result.uri.spec, metaData);
-        }
-        metaData.targetFileSpec = result.annotationValue;
-      }
-    }
-
-    return this.__cachedPlacesMetaData;
-  },
-  __cachedPlacesMetaData: null,
-
-  /**
-   * Reads current metadata from Places annotations for the specified URI, and
-   * returns an object with the format:
-   *
-   * { targetFileSpec, state, endTime, fileSize, ... }
-   *
-   * The targetFileSpec property is the value of "downloads/destinationFileURI",
-   * while the other properties are taken from "downloads/metaData". Any of the
-   * properties may be missing from the object.
-   */
-  _getPlacesMetaDataFor(spec) {
-    let metaData = {};
-
-    try {
-      let uri = NetUtil.newURI(spec);
-      try {
-        metaData = JSON.parse(PlacesUtils.annotations.getPageAnnotation(
-                                          uri, DOWNLOAD_META_DATA_ANNO));
-      } catch (ex) {}
-      metaData.targetFileSpec = PlacesUtils.annotations.getPageAnnotation(
-                                            uri, DESTINATION_FILE_URI_ANNO);
-    } catch (ex) {}
-
-    return metaData;
-  },
-
-  /**
-   * Given a data item for a session download, or a places node for a past
-   * download, updates the view as necessary.
-   *  1. If the given data is a places node, we check whether there are any
-   *     elements for the same download url. If there are, then we just reset
-   *     their places node. Otherwise we add a new download element.
-   *  2. If the given data is a data item, we first check if there's a history
-   *     download in the list that is not associated with a data item. If we
-   *     found one, we use it for the data item as well and reposition it
-   *     alongside the other session downloads. If we don't, then we go ahead
-   *     and create a new element for the download.
-   *
-   * @param [optional] sessionDownload
-   *        A Download object, or null for history downloads.
-   * @param [optional] aPlacesNode
-   *        The Places node for a history download, or null for session downloads.
-   * @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.
-   */
-  _addDownloadData(sessionDownload, aPlacesNode, aNewest = false,
-                   aDocumentFragment = 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);
-    }
-
-    // When a session download is attached to a shell, we ensure not to keep
-    // stale metadata around for the corresponding history download. This
-    // prevents stale state from being used if the view is rebuilt.
-    //
-    // Note that we will eagerly load the data in the cache at this point, even
-    // if we have seen no history download. The case where no history download
-    // will appear at all is rare enough in normal usage, so we can apply this
-    // simpler solution rather than keeping a list of cache items to ignore.
-    if (sessionDownload) {
-      this._cachedPlacesMetaData.delete(sessionDownload.source.url);
-    }
-
-    let newOrUpdatedShell = null;
-
-    // Trivial: if there are no shells for this download URI, we always
-    // need to create one.
-    let shouldCreateShell = shellsForURI.size == 0;
-
-    // However, if we do have shells for this download uri, there are
-    // few options:
-    // 1) There's only one shell and it's for a history download (it has
-    //    no data item). In this case, we update this shell and move it
-    //    if necessary
-    // 2) There are multiple shells, indicating multiple downloads for
-    //    the same download uri are running. In this case we create
-    //    another shell for the download (so we have one shell for each data
-    //    item).
-    //
-    // Note: If a cancelled session download is already in the list, and the
-    // download is retried, onDownloadAdded is called again for the same
-    // data item. Thus, we also check that we make sure we don't have a view item
-    // already.
-    if (!shouldCreateShell &&
-        sessionDownload && !this._viewItemsForDownloads.has(sessionDownload)) {
-      // If there's a past-download-only shell for this download-uri with no
-      // associated data item, use it for the new data item. Otherwise, go ahead
-      // and create another shell.
-      shouldCreateShell = true;
-      for (let shell of shellsForURI) {
-        if (!shell.sessionDownload) {
-          shouldCreateShell = false;
-          shell.sessionDownload = sessionDownload;
-          newOrUpdatedShell = shell;
-          this._viewItemsForDownloads.set(sessionDownload, shell);
-          break;
-        }
-      }
-    }
-
-    if (shouldCreateShell) {
-      // If we are adding a new history download here, it means there is no
-      // associated session download, thus we must read the Places metadata,
-      // because it will not be obscured by the session download.
-      let historyDownload = null;
-      if (aPlacesNode) {
-        let metaData = this._cachedPlacesMetaData.get(aPlacesNode.uri) ||
-                       this._getPlacesMetaDataFor(aPlacesNode.uri);
-        historyDownload = new HistoryDownload(aPlacesNode);
-        historyDownload.updateFromMetaData(metaData);
-      }
-      let shell = new HistoryDownloadElementShell(sessionDownload,
-                                                  historyDownload);
-      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
-      // at least one download element shell already. There are two cases:
-      // 1) There are one or more download element shells for this source URI,
-      //    each with an associated session download. We update the Places node
-      //    because we may need it later, but we don't need to read the Places
-      //    metadata until the last session download is removed.
-      // 2) Occasionally, we may receive a duplicate notification for a history
-      //    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 HistoryDownload(aPlacesNode);
-        }
-        shell.element._placesNode = aPlacesNode;
-      }
-    }
-
-    if (newOrUpdatedShell) {
-      if (aNewest) {
-        this._richlistbox.insertBefore(newOrUpdatedShell.element,
-                                       this._richlistbox.firstChild);
-        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);
-        this._lastSessionDownloadElement = newOrUpdatedShell.element;
-      } else {
-        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();
-      goUpdateCommand("downloadsCmd_clearDownloads");
-    }
-  },
-
   _removeElement(aElement) {
     // If the element was selected exclusively, select its next
     // sibling first, if not, try for previous sibling, if any.
     if ((aElement.nextSibling || aElement.previousSibling) &&
         this._richlistbox.selectedItems &&
         this._richlistbox.selectedItems.length == 1 &&
         this._richlistbox.selectedItems[0] == aElement) {
       this._richlistbox.selectItem(aElement.nextSibling ||
@@ -768,76 +124,16 @@ DownloadsPlacesView.prototype = {
     }
 
     this._richlistbox.removeItemFromSelection(aElement);
     this._richlistbox.removeChild(aElement);
     this._ensureVisibleElementsAreActive();
     goUpdateCommand("downloadsCmd_clearDownloads");
   },
 
-  _removeHistoryDownloadFromView(aPlacesNode) {
-    let downloadURI = aPlacesNode.uri;
-    let shellsForURI = this._downloadElementsShellsForURI.get(downloadURI);
-    if (shellsForURI) {
-      for (let shell of shellsForURI) {
-        if (shell.sessionDownload) {
-          shell.historyDownload = null;
-        } else {
-          this._removeElement(shell.element);
-          shellsForURI.delete(shell);
-          if (shellsForURI.size == 0)
-            this._downloadElementsShellsForURI.delete(downloadURI);
-        }
-      }
-    }
-  },
-
-  _removeSessionDownloadFromView(download) {
-    let shells = this._downloadElementsShellsForURI
-                     .get(download.source.url);
-    if (shells.size == 0) {
-      throw new Error("Should have had at leaat one shell for this uri");
-    }
-
-    let shell = this._viewItemsForDownloads.get(download);
-    if (!shells.has(shell)) {
-      throw new Error("Missing download element shell in shells list for url");
-    }
-
-    // If there's more than one item for this download uri, we can let the
-    // view item for this this particular data item go away.
-    // If there's only one item for this download uri, we should only
-    // keep it if it is associated with a history download.
-    if (shells.size > 1 || !shell.historyDownload) {
-      this._removeElement(shell.element);
-      shells.delete(shell);
-      if (shells.size == 0) {
-        this._downloadElementsShellsForURI.delete(download.source.url);
-      }
-    } else {
-      // We have one download element shell containing both a session download
-      // and a history download, and we are now removing the session download.
-      // Previously, we did not use the Places metadata because it was obscured
-      // by the session download. Since this is no longer the case, we have to
-      // read the latest metadata before removing the session download.
-      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);
-      }
-    }
-  },
-
   _ensureVisibleElementsAreActive() {
     if (!this.active || this._ensureVisibleTimer ||
         !this._richlistbox.firstChild) {
       return;
     }
 
     this._ensureVisibleTimer = setTimeout(() => {
       delete this._ensureVisibleTimer;
@@ -933,17 +229,17 @@ DownloadsPlacesView.prototype = {
       delete this._resultNode;
       delete this._result;
     }
 
     return val;
   },
 
   get selectedNodes() {
-      return this._richlistbox.selectedItems.filter(element => element._placesNode);
+    return this._richlistbox.selectedItems.filter(element => element._placesNode);
   },
 
   get selectedNode() {
     let selectedNodes = this.selectedNodes;
     return selectedNodes.length == 1 ? selectedNodes[0] : null;
   },
 
   get hasSelection() {
@@ -992,17 +288,17 @@ DownloadsPlacesView.prototype = {
       // _addDownloadData may not add new elements if there were already
       // data items in place.
       if (elementsToAppendFragment.firstChild) {
         this._appendDownloadsFragment(elementsToAppendFragment);
         this._ensureVisibleElementsAreActive();
       }
     }
 
-    goUpdateDownloadCommands();
+    DownloadsView.goUpdateCommands();
   },
 
   _appendDownloadsFragment(aDOMFragment) {
     // Workaround multiple reflows hang by removing the richlistbox
     // and adding it back when we're done.
 
     // Hack for bug 836283: reset xbl fields to their old values after the
     // binding is reattached to avoid breaking the selection state
@@ -1043,29 +339,16 @@ DownloadsPlacesView.prototype = {
   nodeMoved() {},
   nodeURIChanged() {},
   batching() {},
 
   get controller() {
     return this._richlistbox.controller;
   },
 
-  get searchTerm() {
-    return this._searchTerm;
-  },
-  set searchTerm(aValue) {
-    if (this._searchTerm != aValue) {
-      for (let element of this._richlistbox.childNodes) {
-        element.hidden = !element._shell.matchesSearchTerm(aValue);
-      }
-      this._ensureVisibleElementsAreActive();
-    }
-    return this._searchTerm = aValue;
-  },
-
   /**
    * When the view loads, we want to select the first item.
    * However, because session downloads, for which the data is loaded
    * asynchronously, always come first in the list, and because the list
    * may (or may not) already contain history downloads at that point, it
    * turns out that by the time we can select the first item, the user may
    * have already started using the view.
    * To make things even more complicated, in other cases, the places data
@@ -1090,205 +373,26 @@ DownloadsPlacesView.prototype = {
           this._richlistbox.selectedItem = firstDownloadElement;
           this._richlistbox.currentItem = firstDownloadElement;
           this._initiallySelectedElement = firstDownloadElement;
         });
       }
     }
   },
 
-  onDataLoadStarting() {},
-  onDataLoadCompleted() {
-    this._ensureInitialSelection();
-  },
-
-  onDownloadAdded(download, newest) {
-    this._addDownloadData(download, null, newest);
-  },
-
-  onDownloadStateChanged(download) {
-    this._viewItemsForDownloads.get(download).onStateChanged();
-  },
-
-  onDownloadChanged(download) {
-    this._viewItemsForDownloads.get(download).onChanged();
-  },
-
-  onDownloadRemoved(download) {
-    this._removeSessionDownloadFromView(download);
-  },
-
-  // nsIController
-  supportsCommand(aCommand) {
-    // Firstly, determine if this is a command that we can handle.
-    if (!DownloadsViewUI.isCommandName(aCommand)) {
-      return false;
-    }
-    if (!(aCommand in this) &&
-        !(aCommand in HistoryDownloadElementShell.prototype)) {
-      return false;
-    }
-    // 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" ||
-           document.activeElement == this._richlistbox;
-  },
-
-  // nsIController
-  isCommandEnabled(aCommand) {
-    switch (aCommand) {
-      case "cmd_copy":
-      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();
-      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 = this._richlistbox.selectedItems.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"].
-                createInstance(Ci.nsITransferable);
-    trans.init(null);
-
-    let flavors = ["text/x-moz-url", "text/unicode"];
-    flavors.forEach(trans.addDataFlavor);
-
-    Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard);
-
-    // Getting the data or creating the nsIURI might fail.
-    try {
-      let data = {};
-      trans.getAnyTransferData({}, data, {});
-      let [url, name] = data.value.QueryInterface(Ci.nsISupportsString)
-                            .data.split("\n");
-      if (url) {
-        return [NetUtil.newURI(url).spec, name];
-      }
-    } catch (ex) {}
-
-    return ["", ""];
-  },
-
-  _canDownloadClipboardURL() {
-    let [url /* ,name */] = this._getURLFromClipboardData();
-    return url != "";
-  },
-
-  _downloadURLFromClipboard() {
-    let [url, name] = this._getURLFromClipboardData();
-    let browserWin = RecentWindow.getMostRecentBrowserWindow();
-    let initiatingDoc = browserWin ? browserWin.document : document;
-    DownloadURL(url, name, initiatingDoc);
-  },
-
-  // nsIController
-  doCommand(aCommand) {
-    // Commands may be invoked with keyboard shortcuts even if disabled.
-    if (!this.isCommandEnabled(aCommand)) {
-      return;
-    }
-
-    // If this command is not selection-specific, execute it.
-    if (aCommand in this) {
-      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._richlistbox.selectedItems];
-    for (let element of selectedElements) {
-      element._shell.doCommand(aCommand);
-    }
-  },
-
-  // nsIController
-  onEvent() {},
-
-  cmd_copy() {
-    this._copySelectedDownloadsToClipboard();
-  },
-
-  cmd_selectAll() {
-    this._richlistbox.selectAll();
-  },
-
-  cmd_paste() {
-    this._downloadURLFromClipboard();
-  },
-
-  downloadsCmd_clearDownloads() {
-    this._downloadsData.removeFinished();
-    if (this.result) {
-      Cc["@mozilla.org/browser/download-history;1"]
-        .getService(Ci.nsIDownloadHistory)
-        .removeAllDownloads();
-    }
-    // There may be no selection or focus change as a result
-    // of these change, and we want the command updated immediately.
-    goUpdateCommand("downloadsCmd_clearDownloads");
-  },
-
   onContextMenu(aEvent) {
     let element = this._richlistbox.selectedItem;
     if (!element || !element._shell) {
       return false;
     }
 
     // Set the state attribute so that only the appropriate items are displayed.
     let contextMenu = document.getElementById("downloadsContextMenu");
     let download = element._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.
-      goUpdateCommand("downloadsCmd_pauseResume");
-    }
+    gController.buildContextMenu(contextMenu, download);
 
     return true;
   },
 
   onKeyPress(aEvent) {
     let selectedElements = this._richlistbox.selectedItems;
     if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
       // In the content tree, opening bookmarks by pressing return is only
@@ -1326,52 +430,28 @@ DownloadsPlacesView.prototype = {
     }
   },
 
   onScroll() {
     this._ensureVisibleElementsAreActive();
   },
 
   onSelect() {
-    goUpdateDownloadCommands();
+    DownloadsView.goUpdateCommands();
 
     let selectedElements = this._richlistbox.selectedItems;
     for (let elt of selectedElements) {
       if (elt._shell) {
         elt._shell.onSelect();
       }
     }
   },
 
   onDragStart(aEvent) {
-    // TODO Bug 831358: Support d&d for multiple selection.
-    // For now, we just drag the first element.
-    let selectedItem = this._richlistbox.selectedItem;
-    if (!selectedItem) {
-      return;
-    }
-
-    let targetPath = selectedItem._shell.download.target.path;
-    if (!targetPath) {
-      return;
-    }
-
-    // We must check for existence synchronously because this is a DOM event.
-    let file = new FileUtils.File(targetPath);
-    if (!file.exists()) {
-      return;
-    }
-
-    let dt = aEvent.dataTransfer;
-    dt.mozSetDataAt("application/x-moz-file", file, 0);
-    let url = Services.io.newFileURI(file).spec;
-    dt.setData("text/uri-list", url);
-    dt.setData("text/plain", url);
-    dt.effectAllowed = "copyMove";
-    dt.addElement(selectedItem);
+    gController.setDataTransfer(aEvent);
   },
 
   onDragOver(aEvent) {
     let types = aEvent.dataTransfer.types;
     if (types.includes("text/uri-list") ||
         types.includes("text/x-moz-url") ||
         types.includes("text/plain")) {
       aEvent.preventDefault();
@@ -1400,20 +480,8 @@ DownloadsPlacesView.prototype = {
 };
 
 for (let methodName of ["load", "applyFilter", "selectNode", "selectItems"]) {
   DownloadsPlacesView.prototype[methodName] = function() {
     throw new Error("|" + methodName +
                     "| is not implemented by the downloads view.");
   }
 }
-
-function goUpdateDownloadCommands() {
-  function updateCommandsForObject(object) {
-    for (let name in object) {
-      if (DownloadsViewUI.isCommandName(name)) {
-        goUpdateCommand(name);
-      }
-    }
-  }
-  updateCommandsForObject(DownloadsPlacesView.prototype);
-  updateCommandsForObject(HistoryDownloadElementShell.prototype);
-}
--- a/browser/components/downloads/content/allDownloadsViewOverlay.xul
+++ b/browser/components/downloads/content/allDownloadsViewOverlay.xul
@@ -44,46 +44,46 @@
                id="downloadsRichListBox" context="downloadsContextMenu"
                onscroll="return this._placesView.onScroll();"
                onkeypress="return this._placesView.onKeyPress(event);"
                ondblclick="return this._placesView.onDoubleClick(event);"
                oncontextmenu="return this._placesView.onContextMenu(event);"
                ondragstart="this._placesView.onDragStart(event);"
                ondragover="this._placesView.onDragOver(event);"
                ondrop="this._placesView.onDrop(event);"
-               onfocus="goUpdateDownloadCommands();"
+               onfocus="DownloadsView.goUpdateCommands();"
                onselect="this._placesView.onSelect();"
-               onblur="goUpdateDownloadCommands();"/>
+               onblur="DownloadsView.goUpdateCommands();"/>
 
   <commandset id="downloadCommands"
               commandupdater="true"
               events="focus,select,contextmenu"
-              oncommandupdate="goUpdateDownloadCommands();">
+              oncommandupdate="DownloadsView.goUpdateCommands();">
     <command id="downloadsCmd_pauseResume"
-             oncommand="goDoCommand('downloadsCmd_pauseResume')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_pauseResume')"/>
     <command id="downloadsCmd_cancel"
-             oncommand="goDoCommand('downloadsCmd_cancel')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_cancel')"/>
     <command id="downloadsCmd_unblock"
-             oncommand="goDoCommand('downloadsCmd_unblock')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_unblock')"/>
     <command id="downloadsCmd_chooseUnblock"
-             oncommand="goDoCommand('downloadsCmd_chooseUnblock')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_chooseUnblock')"/>
     <command id="downloadsCmd_chooseOpen"
-             oncommand="goDoCommand('downloadsCmd_chooseOpen')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_chooseOpen')"/>
     <command id="downloadsCmd_confirmBlock"
-             oncommand="goDoCommand('downloadsCmd_confirmBlock')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_confirmBlock')"/>
     <command id="downloadsCmd_open"
-             oncommand="goDoCommand('downloadsCmd_open')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_open')"/>
     <command id="downloadsCmd_show"
-             oncommand="goDoCommand('downloadsCmd_show')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_show')"/>
     <command id="downloadsCmd_retry"
-             oncommand="goDoCommand('downloadsCmd_retry')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_retry')"/>
     <command id="downloadsCmd_openReferrer"
-             oncommand="goDoCommand('downloadsCmd_openReferrer')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_openReferrer')"/>
     <command id="downloadsCmd_clearDownloads"
-             oncommand="goDoCommand('downloadsCmd_clearDownloads')"/>
+             oncommand="DownloadsView.goDoCommand('downloadsCmd_clearDownloads')"/>
   </commandset>
 
   <menupopup id="downloadsContextMenu" class="download-state">
     <menuitem command="downloadsCmd_pauseResume"
               class="downloadPauseMenuItem"
               label="&cmd.pause.label;"
               accesskey="&cmd.pause.accesskey;"/>
     <menuitem command="downloadsCmd_pauseResume"