Bug 1381411 - Implement the DownloadHistoryList object. r=mak
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Fri, 04 Aug 2017 14:48:53 +0100
changeset 372837 56294c9668222e65593818b97b6c9cda4160db54
parent 372836 5f192263de3e033c3849729724dd388a24dca21e
child 372838 170429ba9a954f0d9794f58e549b61aa1751362d
push id93413
push userpaolo.mozmail@amadzone.org
push dateFri, 04 Aug 2017 14:05:24 +0000
treeherdermozilla-inbound@56294c966822 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmak
bugs1381411
milestone57.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1381411 - Implement the DownloadHistoryList object. r=mak This also changes the Library window to use the newly added back-end object. The only user-visible change should be how the selection behaves when retrying downloads. 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)