Bug 1381411 - Implement the DownloadHistoryList object. r=mak draft
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Fri, 04 Aug 2017 14:48:53 +0100
changeset 621177 e36faf63dadeba0c897b769cb7e14a2d01d0f628
parent 620494 fa1da3c0b200abbd9cfab3cab19962824314044e
child 640936 ce2196ddb1d5ead473a5d45e14d903bc9b493033
push id72297
push userpaolo.mozmail@amadzone.org
push dateFri, 04 Aug 2017 13:54:03 +0000
reviewersmak
bugs1381411
milestone57.0a1
Bug 1381411 - Implement the DownloadHistoryList object. r=mak MozReview-Commit-ID: 7CQr1m21rcB
browser/components/downloads/DownloadsCommon.jsm
browser/components/downloads/DownloadsViewUI.jsm
browser/components/downloads/content/allDownloadsViewOverlay.js
browser/components/downloads/content/downloads.js
browser/components/places/content/places.js
browser/components/places/tests/browser/browser_library_downloads.js
dom/workers/test/serviceworkers/browser_download.js
toolkit/components/jsdownloads/src/DownloadHistory.jsm
toolkit/components/jsdownloads/src/DownloadList.jsm
toolkit/components/jsdownloads/test/unit/common_test_Download.js
toolkit/components/jsdownloads/test/unit/head.js
toolkit/components/jsdownloads/test/unit/test_DownloadHistory.js
toolkit/components/jsdownloads/test/unit/test_DownloadList.js
toolkit/components/jsdownloads/test/unit/xpcshell.ini
--- a/browser/components/downloads/DownloadsCommon.jsm
+++ b/browser/components/downloads/DownloadsCommon.jsm
@@ -191,26 +191,32 @@ this.DownloadsCommon = {
    * Indicates whether we should show visual notification on the indicator
    * when a download event is triggered.
    */
   get animateNotifications() {
     return PrefObserver.animateNotifications;
   },
 
   /**
-   * Get access to one of the DownloadsData or PrivateDownloadsData objects,
-   * depending on the privacy status of the window in question.
+   * Get access to one of the DownloadsData, PrivateDownloadsData, or
+   * HistoryDownloadsData objects, depending on the privacy status of the
+   * specified window and on whether history downloads should be included.
    *
-   * @param aWindow
+   * @param window
    *        The browser window which owns the download button.
+   * @param [optional] history
+   *        True to include history downloads when the window is public.
    */
-  getData(aWindow) {
-    if (PrivateBrowsingUtils.isContentWindowPrivate(aWindow)) {
+  getData(window, history = false) {
+    if (PrivateBrowsingUtils.isContentWindowPrivate(window)) {
       return PrivateDownloadsData;
     }
+    if (history) {
+      return HistoryDownloadsData;
+    }
     return DownloadsData;
   },
 
   /**
    * Initializes the Downloads back-end and starts receiving events for both the
    * private and non-private downloads data objects.
    */
   initializeAllDataLinks() {
@@ -280,27 +286,16 @@ this.DownloadsCommon = {
         return DownloadsCommon.DOWNLOAD_PAUSED;
       }
       return DownloadsCommon.DOWNLOAD_CANCELED;
     }
     return DownloadsCommon.DOWNLOAD_NOTSTARTED;
   },
 
   /**
-   * Helper function required because the Downloads Panel and the Downloads View
-   * don't share the controller yet.
-   */
-  removeAndFinalizeDownload(download) {
-    Downloads.getList(Downloads.ALL)
-             .then(list => list.remove(download))
-             .then(() => download.finalize(true))
-             .catch(Cu.reportError);
-  },
-
-  /**
    * Given an iterable collection of Download objects, generates and returns
    * statistics about that collection.
    *
    * @param downloads An iterable collection of Download objects.
    *
    * @return Object whose properties are the generated statistics. Currently,
    *         we return the following properties:
    *
@@ -644,34 +639,44 @@ XPCOMUtils.defineLazyGetter(DownloadsCom
  * Retrieves the list of past and completed downloads from the underlying
  * Downloads API data, and provides asynchronous notifications allowing to
  * build a consistent view of the available data.
  *
  * Note that using this object does not automatically initialize the list of
  * downloads. This is useful to display a neutral progress indicator in
  * the main browser window until the autostart timeout elapses.
  *
- * Note that DownloadsData and PrivateDownloadsData are two equivalent singleton
- * objects, one accessing non-private downloads, and the other accessing private
- * ones.
+ * This powers the DownloadsData, PrivateDownloadsData, and HistoryDownloadsData
+ * singleton objects.
  */
-function DownloadsDataCtor(aPrivate) {
-  this._isPrivate = aPrivate;
+function DownloadsDataCtor({ isPrivate, isHistory } = {}) {
+  this._isPrivate = !!isPrivate;
 
   // Contains all the available Download objects and their integer state.
   this.oldDownloadStates = new Map();
 
+  // For the history downloads list we don't need to register this as a view,
+  // but we have to ensure that the DownloadsData object is initialized before
+  // we register more views. This ensures that the view methods of DownloadsData
+  // are invoked before those of views registered on HistoryDownloadsData,
+  // allowing the endTime property to be set correctly.
+  if (isHistory) {
+    DownloadsData.initializeDataLink();
+    this._promiseList = DownloadsData._promiseList
+                                     .then(() => DownloadHistory.getList());
+    return;
+  }
+
   // This defines "initializeDataLink" and "_promiseList" synchronously, then
   // continues execution only when "initializeDataLink" is called, allowing the
   // underlying data to be loaded only when actually needed.
   this._promiseList = (async () => {
     await new Promise(resolve => this.initializeDataLink = resolve);
-
-    let list = await Downloads.getList(this._isPrivate ? Downloads.PRIVATE
-                                                       : Downloads.PUBLIC);
+    let list = await Downloads.getList(isPrivate ? Downloads.PRIVATE
+                                                 : Downloads.PUBLIC);
     await list.addView(this);
     return list;
   })();
 }
 
 DownloadsDataCtor.prototype = {
   /**
    * Starts receiving events for current downloads.
@@ -705,17 +710,19 @@ DownloadsDataCtor.prototype = {
     return false;
   },
 
   /**
    * Asks the back-end to remove finished downloads from the list. This method
    * is only called after the data link has been initialized.
    */
   removeFinished() {
-    this._promiseList.then(list => list.removeFinished()).catch(Cu.reportError);
+    Downloads.getList(this._isPrivate ? Downloads.PRIVATE : Downloads.PUBLIC)
+             .then(list => list.removeFinished())
+             .catch(Cu.reportError);
     let indicatorData = this._isPrivate ? PrivateDownloadsIndicatorData
                                         : DownloadsIndicatorData;
     indicatorData.attention = DownloadsCommon.ATTENTION_NONE;
   },
 
   // Integration with the asynchronous Downloads back-end
 
   onDownloadAdded(download) {
@@ -830,22 +837,26 @@ DownloadsDataCtor.prototype = {
       browserWin.DownloadsIndicatorView.showEventNotification(aType);
       return;
     }
     this.panelHasShownBefore = true;
     browserWin.DownloadsPanel.showPanel();
   }
 };
 
+XPCOMUtils.defineLazyGetter(this, "HistoryDownloadsData", function() {
+  return new DownloadsDataCtor({ isHistory: true });
+});
+
 XPCOMUtils.defineLazyGetter(this, "PrivateDownloadsData", function() {
-  return new DownloadsDataCtor(true);
+  return new DownloadsDataCtor({ isPrivate: true });
 });
 
 XPCOMUtils.defineLazyGetter(this, "DownloadsData", function() {
-  return new DownloadsDataCtor(false);
+  return new DownloadsDataCtor();
 });
 
 // DownloadsViewPrototype
 
 /**
  * A prototype for an object that registers itself with DownloadsData as soon
  * as a view is registered with it.
  */
--- a/browser/components/downloads/DownloadsViewUI.jsm
+++ b/browser/components/downloads/DownloadsViewUI.jsm
@@ -20,16 +20,18 @@ Cu.import("resource://gre/modules/XPCOMU
 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, "OS",
                                   "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+                                  "resource://gre/modules/PlacesUtils.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_");
@@ -359,16 +361,18 @@ this.DownloadsViewUI.DownloadElementShel
       case "downloadsCmd_openReferrer":
         return !!this.download.source.referrer;
       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;
     }
     return false;
   },
 
   downloadsCmd_cancel() {
     // This is the correct way to avoid race conditions when cancelling.
     this.download.cancel().catch(() => {});
     this.download.removePartialData().catch(Cu.reportError);
@@ -385,9 +389,25 @@ this.DownloadsViewUI.DownloadElementShel
     } else {
       this.download.cancel();
     }
   },
 
   downloadsCmd_confirmBlock() {
     this.download.confirmBlock().catch(Cu.reportError);
   },
+
+  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 {
+        await PlacesUtils.history.remove(this.download.source.url);
+      } catch (ex) {
+        Cu.reportError(ex);
+      }
+      let list = await Downloads.getList(Downloads.ALL);
+      await list.remove(this.download);
+      await this.download.finalize(true);
+    })().catch(Cu.reportError);
+  },
 };
