Bug 827405 - Lazily "activate" download items when they enter the visible richlistbox area.
authorMarco Bonardo <mbonardo@mozilla.com>
Tue, 08 Jan 2013 21:35:41 +0100
changeset 123522 6ec8ebb4f02e3efe94d948c5837b1b54c67e55a2
parent 123521 f5e3e40ab3c9704459e122393be05083cb8353d8
child 123523 50f3e4082f971cc77940c25d6fe0ac5b3c91aba0
push id3142
push usermak77@bonardo.net
push dateThu, 10 Jan 2013 21:00:11 +0000
treeherdermozilla-aurora@b170785e88b2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs827405
milestone20.0a2
Bug 827405 - Lazily "activate" download items when they enter the visible richlistbox area. r=Mano a=gavin
browser/components/downloads/content/allDownloadsViewOverlay.css
browser/components/downloads/content/allDownloadsViewOverlay.js
browser/components/downloads/content/allDownloadsViewOverlay.xul
browser/components/places/content/downloadsViewOverlay.xul
browser/components/places/content/places.js
browser/components/places/content/tree.xml
--- a/browser/components/downloads/content/allDownloadsViewOverlay.css
+++ b/browser/components/downloads/content/allDownloadsViewOverlay.css
@@ -1,13 +1,13 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
- 
-richlistitem.download {
+
+richlistitem.download[active] {
   -moz-binding: url('chrome://browser/content/downloads/download.xml#download-full-ui');
 }
 
 .download-state:not(          [state="0"]  /* Downloading        */)
                                            .downloadPauseMenuItem,
 .download-state:not(          [state="4"]  /* Paused             */)
                                            .downloadResumeMenuItem,
 .download-state:not(:-moz-any([state="2"], /* Failed             */
--- a/browser/components/downloads/content/allDownloadsViewOverlay.js
+++ b/browser/components/downloads/content/allDownloadsViewOverlay.js
@@ -73,47 +73,67 @@ function GetFileForFileURI(aFileURI)
  */
 function DownloadElementShell(aDataItem, aPlacesNode, aAnnotations) {
   this._element = document.createElement("richlistitem");
   this._element._shell = this;
 
   this._element.classList.add("download");
   this._element.classList.add("download-state");
 
- if (aAnnotations)
+  if (aAnnotations)
     this._annotations = aAnnotations;
   if (aDataItem)
     this.dataItem = aDataItem;
   if (aPlacesNode)
     this.placesNode = aPlacesNode;
 }
 
 DownloadElementShell.prototype = {
   // The richlistitem for the download
   get element() this._element,
 
+  /**
+   * Manages the "active" state of the shell.  By default all the shells
+   * without a dataItem are inactive, thus their UI is not updated.  They must
+   * be activated when entering the visible area.  Session downloads are
+   * always active since they always have a dataItem.
+   */
+  ensureActive: function DES_ensureActive() {
+    if (this._active)
+      return;
+    this._active = true;
+    this._element.setAttribute("active", true);
+    this._updateStatusUI();
+    this._fetchTargetFileInfo();
+  },
+  get active() !!this._active,
+
   // The data item for the download
   _dataItem: null,
   get dataItem() this._dataItem,
 
   set dataItem(aValue) {
-    if ((this._dataItem = aValue)) {
+    this._dataItem = aValue;
+    if (this._dataItem) {
+      this._active = true;
       this._wasDone = this._dataItem.done;
       this._wasInProgress = this._dataItem.inProgress;
       this._targetFileInfoFetched = false;
       this._fetchTargetFileInfo();
     }
     else if (this._placesNode) {
       this._wasInProgress = false;
       this._wasDone = this.getDownloadState(true) == nsIDM.DOWNLOAD_FINISHED;
       this._targetFileInfoFetched = false;
-      this._fetchTargetFileInfo();
+      if (this.active)
+        this._fetchTargetFileInfo();
     }
 
-    this._updateStatusUI();
+    it (this.active)
+      this._updateStatusUI();
     return aValue;
   },
 
   _placesNode: null,
   get placesNode() this._placesNode,
   set placesNode(aNode) {
     if (this._placesNode != aNode) {
       // Preserve the annotations map if this is the first loading and we got
@@ -124,18 +144,20 @@ DownloadElementShell.prototype = {
       this._placesNode = aNode;
 
       // We don't need to update the UI if we had a data item, because
       // the places information isn't used in this case.
       if (!this._dataItem && this._placesNode) {
         this._wasInProgress = false;
         this._wasDone = this.getDownloadState(true) == nsIDM.DOWNLOAD_FINISHED;
         this._targetFileInfoFetched = false;
-        this._updateStatusUI();
-        this._fetchTargetFileInfo();
+        if (this.active) {
+          this._updateStatusUI();
+          this._fetchTargetFileInfo();
+        }
       }
     }
     return aNode;
   },
 
   // The download uri (as a string)
   get downloadURI() {
     if (this._dataItem)
@@ -223,16 +245,18 @@ DownloadElementShell.prototype = {
     if (fileURI)
       return GetFileForFileURI(fileURI).path;
     return "";
   },
 
   _fetchTargetFileInfo: function DES__fetchTargetFileInfo() {
     if (this._targetFileInfoFetched)
       throw new Error("_fetchTargetFileInfo should not be called if the information was already fetched");
+    if (!this.active)
+      throw new Error("Trying to _fetchTargetFileInfo on an inactive download shell");
 
     let path = this._targetFilePath;
 
     // In previous version, the target file annotations were not set,
     // so we cannot where is the file.
     if (!path) {
       this._targetFileInfoFetched = true;
       this._targetFileExists = false;
@@ -406,16 +430,18 @@ DownloadElementShell.prototype = {
     if (this._progressElement) {
       let event = document.createEvent("Events");
       event.initEvent("ValueChange", true, true);
       this._progressElement.dispatchEvent(event);
     }
   },
 
   _updateStatusUI: function DES__updateStatusUI() {
+    if (!this.active)
+      throw new Error("Trying to _updateStatusUI on an inactive download shell");
     this._element.setAttribute("displayName", this._displayName);
     this._element.setAttribute("image", this._icon);
     this._updateDownloadStatusUI();
   },
 
   placesNodeIconChanged: function DES_placesNodeIconChanged() {
     if (!this._dataItem)
       this._element.setAttribute("image", this._icon);
@@ -446,17 +472,18 @@ DownloadElementShell.prototype = {
 
   /* DownloadView */
   onStateChange: function DES_onStateChange() {
     if (!this._wasDone && this._dataItem.done) {
       // See comment in DVI_onStateChange in downloads.js (the panel-view)
       this._element.setAttribute("image", this._icon + "&state=normal");
 
       this._targetFileInfoFetched = false;
-      this._fetchTargetFileInfo();
+      if (this.active)
+        this._fetchTargetFileInfo();
     }
 
     this._wasDone = this._dataItem.done;
 
     // Update the end time using the current time if required.
     if (this._wasInProgress && !this._dataItem.inProgress) {
       this._endTime = Date.now();
     }
@@ -471,16 +498,19 @@ DownloadElementShell.prototype = {
 
   /* DownloadView */
   onProgressChange: function DES_onProgressChange() {
     this._updateDownloadStatusUI();
   },
 
   /* nsIController */
   isCommandEnabled: function DES_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": {
         // We cannot open a session dowload file unless it's done ("openable").
         // If it's finished, we need to make sure the file was not removed,
         // as we do for past downloads.
         if (this._dataItem && !this._dataItem.openable)
           return false;
 
@@ -574,17 +604,18 @@ DownloadElementShell.prototype = {
       }
     }
   },
 
   // Returns whether or not the download handled by this shell should
   // show up in the search results for the given term.  Both the display
   // name for the download and the url are searched.
   matchesSearchTerm: function DES_matchesSearchTerm(aTerm) {
-    // Stub implemention until we figure out something better
+    if (!aTerm)
+      return true;
     aTerm = aTerm.toLowerCase();
     return this._displayName.toLowerCase().indexOf(aTerm) != -1 ||
            this.downloadURI.toLowerCase().indexOf(aTerm) != -1;
   },
 
   // Handles return kepress on the element (the keypress listener is
   // set in the DownloadsPlacesView object).
   doDefaultCommand: function DES_doDefaultCommand() {
@@ -623,49 +654,63 @@ DownloadElementShell.prototype = {
  * As we don't use the places controller, some methods implemented by other
  * places views are not implemented by this view.
  *
  * A richlistitem in this view can represent either a past download or a session
  * download, or both. Session downloads are shown first in the view, and as long
  * as they exist they "collapses" their history "counterpart" (So we don't show two
  * items for every download).
  */
-function DownloadsPlacesView(aRichListBox) {
+function DownloadsPlacesView(aRichListBox, aActive = true) {
   this._richlistbox = aRichListBox;
   this._richlistbox._placesView = this;
   this._richlistbox.controllers.appendController(this);
 
   // Map download URLs to download element shells regardless of their type
   this._downloadElementsShellsForURI = new Map();
 
   // Map download data items to their element shells.
   this._viewItemsForDataItems = 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.
   let downloadsData = DownloadsCommon.getData(window.opener || window);
   downloadsData.addView(this);
 
   // Make sure to unregister the view if the window is closed.
   window.addEventListener("unload", function() {
     this._richlistbox.controllers.removeController(this);
     downloadsData.removeView(this);
     this.result = null;
   }.bind(this), true);
+  // Resizing the window may change items visibility.
+  window.addEventListener("resize", function() {
+    this._ensureVisibleElementsAreActive();
+  }.bind(this), true);
 }
 
 DownloadsPlacesView.prototype = {
   get associatedElement() this._richlistbox,
 
+  get active() this._active,
+  set active(val) {
+    this._active = val;
+    if (this._active)
+      this._ensureVisibleElementsAreActive();
+    return this._active;
+  },
+
   _forEachDownloadElementShellForURI:
   function DPV__forEachDownloadElementShellForURI(aURI, aCallback) {
     if (this._downloadElementsShellsForURI.has(aURI)) {
       let downloadElementShells = this._downloadElementsShellsForURI.get(aURI);
       for (let des of downloadElementShells) {
         aCallback(des);
       }
     }
@@ -811,28 +856,34 @@ DownloadsPlacesView.prototype = {
         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();
   },
 
   _removeElement: function DPV__removeElement(aElement) {
     // If the element was selected exclusively, select its next
     // sibling first, if any.
     if (aElement.nextSibling &&
         this._richlistbox.selectedItems &&
         this._richlistbox.selectedItems.length > 0 &&
         this._richlistbox.selectedItems[0] == aElement) {
       this._richlistbox.selectItem(aElement.nextSibling);
     }
     this._richlistbox.removeChild(aElement);
+    this._ensureVisibleElementsAreActive();
   },
 
   _removeHistoryDownloadFromView:
   function DPV__removeHistoryDownloadFromView(aPlacesNode) {
     let downloadURI = aPlacesNode.uri;
     let shellsForURI = this._downloadElementsShellsForURI.get(downloadURI, null);
     if (shellsForURI) {
       for (let shell of shellsForURI) {
@@ -879,16 +930,48 @@ DownloadsPlacesView.prototype = {
       else {
         let before = this._lastSessionDownloadElement ?
           this._lastSessionDownloadElement.nextSibling : this._richlistbox.firstChild;
         this._richlistbox.insertBefore(shell.element, before);
       }
     }
   },
 
+  _ensureVisibleElementsAreActive:
+  function DPV__ensureVisibleElementsAreActive() {
+    if (!this.active || this._ensureVisibleTimer || !this._richlistbox.firstChild)
+      return;
+
+    this._ensureVisibleTimer = setTimeout(function() {
+      delete this._ensureVisibleTimer;
+
+      let rlRect = this._richlistbox.getBoundingClientRect();
+      let fcRect = this._richlistbox.firstChild.getBoundingClientRect();
+      // For simplicity assume border and padding are the same across all sides.
+      // This works as far as there isn't an horizontal scrollbar since fcRect
+      // is relative to the scrolled area.
+      let offset = fcRect.left - rlRect.left + 1;
+
+      let firstVisible = document.elementFromPoint(fcRect.left, rlRect.top + offset);
+      if (!firstVisible || firstVisible.localName != "richlistitem")
+        throw new Error("_ensureVisibleElementsAreActive invoked on the wrong view");
+
+      let lastVisible = document.elementFromPoint(fcRect.left, rlRect.bottom - offset);
+      // If the last visible child found is not a richlistitem, then there are
+      // less items than the available space, thus just proceed to the last child.
+      if (!lastVisible || lastVisible.localName != "richlistitem")
+        lastVisible = this._richlistbox.lastChild;
+
+      for (let elt = firstVisible; elt != lastVisible.nextSibling; elt = elt.nextSibling) {
+        if (elt._shell)
+          elt._shell.ensureActive();
+      }
+    }.bind(this), 10);
+  },
+
   _place: "",
   get place() 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;
@@ -975,16 +1058,17 @@ DownloadsPlacesView.prototype = {
                               elementsToAppendFragment);
       }
       catch(ex) {
         Cu.reportError(ex);
       }
     }
 
     this._richlistbox.appendChild(elementsToAppendFragment);
+    this._ensureVisibleElementsAreActive();
   },
 
   nodeInserted: function DPV_nodeInserted(aParent, aPlacesNode) {
     this._addDownloadData(null, aPlacesNode);
   },
 
   nodeRemoved: function DPV_nodeRemoved(aParent, aPlacesNode, aOldIndex) {
     this._removeHistoryDownloadFromView(aPlacesNode);
@@ -1022,16 +1106,17 @@ DownloadsPlacesView.prototype = {
   get controller() this._richlistbox.controller,
 
   get searchTerm() this._searchTerm,
   set searchTerm(aValue) {
     if (this._searchTerm != aValue) {
       for (let element of this._richlistbox.childNodes) {
         element.hidden = !element._shell.matchesSearchTerm(aValue);
       }
+      this._ensureVisibleElementsAreActive();
     }
     return this._searchTerm = aValue;
   },
 
   applyFilter: function() {
     throw new Error("applyFilter is not implemented by the DownloadsView")
   },
 
@@ -1198,16 +1283,20 @@ DownloadsPlacesView.prototype = {
 
     let selectedElements = this._richlistbox.selectedItems;
     if (!selectedElements || selectedElements.length != 1)
       return;
 
     let element = selectedElements[0];
     if (element._shell)
       element._shell.doDefaultCommand();
+  },
+
+  onScroll: function DPV_onScroll() {
+    this._ensureVisibleElementsAreActive();
   }
 };
 
 function goUpdateDownloadCommands() {
   for (let command of DOWNLOAD_VIEW_SUPPORTED_COMMANDS) {
     goUpdateCommand(command);
   }
 }
--- a/browser/components/downloads/content/allDownloadsViewOverlay.xul
+++ b/browser/components/downloads/content/allDownloadsViewOverlay.xul
@@ -37,16 +37,17 @@
   <script type="application/javascript"
           src="chrome://browser/content/downloads/allDownloadsViewOverlay.js"/>
   <script type="application/javascript"
           src="chrome://global/content/contentAreaUtils.js"/>
 
   <richlistbox flex="1"
                seltype="multiple"
                id="downloadsRichListBox" context="downloadsContextMenu"
+               onscroll="return this._placesView.onScroll();"
                onkeypress="return this._placesView.onKeyPress(event);"
                ondblclick="return this._placesView.onDoubleClick(event);"
                oncontextmenu="return this._placesView.onContextMenu(event);"
                onfocus="goUpdateDownloadCommands();"
                onselect="goUpdateDownloadCommands();"
                onblur="goUpdateDownloadCommands();"/>
 
   <commandset id="downloadCommands"
--- a/browser/components/places/content/downloadsViewOverlay.xul
+++ b/browser/components/places/content/downloadsViewOverlay.xul
@@ -14,17 +14,17 @@
 
   <script type="application/javascript"><![CDATA[
     const DOWNLOADS_QUERY = "place:transition=" +
       Components.interfaces.nsINavHistoryService.TRANSITION_DOWNLOAD +
       "&sort=" +
       Components.interfaces.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
 
     ContentArea.setContentViewForQueryString(DOWNLOADS_QUERY,
-      function() new DownloadsPlacesView(document.getElementById("downloadsRichListBox")),
+      function() new DownloadsPlacesView(document.getElementById("downloadsRichListBox"), false),
       { showDetailsPane: false,
         toolbarSet: "back-button, forward-button, organizeButton, clearDownloadsButton, libraryToolbarSpacer, searchFilter" });
   ]]></script>
 
   <window id="places">
     <commandset id="downloadCommands"/>
     <menupopup id="downloadsContextMenu"/>
   </window>
--- a/browser/components/places/content/places.js
+++ b/browser/components/places/content/places.js
@@ -269,23 +269,18 @@ var PlacesOrganizer = {
       }
     }
   },
 
   /**
    * Handle focus changes on the places list and the current content view.
    */
   updateDetailsPane: function PO_updateDetailsPane() {
-    let detailsDeck = document.getElementById("detailsDeck");
-    let detailsPaneDisabled = detailsDeck.hidden =
-      !ContentArea.currentViewOptions.showDetailsPane;
-    if (detailsPaneDisabled) {
+    if (!ContentArea.currentViewOptions.showDetailsPane)
       return;
-    }
-
     let view = PlacesUIUtils.getViewForNode(document.activeElement);
     if (view) {
       let selectedNodes = view.selectedNode ?
                           [view.selectedNode] : view.selectedNodes;
       this._fillDetailsPane(selectedNodes);
     }
   },
 
@@ -1273,17 +1268,17 @@ let ContentArea = {
         if (typeof view == "function") {
           view = view();
           this._specialViews.set(aQueryString, { view: view, options: options });
         }
         return view;
       }
     }
     catch(ex) {
-      Cu.reportError(ex);
+      Components.utils.reportError(ex);
     }
     return ContentTree.view;
   },
 
   /**
    * Sets a custom view to be used rather than the default places tree
    * whenever the given query is selected in the left pane.
    * @param aQueryString
@@ -1309,33 +1304,48 @@ let ContentArea = {
   set currentView(aView) {
     if (this.currentView != aView)
       this._deck.selectedPanel = aView.associatedElement;
     return aView;
   },
 
   get currentPlace() this.currentView.place,
   set currentPlace(aQueryString) {
-    this.currentView = this.getContentViewForQueryString(aQueryString);
-    this.currentView.place = aQueryString;
-    this._updateToolbarSet();
+    let oldView = this.currentView;
+    let newView = this.getContentViewForQueryString(aQueryString);
+    newView.place = aQueryString;
+    if (oldView != newView) {
+      oldView.active = false;
+      this.currentView = newView;
+      this._setupView();
+      newView.active = true;
+    }
     return aQueryString;
   },
 
-  _updateToolbarSet: function CA__updateToolbarSet() {
-    let toolbarSet = this.currentViewOptions.toolbarSet;
+  /**
+   * Applies view options.
+   */
+  _setupView: function CA__setupView() {
+    let options = this.currentViewOptions;
+
+    // showDetailsPane.
+    let detailsDeck = document.getElementById("detailsDeck");
+    detailsDeck.hidden = !options.showDetailsPane;
+
+    // toolbarSet.
     for (let elt of this._toolbar.childNodes) {
       // On Windows and Linux the menu buttons are menus wrapped in a menubar.
       if (elt.id == "placesMenu") {
         for (let menuElt of elt.childNodes) {
-          menuElt.hidden = toolbarSet.indexOf(menuElt.id) == -1;
+          menuElt.hidden = options.toolbarSet.indexOf(menuElt.id) == -1;
         }
       }
       else {
-        elt.hidden = toolbarSet.indexOf(elt.id) == -1;
+        elt.hidden = options.toolbarSet.indexOf(elt.id) == -1;
       }
     }
   },
 
   /**
    * Options for the current view.
    *
    * @see ContentTree.viewOptions for supported options and default values.
--- a/browser/components/places/content/tree.xml
+++ b/browser/components/places/content/tree.xml
@@ -669,16 +669,22 @@
         <parameter name="aPopup"/>
           this._contextMenuShown = false;
         <body/>
       </method>
 
       <property name="ownerWindow"
                 readonly="true"
                 onget="return window;"/>
+
+      <field name="_active">true</field>
+      <property name="active"
+                onget="return this._active"
+                onset="return this._active = val"/>
+
     </implementation>
     <handlers>
       <handler event="focus"><![CDATA[
         this._cachedInsertionPoint = undefined;
 
         // See select handler. We need the sidebar's places commandset to be
         // updated as well
         document.commandDispatcher.updateCommands("focus");