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 118137 303b3bb5f27fe7af58ec9bc689ad8325bac5779a
parent 118136 47821c30804eb85fbb24923e92e97dd835a5a9ff
child 118138 63cbf8539eac85de59f81a0ab2641b5762d39b01
push id24153
push useremorley@mozilla.com
push dateWed, 09 Jan 2013 13:34:18 +0000
treeherdermozilla-central@be151be0cf60 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs827405
milestone21.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 827405 - Lazily "activate" download items when they enter the visible richlistbox area. r=Mano
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");