--- a/browser/components/downloads/content/allDownloadsViewOverlay.js
+++ b/browser/components/downloads/content/allDownloadsViewOverlay.js
@@ -2,377 +2,188 @@
  * 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, "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");
 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 onSessionDownloadChanged method.
+ * The caller is also responsible for forwarding status notifications, calling
+ * the onChanged method.
  *
- * @param [optional] aSessionDownload
- *        The session download, required if aHistoryDownload is not set.
- * @param [optional] aHistoryDownload
- *        The history download, required if aSessionDownload is not set.
+ * @param download
+ *        The Download object from the DownloadHistoryList.
  */
-function HistoryDownloadElementShell(aSessionDownload, aHistoryDownload) {
+function HistoryDownloadElementShell(download) {
+  this._download = download;
+
   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.
+   * Manages the "active" state of the shell.  By default all the shells are
+   * inactive, thus their UI is not updated.  They must be activated when
+   * entering the visible area.
    */
   ensureActive() {
     if (!this._active) {
       this._active = true;
       this.element.setAttribute("active", true);
-      this._updateUI();
+      this.onChanged();
     }
   },
   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;
-      if (aValue) {
-        this.sessionDownloadState = DownloadsCommon.stateOfDownload(aValue);
-      }
-
-      this.ensureActive();
-      this._updateUI();
-    }
-    return aValue;
+    return this._download;
   },
 
-  _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;
-    }
-
+  onStateChanged() {
     // 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");
     }
   },
 
-  onSessionDownloadChanged() {
-    let newState = DownloadsCommon.stateOfDownload(this.sessionDownload);
-    if (this.sessionDownloadState != newState) {
-      this.sessionDownloadState = newState;
+  onChanged() {
+    // There is nothing to do if the item has always been invisible.
+    if (!this.active) {
+      return;
+    }
+
+    let newState = DownloadsCommon.stateOfDownload(this.download);
+    if (this._downloadState !== newState) {
+      this._downloadState = newState;
       this.onStateChanged();
     }
 
     // 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();
   },
+  _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._sessionDownload && this.download.target.partFilePath) {
+        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;
-      case "downloadsCmd_cancel":
-        return !!this._sessionDownload;
     }
     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);
   },
 
-  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");
   },
 
@@ -416,37 +227,22 @@ HistoryDownloadElementShell.prototype = 
     // 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);
+      this.download.refresh().catch(Cu.reportError).then(() => {
+        // Do not try to check for existence again even if this failed.
+        this._targetFileChecked = true;
+      });
     }
   },
-
-  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();
-  },
 };
 
 /**
  * Relays commands from the download.xml binding to the selected items.
  */
 const DownloadsView = {
   onDownloadCommand(event, command) {
     goDoCommand(command);
@@ -467,34 +263,27 @@ const DownloadsView = {
  * 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.
+  // Map downloads 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;
-
   this._searchTerm = "";
 
   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 = DownloadsCommon.getData(window.opener || window, true);
   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", () => {
@@ -518,335 +307,16 @@ DownloadsPlacesView.prototype = {
   },
   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
-   *        Whether the download should be added at the top of the list.
-   * @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 ||
-                                   aElement.previousSibling);
-    }
-
-    if (this._lastSessionDownloadElement == aElement) {
-      this._lastSessionDownloadElement = aElement.previousSibling;
-    }
-
-    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;
@@ -892,173 +362,38 @@ DownloadsPlacesView.prototype = {
     }, 10);
   },
 
   _place: "",
   get place() {
     return this._place;
   },
   set place(val) {
-    // Don't reload everything if we don't have to.
     if (this._place == val) {
       // XXXmano: places.js relies on this behavior (see Bug 822203).
       this.searchTerm = "";
-      return val;
-    }
-
-    this._place = val;
-
-    let history = PlacesUtils.history;
-    let queries = { }, options = { };
-    history.queryStringToQueries(val, queries, { }, options);
-    if (!queries.value.length) {
-      queries.value = [history.getNewQuery()];
+    } else {
+      this._place = val;
     }
-
-    let result = history.executeQueries(queries.value, queries.value.length,
-                                        options.value);
-    result.addObserver(this);
-    return val;
-  },
-
-  _result: null,
-  get result() {
-    return this._result;
-  },
-  set result(val) {
-    if (this._result == val) {
-      return val;
-    }
-
-    if (this._result) {
-      this._result.removeObserver(this);
-      this._resultNode.containerOpen = false;
-    }
-
-    if (val) {
-      this._result = val;
-      this._resultNode = val.root;
-      this._resultNode.containerOpen = true;
-      this._ensureInitialSelection();
-    } else {
-      delete this._resultNode;
-      delete this._result;
-    }
-
-    return val;
   },
 
   get selectedNodes() {
       return Array.filter(this._richlistbox.selectedItems,
-                          element => element._placesNode);
+                          element => element._shell.download.placesNode);
   },
 
   get selectedNode() {
     let selectedNodes = this.selectedNodes;
     return selectedNodes.length == 1 ? selectedNodes[0] : null;
   },
 
   get hasSelection() {
     return this.selectedNodes.length > 0;
   },
 
-  containerStateChanged(aNode, aOldState, aNewState) {
-    this.invalidateContainer(aNode)
-  },
-
-  invalidateContainer(aContainer) {
-    if (aContainer != this._resultNode) {
-      throw new Error("Unexpected container node");
-    }
-    if (!aContainer.containerOpen) {
-      throw new Error("Root container for the downloads query cannot be closed");
-    }
-
-    let suppressOnSelect = this._richlistbox.suppressOnSelect;
-    this._richlistbox.suppressOnSelect = true;
-    try {
-      // Remove the invalidated history downloads from the list and unset the
-      // places node for data downloads.
-      // Loop backwards since _removeHistoryDownloadFromView may removeChild().
-      for (let i = this._richlistbox.childNodes.length - 1; i >= 0; --i) {
-        let element = this._richlistbox.childNodes[i];
-        if (element._placesNode) {
-          this._removeHistoryDownloadFromView(element._placesNode);
-        }
-      }
-    } finally {
-      this._richlistbox.suppressOnSelect = suppressOnSelect;
-    }
-
-    if (aContainer.childCount > 0) {
-      let elementsToAppendFragment = document.createDocumentFragment();
-      for (let i = 0; i < aContainer.childCount; i++) {
-        try {
-          this._addDownloadData(null, aContainer.getChild(i), false,
-                                elementsToAppendFragment);
-        } catch (ex) {
-          Cu.reportError(ex);
-        }
-      }
-
-      // _addDownloadData may not add new elements if there were already
-      // data items in place.
-      if (elementsToAppendFragment.firstChild) {
-        this._appendDownloadsFragment(elementsToAppendFragment);
-        this._ensureVisibleElementsAreActive();
-      }
-    }
-
-    goUpdateDownloadCommands();
-  },
-
-  _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
-    let xblFields = new Map();
-    for (let key of Object.getOwnPropertyNames(this._richlistbox)) {
-      let value = this._richlistbox[key];
-      xblFields.set(key, value);
-    }
-
-    let parentNode = this._richlistbox.parentNode;
-    let nextSibling = this._richlistbox.nextSibling;
-    parentNode.removeChild(this._richlistbox);
-    this._richlistbox.appendChild(aDOMFragment);
-    parentNode.insertBefore(this._richlistbox, nextSibling);
-
-    for (let [key, value] of xblFields) {
-      this._richlistbox[key] = value;
-    }
-  },
-
-  nodeInserted(aParent, aPlacesNode) {
-    this._addDownloadData(null, aPlacesNode);
-  },
-
-  nodeRemoved(aParent, aPlacesNode, aOldIndex) {
-    this._removeHistoryDownloadFromView(aPlacesNode);
-  },
-
-  nodeAnnotationChanged() {},
-  nodeIconChanged() {},
-  nodeTitleChanged() {},
-  nodeKeywordChanged() {},
-  nodeDateAddedChanged() {},
-  nodeLastModifiedChanged() {},
-  nodeHistoryDetailsChanged() {},
-  nodeTagsChanged() {},
-  sortingChanged() {},
-  nodeMoved() {},
-  nodeURIChanged() {},
-  batching() {},
-
   get controller() {
     return this._richlistbox.controller;
   },
 
   get searchTerm() {
     return this._searchTerm;
   },
   set searchTerm(aValue) {
@@ -1100,30 +435,118 @@ DownloadsPlacesView.prototype = {
           this._richlistbox.selectedItem = firstDownloadElement;
           this._richlistbox.currentItem = firstDownloadElement;
           this._initiallySelectedElement = firstDownloadElement;
         });
       }
     }
   },
 
+  /**
+   * DocumentFragment object that contains all the new elements added during a
+   * batch operation, or null if no batch is in progress.
+   *
+   * Since newest downloads are displayed at the top, elements are normally
+   * prepended to the fragment, and then the fragment is prepended to the list.
+   */
+  batchFragment: null,
+
+  onDownloadBatchStarting() {
+    this.batchFragment = document.createDocumentFragment();
+
+    this.oldSuppressOnSelect = this._richlistbox.suppressOnSelect;
+    this._richlistbox.suppressOnSelect = true;
+  },
+
   onDownloadBatchEnded() {
+    this._richlistbox.suppressOnSelect = this.oldSuppressOnSelect;
+    delete this.oldSuppressOnSelect;
+
+    if (this.batchFragment.childElementCount) {
+      this._prependBatchFragment();
+    }
+    this.batchFragment = null;
+
     this._ensureInitialSelection();
+    this._ensureVisibleElementsAreActive();
+    goUpdateDownloadCommands();
   },
 
-  onDownloadAdded(download) {
-    this._addDownloadData(download, null, true);
+  _prependBatchFragment() {
+    // 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
+    let xblFields = new Map();
+    for (let key of Object.getOwnPropertyNames(this._richlistbox)) {
+      let value = this._richlistbox[key];
+      xblFields.set(key, value);
+    }
+
+    let parentNode = this._richlistbox.parentNode;
+    let nextSibling = this._richlistbox.nextSibling;
+    parentNode.removeChild(this._richlistbox);
+    this._richlistbox.prepend(this.batchFragment);
+    parentNode.insertBefore(this._richlistbox, nextSibling);
+
+    for (let [key, value] of xblFields) {
+      this._richlistbox[key] = value;
+    }
+  },
+
+  onDownloadAdded(download, { insertBefore } = {}) {
+    let shell = new HistoryDownloadElementShell(download);
+    this._viewItemsForDownloads.set(download, shell);
+
+    // 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._richlistbox).prepend(shell.element);
+    }
+
+    if (this.searchTerm) {
+      shell.element.hidden = !shell.matchesSearchTerm(this.searchTerm);
+    }
+
+    // Don't update commands and visible elements during a batch change.
+    if (!this.batchFragment) {
+      this._ensureVisibleElementsAreActive();
+      goUpdateCommand("downloadsCmd_clearDownloads");
+    }
   },
 
   onDownloadChanged(download) {
-    this._viewItemsForDownloads.get(download).onSessionDownloadChanged();
+    this._viewItemsForDownloads.get(download).onChanged();
   },
 
   onDownloadRemoved(download) {
-    this._removeSessionDownloadFromView(download);
+    let element = this._viewItemsForDownloads.get(download).element;
+
+    // If the element was selected exclusively, select its next
+    // sibling first, if not, try for previous sibling, if any.
+    if ((element.nextSibling || element.previousSibling) &&
+        this._richlistbox.selectedItems &&
+        this._richlistbox.selectedItems.length == 1 &&
+        this._richlistbox.selectedItems[0] == element) {
+      this._richlistbox.selectItem(element.nextSibling ||
+                                   element.previousSibling);
+    }
+
+    this._richlistbox.removeItemFromSelection(element);
+    element.remove();
+
+    // Don't update commands and visible elements during a batch change.
+    if (!this.batchFragment) {
+      this._ensureVisibleElementsAreActive();
+      goUpdateCommand("downloadsCmd_clearDownloads");
+    }
   },
 
   // nsIController
   supportsCommand(aCommand) {
     // Firstly, determine if this is a command that we can handle.
     if (!DownloadsViewUI.isCommandName(aCommand)) {
       return false;
     }
@@ -1255,17 +678,17 @@ DownloadsPlacesView.prototype = {
   },
 
   cmd_paste() {
     this._downloadURLFromClipboard();
   },
 
   downloadsCmd_clearDownloads() {
     this._downloadsData.removeFinished();
-    if (this.result) {
+    if (this._place) {
       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");
   },
--- a/browser/components/downloads/content/downloads.js
+++ b/browser/components/downloads/content/downloads.js
@@ -1090,17 +1090,16 @@ DownloadsViewItem.prototype = {
         if (!this.download.target.partFilePath) {
           return false;
         }
 
         let partFile = new FileUtils.File(this.download.target.partFilePath);
         return partFile.exists();
       }
       case "cmd_delete":
-      case "downloadsCmd_cancel":
       case "downloadsCmd_copyLocation":
       case "downloadsCmd_doDefault":
         return true;
       case "downloadsCmd_showBlockedInfo":
         return this.download.hasBlockedData;
     }
     return DownloadsViewUI.DownloadElementShell.prototype
                           .isCommandEnabled.call(this, aCommand);
@@ -1109,21 +1108,16 @@ DownloadsViewItem.prototype = {
   doCommand(aCommand) {
     if (this.isCommandEnabled(aCommand)) {
       this[aCommand]();
     }
   },
 
   // Item commands
 
-  cmd_delete() {
-    DownloadsCommon.removeAndFinalizeDownload(this.download);
-    PlacesUtils.history.remove(this.download.source.url).catch(Cu.reportError);
-  },
-
   downloadsCmd_unblock() {
     DownloadsPanel.hidePanel();
     this.confirmUnblock(window, "unblock");
   },
 
   downloadsCmd_chooseUnblock() {
     DownloadsPanel.hidePanel();
     this.confirmUnblock(window, "chooseUnblock");
--- a/browser/components/places/content/places.js
+++ b/browser/components/places/content/places.js
@@ -779,25 +779,25 @@ var PlacesSearchBox = {
     // XXX this might be to jumpy, maybe should search for "", so results
     // are ungrouped, and search box not reset
     if (filterString == "") {
       PO.onPlaceSelected(false);
       return;
     }
 
     let currentView = ContentArea.currentView;
-    let currentOptions = PO.getCurrentOptions();
 
     // Search according to the current scope, which was set by
     // PQB_setScope()
     switch (PlacesSearchBox.filterCollection) {
       case "bookmarks":
         currentView.applyFilter(filterString, this.folders);
         break;
       case "history": {
+        let currentOptions = PO.getCurrentOptions();
         if (currentOptions.queryType != Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) {
           let query = PlacesUtils.history.getNewQuery();
           query.searchTerms = filterString;
           let options = currentOptions.clone();
           // Make sure we're getting uri results.
           options.resultType = currentOptions.RESULTS_AS_URI;
           options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY;
           options.includeHidden = true;
@@ -805,30 +805,18 @@ var PlacesSearchBox = {
         } else {
           TelemetryStopwatch.start(HISTORY_LIBRARY_SEARCH_TELEMETRY);
           currentView.applyFilter(filterString, null, true);
           TelemetryStopwatch.finish(HISTORY_LIBRARY_SEARCH_TELEMETRY);
         }
         break;
       }
       case "downloads": {
-        if (currentView == ContentTree.view) {
-          let query = PlacesUtils.history.getNewQuery();
-          query.searchTerms = filterString;
-          query.setTransitions([Ci.nsINavHistoryService.TRANSITION_DOWNLOAD], 1);
-          let options = currentOptions.clone();
-          // Make sure we're getting uri results.
-          options.resultType = currentOptions.RESULTS_AS_URI;
-          options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY;
-          options.includeHidden = true;
-          currentView.load([query], options);
-        } else {
-          // The new downloads view doesn't use places for searching downloads.
-          currentView.searchTerm = filterString;
-        }
+        // The new downloads view doesn't use places for searching downloads.
+        currentView.searchTerm = filterString;
         break;
       }
       default:
         throw "Invalid filterCollection on search";
     }
 
     // Update the details panel
     PlacesOrganizer.updateDetailsPane();
--- a/browser/components/places/tests/browser/browser_library_downloads.js
+++ b/browser/components/places/tests/browser/browser_library_downloads.js
@@ -38,22 +38,21 @@ function test() {
       },
       handleCompletion() {
         // Make sure Downloads is present.
         isnot(win.PlacesOrganizer._places.selectedNode, null,
               "Downloads is present and selected");
 
 
         // Check results.
-        let contentRoot = win.ContentArea.currentView.result.root;
-        let len = contentRoot.childCount;
-        const TEST_URIS = ["http://ubuntu.org/", "http://google.com/"];
-        for (let i = 0; i < len; i++) {
-          is(contentRoot.getChild(i).uri, TEST_URIS[i],
-              "Comparing downloads shown at index " + i);
+        let testURIs = ["http://ubuntu.org/", "http://google.com/"];
+        for (let element of win.ContentArea.currentView
+                                           .associatedElement.children) {
+          is(element._shell.download.source.url, testURIs.shift(),
+             "URI matches");
         }
 
         win.close();
         PlacesTestUtils.clearHistory().then(finish);
       }
     })
   }
 
--- a/dom/workers/test/serviceworkers/browser_download.js
+++ b/dom/workers/test/serviceworkers/browser_download.js
@@ -1,14 +1,13 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 Cu.import('resource://gre/modules/Services.jsm');
 var Downloads = Cu.import("resource://gre/modules/Downloads.jsm", {}).Downloads;
-var DownloadsCommon = Cu.import("resource:///modules/DownloadsCommon.jsm", {}).DownloadsCommon;
 Cu.import('resource://gre/modules/NetUtil.jsm');
 
 var gTestRoot = getRootDirectory(gTestPath).replace("chrome://mochitests/content/",
                                                     "http://mochi.test:8888/")
 
 function getFile(aFilename) {
   if (aFilename.startsWith('file:')) {
     var url = NetUtil.newURI(aFilename).QueryInterface(Ci.nsIFileURL);
@@ -54,19 +53,18 @@ function test() {
       var downloadListener;
 
       function downloadVerifier(aDownload) {
         if (aDownload.succeeded) {
           var file = getFile(aDownload.target.path);
           ok(file.exists(), 'download completed');
           is(file.fileSize, 33, 'downloaded file has correct size');
           file.remove(false);
-          DownloadsCommon.removeAndFinalizeDownload(aDownload);
-
-          downloadList.removeView(downloadListener);
+          downloadList.remove(aDownload).catch(Cu.reportError);
+          downloadList.removeView(downloadListener).catch(Cu.reportError);
           gBrowser.removeTab(tab);
           Services.ww.unregisterNotification(windowObserver);
 
           executeSoon(finish);
         }
       }
 
       downloadListener = {
--- a/toolkit/components/jsdownloads/src/DownloadHistory.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadHistory.jsm
@@ -14,36 +14,68 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = [
   "DownloadHistory",
 ];
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
+Cu.import("resource://gre/modules/DownloadList.jsm");
 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, "OS",
+                                  "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 
+// Places query used to retrieve all history downloads for the related list.
+const HISTORY_PLACES_QUERY =
+      "place:transition=" + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD +
+      "&sort=" + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING;
+
+const DESTINATIONFILEURI_ANNO = "downloads/destinationFileURI";
 const METADATA_ANNO = "downloads/metaData";
 
 const METADATA_STATE_FINISHED = 1;
 const METADATA_STATE_FAILED = 2;
 const METADATA_STATE_CANCELED = 3;
+const METADATA_STATE_PAUSED = 4;
 const METADATA_STATE_BLOCKED_PARENTAL = 6;
 const METADATA_STATE_DIRTY = 8;
 
 /**
  * Provides methods to retrieve downloads from previous sessions and store
  * downloads for future sessions.
  */
 this.DownloadHistory = {
   /**
+   * Retrieves the main DownloadHistoryList object which provides a view on
+   * downloads from previous browsing sessions, as well as downloads from this
+   * session that were not started from a private browsing window.
+   *
+   * @return {Promise}
+   * @resolves The requested DownloadHistoryList object.
+   * @rejects JavaScript exception.
+   */
+  getList() {
+    if (!this._promiseList) {
+      this._promiseList = Downloads.getList(Downloads.PUBLIC).then(list => {
+        return new DownloadHistoryList(list, HISTORY_PLACES_QUERY);
+      });
+    }
+
+    return this._promiseList;
+  },
+  _promiseList: null,
+
+  /**
    * Stores new detailed metadata for the given download in history. This is
    * normally called after a download finishes, fails, or is canceled.
    *
    * Failed or canceled downloads with partial data are not stored as paused,
    * because the information from the session download is required for resuming.
    *
    * @param download
    *        Download object whose metadata should be updated. If the object
@@ -83,9 +115,586 @@ this.DownloadHistory = {
                                  Services.io.newURI(download.source.url),
                                  METADATA_ANNO,
                                  JSON.stringify(metaData), 0,
                                  PlacesUtils.annotations.EXPIRE_WITH_HISTORY);
     } catch (ex) {
       Cu.reportError(ex);
     }
   },
+
+  /**
+   * 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 = Services.io.newURI(spec);
+      try {
+        metaData = JSON.parse(PlacesUtils.annotations.getPageAnnotation(
+                                          uri, METADATA_ANNO));
+      } catch (ex) {}
+      metaData.targetFileSpec = PlacesUtils.annotations.getPageAnnotation(
+                                            uri, DESTINATIONFILEURI_ANNO);
+    } catch (ex) {}
+
+    return metaData;
+  },
 };
+
+/**
+ * This cache exists in order to optimize the load of DownloadsHistoryList, 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 avoid using stale data, consumers should
+ * permanently remove from the cache any URI corresponding to a session
+ * download. 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.
+ */
+XPCOMUtils.defineLazyGetter(this, "gCachedPlacesMetaData", function() {
+  let placesMetaData = new Map();
+
+  // Read the metadata annotations first, but ignore invalid JSON.
+  for (let result of PlacesUtils.annotations.getAnnotationsWithName(
+                                             METADATA_ANNO)) {
+    try {
+      placesMetaData.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(
+                                             DESTINATIONFILEURI_ANNO)) {
+    let metaData = placesMetaData.get(result.uri.spec);
+    if (!metaData) {
+      metaData = {};
+      placesMetaData.set(result.uri.spec, metaData);
+    }
+    metaData.targetFileSpec = result.annotationValue;
+  }
+
+  return placesMetaData;
+});
+
+/**
+ * Represents a download from the browser history. This object implements part
+ * of the interface of the Download object.
+ *
+ * While Download objects are shared between the public DownloadList and all the
+ * DownloadHistoryList instances, multiple HistoryDownload objects referring to
+ * the same item can be created for different DownloadHistoryList instances.
+ *
+ * @param placesNode
+ *        The Places node from which the history download should be initialized.
+ */
+function HistoryDownload(placesNode) {
+  this.placesNode = placesNode;
+
+  // History downloads should get the referrer from Places (bug 829201).
+  this.source = {
+    url: placesNode.uri,
+    isPrivate: false,
+  };
+  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 = placesNode.time / 1000;
+}
+
+HistoryDownload.prototype = {
+  /**
+   * DownloadSlot containing this history download.
+   */
+  slot: null,
+
+  /**
+   * 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 == METADATA_STATE_FINISHED;
+      this.canceled = metaData.state == METADATA_STATE_CANCELED ||
+                      metaData.state == METADATA_STATE_PAUSED;
+      this.endTime = metaData.endTime;
+
+      // Recreate partial error information from the state saved in history.
+      if (metaData.state == METADATA_STATE_FAILED) {
+        this.error = { message: "History download failed." };
+      } else if (metaData.state == METADATA_STATE_BLOCKED_PARENTAL) {
+        this.error = { becauseBlockedByParentalControls: true };
+      } else if (metaData.state == METADATA_STATE_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 may be called when deleting a history download.
+   */
+  async finalize() {},
+
+  /**
+   * This method mimicks the "refresh" method of session downloads.
+   */
+  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;
+    }
+
+    this.slot.list._notifyAllViews("onDownloadChanged", this);
+  },
+};
+
+/**
+ * Represents one item in the list of public session and history downloads.
+ *
+ * The object 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 accessed.
+ *
+ * @param list
+ *        The DownloadHistoryList that owns this DownloadSlot object.
+ */
+function DownloadSlot(list) {
+  this.list = list;
+}
+
+DownloadSlot.prototype = {
+  list: null,
+
+  /**
+   * Download object representing the session download contained in this slot.
+   */
+  sessionDownload: null,
+
+  /**
+   * HistoryDownload object contained in this slot.
+   */
+  get historyDownload() {
+    return this._historyDownload;
+  },
+  set historyDownload(historyDownload) {
+    this._historyDownload = historyDownload;
+    if (historyDownload) {
+      historyDownload.slot = this;
+    }
+  },
+  _historyDownload: null,
+
+  /**
+   * Returns the Download or HistoryDownload object for displaying information
+   * and executing commands in the user interface.
+   */
+  get download() {
+    return this.sessionDownload || this.historyDownload;
+  },
+};
+
+/**
+ * Represents an ordered collection of DownloadSlot objects containing a merged
+ * view on session downloads and history downloads. Views on this list will
+ * receive notifications for changes to both types of downloads.
+ *
+ * Downloads in this list are sorted from oldest to newest, with all session
+ * downloads after all the history downloads. When a new history download is
+ * added and the list also contains session downloads, the insertBefore option
+ * of the onDownloadAdded notification refers to the first session download.
+ *
+ * The list of downloads cannot be modified using the DownloadList methods.
+ *
+ * @param publicList
+ *        Underlying DownloadList containing public downloads.
+ * @param place
+ *        Places query used to retrieve history downloads.
+ */
+this.DownloadHistoryList = function(publicList, place) {
+  DownloadList.call(this);
+
+  // While "this._slots" contains all the data in order, the other properties
+  // provide fast access for the most common operations.
+  this._slots = [];
+  this._slotsForUrl = new Map();
+  this._slotForDownload = new WeakMap();
+
+  // Start the asynchronous queries to retrieve history and session downloads.
+  publicList.addView(this).catch(Cu.reportError);
+  let queries = {}, options = {};
+  PlacesUtils.history.queryStringToQueries(place, queries, {}, options);
+  if (!queries.value.length) {
+    queries.value = [PlacesUtils.history.getNewQuery()];
+  }
+
+  let result = PlacesUtils.history.executeQueries(queries.value,
+                                                  queries.value.length,
+                                                  options.value);
+  result.addObserver(this);
+}
+
+this.DownloadHistoryList.prototype = {
+  __proto__: DownloadList.prototype,
+
+  /**
+   * This is set when executing the Places query.
+   */
+  get result() {
+    return this._result;
+  },
+  set result(result) {
+    if (this._result == result) {
+      return;
+    }
+
+    if (this._result) {
+      PlacesUtils.annotations.removeObserver(this);
+      this._result.removeObserver(this);
+      this._result.root.containerOpen = false;
+    }
+
+    this._result = result;
+
+    if (this._result) {
+      this._result.root.containerOpen = true;
+      PlacesUtils.annotations.addObserver(this);
+    }
+  },
+  _result: null,
+
+  /**
+   * Index of the first slot that contains a session download. This is equal to
+   * the length of the list when there are no session downloads.
+   */
+  _firstSessionSlotIndex: 0,
+
+  _insertSlot({ slot, index, slotsForUrl }) {
+    // Add the slot to the ordered array.
+    this._slots.splice(index, 0, slot);
+    this._downloads.splice(index, 0, slot.download);
+    if (!slot.sessionDownload) {
+      this._firstSessionSlotIndex++;
+    }
+
+    // Add the slot to the fast access maps.
+    slotsForUrl.add(slot);
+    this._slotsForUrl.set(slot.download.source.url, slotsForUrl);
+
+    // Add the associated view items.
+    this._notifyAllViews("onDownloadAdded", slot.download, {
+      insertBefore: this._downloads[index + 1],
+    });
+  },
+
+  _removeSlot({ slot, slotsForUrl }) {
+    // Remove the slot from the ordered array.
+    let index = this._slots.indexOf(slot);
+    this._slots.splice(index, 1);
+    this._downloads.splice(index, 1);
+    if (this._firstSessionSlotIndex > index) {
+      this._firstSessionSlotIndex--;
+    }
+
+    // Remove the slot from the fast access maps.
+    slotsForUrl.delete(slot);
+    if (slotsForUrl.size == 0) {
+      this._slotsForUrl.delete(slot.download.source.url);
+    }
+
+    // Remove the associated view items.
+    this._notifyAllViews("onDownloadRemoved", slot.download);
+  },
+
+  /**
+   * Ensures that the information about a history download is stored in at least
+   * one slot, adding a new one at the end of the list if necessary.
+   *
+   * A reference to the same Places node will be stored in the HistoryDownload
+   * object for all the DownloadSlot objects associated with the source URL.
+   *
+   * @param placesNode
+   *        The Places node that represents the history download.
+   */
+  _insertPlacesNode(placesNode) {
+    let slotsForUrl = this._slotsForUrl.get(placesNode.uri) || new Set();
+
+    // If there are existing slots associated with this URL, we only have to
+    // ensure that the Places node reference is kept updated in case the more
+    // recent Places notification contained a different node object.
+    if (slotsForUrl.size > 0) {
+      for (let slot of slotsForUrl) {
+        if (!slot.historyDownload) {
+          slot.historyDownload = new HistoryDownload(placesNode);
+        } else {
+          slot.historyDownload.placesNode = placesNode;
+        }
+      }
+      return;
+    }
+
+    // If there are no existing slots for this URL, we have to create a new one.
+    // Since the history download is visible in the slot, we also have to update
+    // the object using the Places metadata.
+    let historyDownload = new HistoryDownload(placesNode);
+    historyDownload.updateFromMetaData(
+      gCachedPlacesMetaData.get(placesNode.uri) ||
+      DownloadHistory.getPlacesMetaDataFor(placesNode.uri));
+    let slot = new DownloadSlot(this);
+    slot.historyDownload = historyDownload;
+    this._insertSlot({ slot, slotsForUrl, index: this._firstSessionSlotIndex });
+  },
+
+  // nsINavHistoryResultObserver
+  containerStateChanged(node, oldState, newState) {
+    this.invalidateContainer(node);
+  },
+
+  // nsINavHistoryResultObserver
+  invalidateContainer(container) {
+    this._notifyAllViews("onDownloadBatchStarting");
+
+    // Remove all the current slots containing only history downloads.
+    for (let index = this._slots.length - 1; index >= 0; index--) {
+      let slot = this._slots[index];
+      if (slot.sessionDownload) {
+        // The visible data doesn't change, so we don't have to notify views.
+        slot.historyDownload = null;
+      } else {
+        let slotsForUrl = this._slotsForUrl.get(slot.download.source.url);
+        this._removeSlot({ slot, slotsForUrl });
+      }
+    }
+
+    // Add new slots or reuse existing ones for history downloads.
+    for (let index = 0; index < container.childCount; index++) {
+      try {
+        this._insertPlacesNode(container.getChild(index));
+      } catch (ex) {
+        Cu.reportError(ex);
+      }
+    }
+
+    this._notifyAllViews("onDownloadBatchEnded");
+  },
+
+  // nsINavHistoryResultObserver
+  nodeInserted(parent, placesNode) {
+    this._insertPlacesNode(placesNode);
+  },
+
+  // nsINavHistoryResultObserver
+  nodeRemoved(parent, placesNode, aOldIndex) {
+    let slotsForUrl = this._slotsForUrl.get(placesNode.uri);
+    for (let slot of slotsForUrl) {
+      if (slot.sessionDownload) {
+        // The visible data doesn't change, so we don't have to notify views.
+        slot.historyDownload = null;
+      } else {
+        this._removeSlot({ slot, slotsForUrl });
+      }
+    }
+  },
+
+  // nsINavHistoryResultObserver
+  nodeAnnotationChanged() {},
+  nodeIconChanged() {},
+  nodeTitleChanged() {},
+  nodeKeywordChanged() {},
+  nodeDateAddedChanged() {},
+  nodeLastModifiedChanged() {},
+  nodeHistoryDetailsChanged() {},
+  nodeTagsChanged() {},
+  sortingChanged() {},
+  nodeMoved() {},
+  nodeURIChanged() {},
+  batching() {},
+
+  // nsIAnnotationObserver
+  onPageAnnotationSet(page, name) {
+    // Annotations can only be added after a history node has been added, so we
+    // have to listen for changes to nodes we already added to the list.
+    if (name != DESTINATIONFILEURI_ANNO && name != METADATA_ANNO) {
+      return;
+    }
+
+    let slotsForUrl = this._slotsForUrl.get(page.spec);
+    if (!slotsForUrl) {
+      return;
+    }
+
+    for (let slot of slotsForUrl) {
+      if (slot.sessionDownload) {
+        // The visible data doesn't change, so we don't have to notify views.
+        return;
+      }
+      slot.historyDownload.updateFromMetaData(
+        DownloadHistory.getPlacesMetaDataFor(page.spec));
+      this._notifyAllViews("onDownloadChanged", slot.download);
+    }
+  },
+
+  // nsIAnnotationObserver
+  onItemAnnotationSet() {},
+  onPageAnnotationRemoved() {},
+  onItemAnnotationRemoved() {},
+
+  // DownloadList callback
+  onDownloadAdded(download) {
+    let url = download.source.url;
+    let slotsForUrl = this._slotsForUrl.get(url) || new Set();
+
+    // When a session download is attached to a slot, 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.
+    gCachedPlacesMetaData.delete(url);
+
+    // For every source URL, there can be at most one slot containing a history
+    // download without an associated session download. If we find one, then we
+    // can reuse it for the current session download, although we have to move
+    // it together with the other session downloads.
+    let slot = [...slotsForUrl][0];
+    if (slot && !slot.sessionDownload) {
+      // Remove the slot because we have to change its position.
+      this._removeSlot({ slot, slotsForUrl });
+    } else {
+      slot = new DownloadSlot(this);
+    }
+    slot.sessionDownload = download;
+    this._insertSlot({ slot, slotsForUrl, index: this._slots.length });
+    this._slotForDownload.set(download, slot);
+  },
+
+  // DownloadList callback
+  onDownloadChanged(download) {
+    let slot = this._slotForDownload.get(download);
+    this._notifyAllViews("onDownloadChanged", slot.download);
+  },
+
+  // DownloadList callback
+  onDownloadRemoved(download) {
+    let url = download.source.url;
+    let slotsForUrl = this._slotsForUrl.get(url);
+    let slot = this._slotForDownload.get(download);
+    this._removeSlot({ slot, slotsForUrl });
+
+    // If there was only one slot for this source URL and it also contained a
+    // history download, we should resurrect it in the correct area of the list.
+    if (slotsForUrl.size == 0 && slot.historyDownload) {
+      // We have one download slot 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 resurrecting the history download.
+      slot.historyDownload.updateFromMetaData(
+        DownloadHistory.getPlacesMetaDataFor(url));
+      slot.sessionDownload = null;
+      // Place the resurrected history slot after all the session slots.
+      this._insertSlot({ slot, slotsForUrl,
+                         index: this._firstSessionSlotIndex });
+    }
+
+    this._slotForDownload.delete(download);
+  },
+
+  // DownloadList
+  add() {
+    throw new Error("Not implemented.");
+  },
+
+  // DownloadList
+  remove() {
+    throw new Error("Not implemented.");
+  },
+
+  // DownloadList
+  removeFinished() {
+    throw new Error("Not implemented.");
+  },
+};
--- a/toolkit/components/jsdownloads/src/DownloadList.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadList.jsm
@@ -173,28 +173,27 @@ this.DownloadList.prototype = {
    */
   removeView: function DL_removeView(aView) {
     this._views.delete(aView);
 
     return Promise.resolve();
   },
 
   /**
-   * Notifies all the views of a download addition, change, or removal.
+   * Notifies all the views of a download addition, change, removal, or other
+   * event. The additional arguments are passed to the called method.
    *
-   * @param aMethodName
+   * @param methodName
    *        String containing the name of the method to call on the view.
-   * @param aDownload
-   *        The Download object that changed.
    */
-  _notifyAllViews(aMethodName, aDownload) {
+  _notifyAllViews(methodName, ...args) {
     for (let view of this._views) {
       try {
-        if (aMethodName in view) {
-          view[aMethodName](aDownload);
+        if (methodName in view) {
+          view[methodName](...args);
         }
       } catch (ex) {
         Cu.reportError(ex);
       }
     }
   },
 
   /**
--- a/toolkit/components/jsdownloads/test/unit/common_test_Download.js
+++ b/toolkit/components/jsdownloads/test/unit/common_test_Download.js
@@ -2312,44 +2312,44 @@ add_task(async function test_toSerializa
 
 /**
  * Checks that downloads are added to browsing history when they start.
  */
 add_task(async function test_history() {
   mustInterruptResponses();
 
   // We will wait for the visit to be notified during the download.
-  await PlacesTestUtils.clearHistory();
+  await PlacesUtils.history.clear();
   let promiseVisit = promiseWaitForVisit(httpUrl("interruptible.txt"));
 
   // Start a download that is not allowed to finish yet.
   let download = await promiseStartDownload(httpUrl("interruptible.txt"));
 
   // The history notifications should be received before the download completes.
   let [time, transitionType] = await promiseVisit;
   do_check_eq(time, download.startTime.getTime() * 1000);
   do_check_eq(transitionType, Ci.nsINavHistoryService.TRANSITION_DOWNLOAD);
 
   // Restart and complete the download after clearing history.
-  await PlacesTestUtils.clearHistory();
+  await PlacesUtils.history.clear();
   download.cancel();
   continueResponses();
   await download.start();
 
   // The restart should not have added a new history visit.
   do_check_false(await promiseIsURIVisited(httpUrl("interruptible.txt")));
 });
 
 /**
  * Checks that downloads started by nsIHelperAppService are added to the
  * browsing history when they start.
  */
 add_task(async function test_history_tryToKeepPartialData() {
   // We will wait for the visit to be notified during the download.
-  await PlacesTestUtils.clearHistory();
+  await PlacesUtils.history.clear();
   let promiseVisit =
       promiseWaitForVisit(httpUrl("interruptible_resumable.txt"));
 
   // Start a download that is not allowed to finish yet.
   let beforeStartTimeMs = Date.now();
   let download = await promiseStartDownload_tryToKeepPartialData();
 
   // The history notifications should be received before the download completes.
--- a/toolkit/components/jsdownloads/test/unit/head.js
+++ b/toolkit/components/jsdownloads/test/unit/head.js
@@ -24,18 +24,16 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "Downloads",
                                   "resource://gre/modules/Downloads.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                   "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "HttpServer",
                                   "resource://testing-common/httpd.js");
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils",
-                                  "resource://testing-common/PlacesTestUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
                                   "resource://gre/modules/Promise.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
new file mode 100644
--- /dev/null
+++ b/toolkit/components/jsdownloads/test/unit/test_DownloadHistory.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the DownloadHistory module.
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/DownloadHistory.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "gDownloadHistory",
+           "@mozilla.org/browser/download-history;1",
+           Ci.nsIDownloadHistory);
+
+let baseDate = new Date("2000-01-01");
+
+/**
+ * Waits for the download annotations to be set for the given page, required
+ * because the addDownload method will add these to the database asynchronously.
+ */
+function waitForAnnotations(sourceUriSpec) {
+  let sourceUri = Services.io.newURI(sourceUriSpec);
+  let destinationFileUriSet = false;
+  let metaDataSet = false;
+  return new Promise(resolve => {
+    PlacesUtils.annotations.addObserver({
+      onPageAnnotationSet(page, name) {
+        if (!page.equals(sourceUri)) {
+          return;
+        }
+        switch (name) {
+          case "downloads/destinationFileURI":
+            destinationFileUriSet = true;
+            break;
+          case "downloads/metaData":
+            metaDataSet = true;
+            break;
+        }
+        if (destinationFileUriSet && metaDataSet) {
+          PlacesUtils.annotations.removeObserver(this);
+          resolve();
+        }
+      },
+      onItemAnnotationSet() {},
+      onPageAnnotationRemoved() {},
+      onItemAnnotationRemoved() {},
+    });
+  });
+}
+
+/**
+ * Non-fatal assertion used to test whether the downloads in the list already
+ * match the expected state.
+ */
+function areEqual(a, b) {
+  if (a === b) {
+    Assert.equal(a, b);
+    return true;
+  }
+  do_print(a + " !== " + b);
+  return false;
+}
+
+/**
+ * Tests that various operations on session and history downloads are reflected
+ * by the DownloadHistoryList object, and that the order of results is correct.
+ */
+add_task(async function test_DownloadHistory() {
+  // Clean up at the beginning and at the end of the test.
+  async function cleanup() {
+    await PlacesUtils.history.clear();
+  }
+  do_register_cleanup(cleanup);
+  await cleanup();
+
+  let testDownloads = [
+    // History downloads should appear in order at the beginning of the list.
+    { offset: 10, canceled: true },
+    { offset: 20, succeeded: true },
+    { offset: 30, error: { becauseSourceFailed: true } },
+    { offset: 40, error: { becauseBlockedByParentalControls: true } },
+    { offset: 50, error: { becauseBlockedByReputationCheck: true } },
+    // Session downloads should show up after all the history download, in the
+    // same order as they were added.
+    { offset: 45, canceled: true, inSession: true },
+    { offset: 35, canceled: true, hasPartialData: true, inSession: true },
+    { offset: 55, succeeded: true, inSession: true },
+  ];
+  const NEXT_OFFSET = 60;
+
+  async function addTestDownload(properties) {
+    properties.source = { url: httpUrl("source" + properties.offset) };
+    let targetFile = getTempFile(TEST_TARGET_FILE_NAME + properties.offset);
+    properties.target = { path: targetFile.path };
+    properties.startTime = new Date(baseDate.getTime() + properties.offset);
+
+    let download = await Downloads.createDownload(properties);
+    if (properties.inSession) {
+      await publicList.add(download);
+    }
+
+    // Add the download to history using the XPCOM service, then use the
+    // DownloadHistory module to save the associated metadata.
+    let promiseAnnotations = waitForAnnotations(properties.source.url);
+    let promiseVisit = promiseWaitForVisit(properties.source.url);
+    gDownloadHistory.addDownload(Services.io.newURI(properties.source.url),
+                                 null,
+                                 properties.startTime.getTime() * 1000,
+                                 NetUtil.newURI(targetFile));
+    await promiseVisit;
+    DownloadHistory.updateMetaData(download);
+    await promiseAnnotations;
+  }
+
+  // Add all the test downloads to history.
+  let publicList = await promiseNewList();
+  for (let properties of testDownloads) {
+    await addTestDownload(properties);
+  }
+
+  // This allows waiting for an expected list at various points during the test.
+  let view = {
+    downloads: [],
+    onDownloadAdded(download, options = {}) {
+      if (options.insertBefore) {
+        let index = this.downloads.indexOf(options.insertBefore);
+        this.downloads.splice(index, 0, download);
+      } else {
+        this.downloads.push(download);
+      }
+      this.checkForExpectedDownloads();
+    },
+    onDownloadChanged(download) {
+      this.checkForExpectedDownloads();
+    },
+    onDownloadRemoved(download) {
+      let index = this.downloads.indexOf(download);
+      this.downloads.splice(index, 1);
+      this.checkForExpectedDownloads();
+    },
+    checkForExpectedDownloads() {
+      // Wait for all the expected downloads to be added or removed before doing
+      // the detailed tests. This is done to avoid creating irrelevant output.
+      if (this.downloads.length != testDownloads.length) {
+        return;
+      }
+      for (let i = 0; i < this.downloads.length; i++) {
+        if (this.downloads[i].source.url != testDownloads[i].source.url ||
+            this.downloads[i].target.path != testDownloads[i].target.path) {
+          return;
+        }
+      }
+      // Check and report the actual state of the downloads. Even if the items
+      // are in the expected order, the metadata for history downloads might not
+      // have been updated to the final state yet.
+      for (let i = 0; i < view.downloads.length; i++) {
+        let download = view.downloads[i];
+        let testDownload = testDownloads[i];
+        do_print("Checking download source " + download.source.url +
+                 " with target " + download.target.path);
+        if (!areEqual(download.succeeded, !!testDownload.succeeded) ||
+            !areEqual(download.canceled, !!testDownload.canceled) ||
+            !areEqual(download.hasPartialData, !!testDownload.hasPartialData) ||
+            !areEqual(!!download.error, !!testDownload.error)) {
+          return;
+        }
+        // If the above properties match, the error details should be correct.
+        if (download.error) {
+          if (testDownload.error.becauseSourceFailed) {
+            Assert.equal(download.error.message, "History download failed.");
+          }
+          Assert.equal(download.error.becauseBlockedByParentalControls,
+                       testDownload.error.becauseBlockedByParentalControls);
+          Assert.equal(download.error.becauseBlockedByReputationCheck,
+                       testDownload.error.becauseBlockedByReputationCheck);
+        }
+      }
+      this.resolveWhenExpected();
+    },
+    resolveWhenExpected: () => {},
+    async waitForExpected() {
+      let promise = new Promise(resolve => this.resolveWhenExpected = resolve);
+      this.checkForExpectedDownloads();
+      await promise;
+    },
+  };
+
+  // Initialize DownloadHistoryList only after having added the history and
+  // session downloads, and check that they are loaded in the correct order.
+  let list = await DownloadHistory.getList();
+  await list.addView(view);
+  await view.waitForExpected();
+
+  // Remove a download from history and verify that the change is reflected.
+  let downloadToRemove = testDownloads[1];
+  testDownloads.splice(1, 1);
+  await PlacesUtils.history.remove(downloadToRemove.source.url);
+  await view.waitForExpected();
+
+  // Add a download to history and verify it's placed before session downloads,
+  // even if the start date is more recent.
+  let downloadToAdd = { offset: NEXT_OFFSET, canceled: true };
+  testDownloads.splice(testDownloads.findIndex(d => d.inSession), 0,
+                       downloadToAdd);
+  await addTestDownload(downloadToAdd);
+  await view.waitForExpected();
+
+  // Add a session download and verify it's placed after all session downloads,
+  // even if the start date is less recent.
+  let sessionDownloadToAdd = { offset: 0, inSession: true, succeeded: true };
+  testDownloads.push(sessionDownloadToAdd);
+  await addTestDownload(sessionDownloadToAdd);
+  await view.waitForExpected();
+
+  // Add a session download for the same URI without a history entry, and verify
+  // it's visible and placed after all session downloads.
+  testDownloads.push(sessionDownloadToAdd);
+  await publicList.add(await Downloads.createDownload(sessionDownloadToAdd));
+  await view.waitForExpected();
+
+  // Clear history and check that session downloads with partial data remain.
+  testDownloads = testDownloads.filter(d => d.hasPartialData);
+  await PlacesUtils.history.clear();
+  await view.waitForExpected();
+});
--- a/toolkit/components/jsdownloads/test/unit/test_DownloadList.js
+++ b/toolkit/components/jsdownloads/test/unit/test_DownloadList.js
@@ -336,17 +336,17 @@ add_task(async function test_history_exp
 
   // Work with one finished download and one canceled download.
   await downloadOne.start();
   downloadTwo.start().catch(() => {});
   await downloadTwo.cancel();
 
   // We must replace the visits added while executing the downloads with visits
   // that are older than 7 days, otherwise they will not be expired.
-  await PlacesTestUtils.clearHistory();
+  await PlacesUtils.history.clear();
   await promiseExpirableDownloadVisit();
   await promiseExpirableDownloadVisit(httpUrl("interruptible.txt"));
 
   // After clearing history, we can add the downloads to be removed to the list.
   await list.add(downloadOne);
   await list.add(downloadTwo);
 
   // Force a history expiration.
@@ -378,17 +378,17 @@ add_task(async function test_history_cle
       }
     },
   };
   await list.addView(downloadView);
 
   await downloadOne.start();
   await downloadTwo.start();
 
-  await PlacesTestUtils.clearHistory();
+  await PlacesUtils.history.clear();
 
   // Wait for the removal notifications that may still be pending.
   await deferred.promise;
 });
 
 /**
  * Tests the removeFinished method to ensure that it only removes
  * finished downloads.
--- a/toolkit/components/jsdownloads/test/unit/xpcshell.ini
+++ b/toolkit/components/jsdownloads/test/unit/xpcshell.ini
@@ -3,16 +3,17 @@ head = head.js
 skip-if = toolkit == 'android'
 
 # Note: The "tail.js" file is not defined in the "tail" key because it calls
 #       the "add_test_task" function, that does not work properly in tail files.
 support-files =
   common_test_Download.js
 
 [test_DownloadCore.js]
+[test_DownloadHistory.js]
 [test_DownloadIntegration.js]
 [test_DownloadLegacy.js]
 [test_DownloadList.js]
 [test_Downloads.js]
 [test_DownloadStore.js]
 [test_PrivateTemp.js]
 # coverage flag is for bug 1336730
 skip-if = (os != 'linux' || coverage)