Home | History | Annotate | Download | only in options
      1 // Copyright (c) 2011 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 cr.define('options', function() {
      6   const DeletableItemList = options.DeletableItemList;
      7   const DeletableItem = options.DeletableItem;
      8   const ArrayDataModel = cr.ui.ArrayDataModel;
      9   const ListSingleSelectionModel = cr.ui.ListSingleSelectionModel;
     10 
     11   // This structure maps the various cookie type names from C++ (hence the
     12   // underscores) to arrays of the different types of data each has, along with
     13   // the i18n name for the description of that data type.
     14   const cookieInfo = {
     15     'cookie': [ ['name', 'label_cookie_name'],
     16                 ['content', 'label_cookie_content'],
     17                 ['domain', 'label_cookie_domain'],
     18                 ['path', 'label_cookie_path'],
     19                 ['sendfor', 'label_cookie_send_for'],
     20                 ['accessibleToScript', 'label_cookie_accessible_to_script'],
     21                 ['created', 'label_cookie_created'],
     22                 ['expires', 'label_cookie_expires'] ],
     23     'app_cache': [ ['manifest', 'label_app_cache_manifest'],
     24                    ['size', 'label_local_storage_size'],
     25                    ['created', 'label_cookie_created'],
     26                    ['accessed', 'label_cookie_last_accessed'] ],
     27     'database': [ ['name', 'label_cookie_name'],
     28                   ['desc', 'label_webdb_desc'],
     29                   ['webdbSize', 'label_local_storage_size'],
     30                   ['modified', 'label_local_storage_last_modified'] ],
     31     'local_storage': [ ['origin', 'label_local_storage_origin'],
     32                        ['size', 'label_local_storage_size'],
     33                        ['modified', 'label_local_storage_last_modified'] ],
     34     'indexed_db': [ ['origin', 'label_indexed_db_origin'],
     35                     ['size', 'label_indexed_db_size'],
     36                     ['modified', 'label_indexed_db_last_modified'] ],
     37   };
     38 
     39   const localStrings = new LocalStrings();
     40 
     41   /**
     42    * Returns the item's height, like offsetHeight but such that it works better
     43    * when the page is zoomed. See the similar calculation in @{code cr.ui.List}.
     44    * This version also accounts for the animation done in this file.
     45    * @param {Element} item The item to get the height of.
     46    * @return {number} The height of the item, calculated with zooming in mind.
     47    */
     48   function getItemHeight(item) {
     49     var height = item.style.height;
     50     // Use the fixed animation target height if set, in case the element is
     51     // currently being animated and we'd get an intermediate height below.
     52     if (height && height.substr(-2) == 'px')
     53       return parseInt(height.substr(0, height.length - 2));
     54     return item.getBoundingClientRect().height;
     55   }
     56 
     57   var parentLookup = {};
     58   var lookupRequests = {};
     59 
     60   /**
     61    * Creates a new list item for sites data. Note that these are created and
     62    * destroyed lazily as they scroll into and out of view, so they must be
     63    * stateless. We cache the expanded item in @{code CookiesList} though, so it
     64    * can keep state. (Mostly just which item is selected.)
     65    * @param {Object} origin Data used to create a cookie list item.
     66    * @param {CookiesList} list The list that will contain this item.
     67    * @constructor
     68    * @extends {DeletableItem}
     69    */
     70   function CookieListItem(origin, list) {
     71     var listItem = new DeletableItem(null);
     72     listItem.__proto__ = CookieListItem.prototype;
     73 
     74     listItem.origin = origin;
     75     listItem.list = list;
     76     listItem.decorate();
     77 
     78     // This hooks up updateOrigin() to the list item, makes the top-level
     79     // tree nodes (i.e., origins) register their IDs in parentLookup, and
     80     // causes them to request their children if they have none. Note that we
     81     // have special logic in the setter for the parent property to make sure
     82     // that we can still garbage collect list items when they scroll out of
     83     // view, even though it appears that we keep a direct reference.
     84     if (origin) {
     85       origin.parent = listItem;
     86       origin.updateOrigin();
     87     }
     88 
     89     return listItem;
     90   }
     91 
     92   CookieListItem.prototype = {
     93     __proto__: DeletableItem.prototype,
     94 
     95     /** @inheritDoc */
     96     decorate: function() {
     97       this.siteChild = this.ownerDocument.createElement('div');
     98       this.siteChild.className = 'cookie-site';
     99       this.dataChild = this.ownerDocument.createElement('div');
    100       this.dataChild.className = 'cookie-data';
    101       this.itemsChild = this.ownerDocument.createElement('div');
    102       this.itemsChild.className = 'cookie-items';
    103       this.infoChild = this.ownerDocument.createElement('div');
    104       this.infoChild.className = 'cookie-details hidden';
    105       var remove = this.ownerDocument.createElement('button');
    106       remove.textContent = localStrings.getString('remove_cookie');
    107       remove.onclick = this.removeCookie_.bind(this);
    108       this.infoChild.appendChild(remove);
    109       var content = this.contentElement;
    110       content.appendChild(this.siteChild);
    111       content.appendChild(this.dataChild);
    112       content.appendChild(this.itemsChild);
    113       this.itemsChild.appendChild(this.infoChild);
    114       if (this.origin && this.origin.data) {
    115         this.siteChild.textContent = this.origin.data.title;
    116         this.siteChild.setAttribute('title', this.origin.data.title);
    117       }
    118       this.itemList_ = [];
    119     },
    120 
    121     /** @type {boolean} */
    122     get expanded() {
    123       return this.expanded_;
    124     },
    125     set expanded(expanded) {
    126       if (this.expanded_ == expanded)
    127         return;
    128       this.expanded_ = expanded;
    129       if (expanded) {
    130         var oldExpanded = this.list.expandedItem;
    131         this.list.expandedItem = this;
    132         this.updateItems_();
    133         if (oldExpanded)
    134           oldExpanded.expanded = false;
    135         this.classList.add('show-items');
    136       } else {
    137         if (this.list.expandedItem == this) {
    138           this.list.leadItemHeight = 0;
    139           this.list.expandedItem = null;
    140         }
    141         this.style.height = '';
    142         this.itemsChild.style.height = '';
    143         this.classList.remove('show-items');
    144       }
    145     },
    146 
    147     /**
    148      * The callback for the "remove" button shown when an item is selected.
    149      * Requests that the currently selected cookie be removed.
    150      * @private
    151      */
    152     removeCookie_: function() {
    153       if (this.selectedIndex_ >= 0) {
    154         var item = this.itemList_[this.selectedIndex_];
    155         if (item && item.node)
    156           chrome.send('removeCookie', [item.node.pathId]);
    157       }
    158     },
    159 
    160     /**
    161      * Disable animation within this cookie list item, in preparation for making
    162      * changes that will need to be animated. Makes it possible to measure the
    163      * contents without displaying them, to set animation targets.
    164      * @private
    165      */
    166     disableAnimation_: function() {
    167       this.itemsHeight_ = getItemHeight(this.itemsChild);
    168       this.classList.add('measure-items');
    169     },
    170 
    171     /**
    172      * Enable animation after changing the contents of this cookie list item.
    173      * See @{code disableAnimation_}.
    174      * @private
    175      */
    176     enableAnimation_: function() {
    177       if (!this.classList.contains('measure-items'))
    178         this.disableAnimation_();
    179       this.itemsChild.style.height = '';
    180       // This will force relayout in order to calculate the new heights.
    181       var itemsHeight = getItemHeight(this.itemsChild);
    182       var fixedHeight = getItemHeight(this) + itemsHeight - this.itemsHeight_;
    183       this.itemsChild.style.height = this.itemsHeight_ + 'px';
    184       // Force relayout before enabling animation, so that if we have
    185       // changed things since the last layout, they will not be animated
    186       // during subsequent layouts.
    187       this.itemsChild.offsetHeight;
    188       this.classList.remove('measure-items');
    189       this.itemsChild.style.height = itemsHeight + 'px';
    190       this.style.height = fixedHeight + 'px';
    191       if (this.expanded)
    192         this.list.leadItemHeight = fixedHeight;
    193     },
    194 
    195     /**
    196      * Updates the origin summary to reflect changes in its items.
    197      * Both CookieListItem and CookieTreeNode implement this API.
    198      * This implementation scans the descendants to update the text.
    199      */
    200     updateOrigin: function() {
    201       var info = {
    202         cookies: 0,
    203         database: false,
    204         localStorage: false,
    205         appCache: false,
    206         indexedDb: false
    207       };
    208       if (this.origin)
    209         this.origin.collectSummaryInfo(info);
    210       var list = [];
    211       if (info.cookies > 1)
    212         list.push(localStrings.getStringF('cookie_plural', info.cookies));
    213       else if (info.cookies > 0)
    214         list.push(localStrings.getString('cookie_singular'));
    215       if (info.database || info.indexedDb)
    216         list.push(localStrings.getString('cookie_database_storage'));
    217       if (info.localStorage)
    218         list.push(localStrings.getString('cookie_local_storage'));
    219       if (info.appCache)
    220         list.push(localStrings.getString('cookie_session_storage'));
    221       var text = '';
    222       for (var i = 0; i < list.length; ++i)
    223         if (text.length > 0)
    224           text += ', ' + list[i];
    225         else
    226           text = list[i];
    227       this.dataChild.textContent = text;
    228       if (this.expanded)
    229         this.updateItems_();
    230     },
    231 
    232     /**
    233      * Updates the items section to reflect changes, animating to the new state.
    234      * Removes existing contents and calls @{code CookieTreeNode.createItems}.
    235      * @private
    236      */
    237     updateItems_: function() {
    238       this.disableAnimation_();
    239       this.itemsChild.textContent = '';
    240       this.infoChild.classList.add('hidden');
    241       this.selectedIndex_ = -1;
    242       this.itemList_ = [];
    243       if (this.origin)
    244         this.origin.createItems(this);
    245       this.itemsChild.appendChild(this.infoChild);
    246       this.enableAnimation_();
    247     },
    248 
    249     /**
    250      * Append a new cookie node "bubble" to this list item.
    251      * @param {CookieTreeNode} node The cookie node to add a bubble for.
    252      * @param {Element} div The DOM element for the bubble itself.
    253      * @return {number} The index the bubble was added at.
    254      */
    255     appendItem: function(node, div) {
    256       this.itemList_.push({node: node, div: div});
    257       this.itemsChild.appendChild(div);
    258       return this.itemList_.length - 1;
    259     },
    260 
    261     /**
    262      * The currently selected cookie node ("cookie bubble") index.
    263      * @type {number}
    264      * @private
    265      */
    266     selectedIndex_: -1,
    267 
    268     /**
    269      * Get the currently selected cookie node ("cookie bubble") index.
    270      * @type {number}
    271      */
    272     get selectedIndex() {
    273       return this.selectedIndex_;
    274     },
    275 
    276     /**
    277      * Set the currently selected cookie node ("cookie bubble") index to
    278      * @{code itemIndex}, unselecting any previously selected node first.
    279      * @param {number} itemIndex The index to set as the selected index.
    280      */
    281     set selectedIndex(itemIndex) {
    282       // Get the list index up front before we change anything.
    283       var index = this.list.getIndexOfListItem(this);
    284       // Unselect any previously selected item.
    285       if (this.selectedIndex_ >= 0) {
    286         var item = this.itemList_[this.selectedIndex_];
    287         if (item && item.div)
    288           item.div.removeAttribute('selected');
    289       }
    290       // Special case: decrementing -1 wraps around to the end of the list.
    291       if (itemIndex == -2)
    292         itemIndex = this.itemList_.length - 1;
    293       // Check if we're going out of bounds and hide the item details.
    294       if (itemIndex < 0 || itemIndex >= this.itemList_.length) {
    295         this.selectedIndex_ = -1;
    296         this.disableAnimation_();
    297         this.infoChild.classList.add('hidden');
    298         this.enableAnimation_();
    299         return;
    300       }
    301       // Set the new selected item and show the item details for it.
    302       this.selectedIndex_ = itemIndex;
    303       this.itemList_[itemIndex].div.setAttribute('selected', '');
    304       this.disableAnimation_();
    305       this.itemList_[itemIndex].node.setDetailText(this.infoChild,
    306                                                    this.list.infoNodes);
    307       this.infoChild.classList.remove('hidden');
    308       this.enableAnimation_();
    309       // If we're near the bottom of the list this may cause the list item to go
    310       // beyond the end of the visible area. Fix it after the animation is done.
    311       var list = this.list;
    312       window.setTimeout(function() { list.scrollIndexIntoView(index); }, 150);
    313     },
    314   };
    315 
    316   /**
    317    * {@code CookieTreeNode}s mirror the structure of the cookie tree lazily, and
    318    * contain all the actual data used to generate the {@code CookieListItem}s.
    319    * @param {Object} data The data object for this node.
    320    * @constructor
    321    */
    322   function CookieTreeNode(data) {
    323     this.data = data;
    324     this.children = [];
    325   }
    326 
    327   CookieTreeNode.prototype = {
    328     /**
    329      * Insert a cookie tree node at the given index.
    330      * Both CookiesList and CookieTreeNode implement this API.
    331      * @param {Object} data The data object for the node to add.
    332      * @param {number} index The index at which to insert the node.
    333      */
    334     insertAt: function(data, index) {
    335       var child = new CookieTreeNode(data);
    336       this.children.splice(index, 0, child);
    337       child.parent = this;
    338       this.updateOrigin();
    339     },
    340 
    341     /**
    342      * Remove a cookie tree node from the given index.
    343      * Both CookiesList and CookieTreeNode implement this API.
    344      * @param {number} index The index of the tree node to remove.
    345      */
    346     remove: function(index) {
    347       if (index < this.children.length) {
    348         this.children.splice(index, 1);
    349         this.updateOrigin();
    350       }
    351     },
    352 
    353     /**
    354      * Clears all children.
    355      * Both CookiesList and CookieTreeNode implement this API.
    356      * It is used by CookiesList.loadChildren().
    357      */
    358     clear: function() {
    359       // We might leave some garbage in parentLookup for removed children.
    360       // But that should be OK because parentLookup is cleared when we
    361       // reload the tree.
    362       this.children = [];
    363       this.updateOrigin();
    364     },
    365 
    366     /**
    367      * The counter used by startBatchUpdates() and endBatchUpdates().
    368      * @type {number}
    369      */
    370     batchCount_: 0,
    371 
    372     /**
    373      * See cr.ui.List.startBatchUpdates().
    374      * Both CookiesList (via List) and CookieTreeNode implement this API.
    375      */
    376     startBatchUpdates: function() {
    377       this.batchCount_++;
    378     },
    379 
    380     /**
    381      * See cr.ui.List.endBatchUpdates().
    382      * Both CookiesList (via List) and CookieTreeNode implement this API.
    383      */
    384     endBatchUpdates: function() {
    385       if (!--this.batchCount_)
    386         this.updateOrigin();
    387     },
    388 
    389     /**
    390      * Requests updating the origin summary to reflect changes in this item.
    391      * Both CookieListItem and CookieTreeNode implement this API.
    392      */
    393     updateOrigin: function() {
    394       if (!this.batchCount_ && this.parent)
    395         this.parent.updateOrigin();
    396     },
    397 
    398     /**
    399      * Summarize the information in this node and update @{code info}.
    400      * This will recurse into child nodes to summarize all descendants.
    401      * @param {Object} info The info object from @{code updateOrigin}.
    402      */
    403     collectSummaryInfo: function(info) {
    404       if (this.children.length > 0) {
    405         for (var i = 0; i < this.children.length; ++i)
    406           this.children[i].collectSummaryInfo(info);
    407       } else if (this.data && !this.data.hasChildren) {
    408         if (this.data.type == 'cookie')
    409           info.cookies++;
    410         else if (this.data.type == 'database')
    411           info.database = true;
    412         else if (this.data.type == 'local_storage')
    413           info.localStorage = true;
    414         else if (this.data.type == 'app_cache')
    415           info.appCache = true;
    416         else if (this.data.type == 'indexed_db')
    417           info.indexedDb = true;
    418       }
    419     },
    420 
    421     /**
    422      * Create the cookie "bubbles" for this node, recursing into children
    423      * if there are any. Append the cookie bubbles to @{code item}.
    424      * @param {CookieListItem} item The cookie list item to create items in.
    425      */
    426     createItems: function(item) {
    427       if (this.children.length > 0) {
    428         for (var i = 0; i < this.children.length; ++i)
    429           this.children[i].createItems(item);
    430       } else if (this.data && !this.data.hasChildren) {
    431         var text = '';
    432         switch (this.data.type) {
    433           case 'cookie':
    434           case 'database':
    435             text = this.data.name;
    436             break;
    437           case 'local_storage':
    438             text = localStrings.getString('cookie_local_storage');
    439             break;
    440           case 'app_cache':
    441             text = localStrings.getString('cookie_session_storage');
    442             break;
    443           case 'indexed_db':
    444             text = localStrings.getString('cookie_indexed_db');
    445             break;
    446         }
    447         var div = item.ownerDocument.createElement('div');
    448         div.className = 'cookie-item';
    449         // Help out screen readers and such: this is a clickable thing.
    450         div.setAttribute('role', 'button');
    451         div.textContent = text;
    452         var index = item.appendItem(this, div);
    453         div.onclick = function() {
    454           if (item.selectedIndex == index)
    455             item.selectedIndex = -1;
    456           else
    457             item.selectedIndex = index;
    458         };
    459       }
    460     },
    461 
    462     /**
    463      * Set the detail text to be displayed to that of this cookie tree node.
    464      * Uses preallocated DOM elements for each cookie node type from @{code
    465      * infoNodes}, and inserts the appropriate elements to @{code element}.
    466      * @param {Element} element The DOM element to insert elements to.
    467      * @param {Object.<string, {table: Element, info: Object.<string,
    468      *     Element>}>} infoNodes The map from cookie node types to maps from
    469      *     cookie attribute names to DOM elements to display cookie attribute
    470      *     values, created by @{code CookiesList.decorate}.
    471      */
    472     setDetailText: function(element, infoNodes) {
    473       var table;
    474       if (this.data && !this.data.hasChildren) {
    475         if (cookieInfo[this.data.type]) {
    476           var info = cookieInfo[this.data.type];
    477           var nodes = infoNodes[this.data.type].info;
    478           for (var i = 0; i < info.length; ++i) {
    479             var name = info[i][0];
    480             if (name != 'id' && this.data[name])
    481               nodes[name].textContent = this.data[name];
    482           }
    483           table = infoNodes[this.data.type].table;
    484         }
    485       }
    486       while (element.childNodes.length > 1)
    487         element.removeChild(element.firstChild);
    488       if (table)
    489         element.insertBefore(table, element.firstChild);
    490     },
    491 
    492     /**
    493      * The parent of this cookie tree node.
    494      * @type {?CookieTreeNode|CookieListItem}
    495      */
    496     get parent(parent) {
    497       // See below for an explanation of this special case.
    498       if (typeof this.parent_ == 'number')
    499         return this.list_.getListItemByIndex(this.parent_);
    500       return this.parent_;
    501     },
    502     set parent(parent) {
    503       if (parent == this.parent)
    504         return;
    505       if (parent instanceof CookieListItem) {
    506         // If the parent is to be a CookieListItem, then we keep the reference
    507         // to it by its containing list and list index, rather than directly.
    508         // This allows the list items to be garbage collected when they scroll
    509         // out of view (except the expanded item, which we cache). This is
    510         // transparent except in the setter and getter, where we handle it.
    511         this.parent_ = parent.listIndex;
    512         this.list_ = parent.list;
    513         parent.addEventListener('listIndexChange',
    514                                 this.parentIndexChanged_.bind(this));
    515       } else {
    516         this.parent_ = parent;
    517       }
    518       if (this.data && this.data.id) {
    519         if (parent)
    520           parentLookup[this.data.id] = this;
    521         else
    522           delete parentLookup[this.data.id];
    523       }
    524       if (this.data && this.data.hasChildren &&
    525           !this.children.length && !lookupRequests[this.data.id]) {
    526         lookupRequests[this.data.id] = true;
    527         chrome.send('loadCookie', [this.pathId]);
    528       }
    529     },
    530 
    531     /**
    532      * Called when the parent is a CookieListItem whose index has changed.
    533      * See the code above that avoids keeping a direct reference to
    534      * CookieListItem parents, to allow them to be garbage collected.
    535      * @private
    536      */
    537     parentIndexChanged_: function(event) {
    538       if (typeof this.parent_ == 'number') {
    539         this.parent_ = event.newValue;
    540         // We set a timeout to update the origin, rather than doing it right
    541         // away, because this callback may occur while the list items are
    542         // being repopulated following a scroll event. Calling updateOrigin()
    543         // immediately could trigger relayout that would reset the scroll
    544         // position within the list, among other things.
    545         window.setTimeout(this.updateOrigin.bind(this), 0);
    546       }
    547     },
    548 
    549     /**
    550      * The cookie tree path id.
    551      * @type {string}
    552      */
    553     get pathId() {
    554       var parent = this.parent;
    555       if (parent && parent instanceof CookieTreeNode)
    556         return parent.pathId + ',' + this.data.id;
    557       return this.data.id;
    558     },
    559   };
    560 
    561   /**
    562    * Creates a new cookies list.
    563    * @param {Object=} opt_propertyBag Optional properties.
    564    * @constructor
    565    * @extends {DeletableItemList}
    566    */
    567   var CookiesList = cr.ui.define('list');
    568 
    569   CookiesList.prototype = {
    570     __proto__: DeletableItemList.prototype,
    571 
    572     /** @inheritDoc */
    573     decorate: function() {
    574       DeletableItemList.prototype.decorate.call(this);
    575       this.classList.add('cookie-list');
    576       this.data_ = [];
    577       this.dataModel = new ArrayDataModel(this.data_);
    578       this.addEventListener('keydown', this.handleKeyLeftRight_.bind(this));
    579       var sm = new ListSingleSelectionModel();
    580       sm.addEventListener('change', this.cookieSelectionChange_.bind(this));
    581       sm.addEventListener('leadIndexChange', this.cookieLeadChange_.bind(this));
    582       this.selectionModel = sm;
    583       this.infoNodes = {};
    584       var doc = this.ownerDocument;
    585       // Create a table for each type of site data (e.g. cookies, databases,
    586       // etc.) and save it so that we can reuse it for all origins.
    587       for (var type in cookieInfo) {
    588         var table = doc.createElement('table');
    589         table.className = 'cookie-details-table';
    590         var tbody = doc.createElement('tbody');
    591         table.appendChild(tbody);
    592         var info = {};
    593         for (var i = 0; i < cookieInfo[type].length; i++) {
    594           var tr = doc.createElement('tr');
    595           var name = doc.createElement('td');
    596           var data = doc.createElement('td');
    597           var pair = cookieInfo[type][i];
    598           name.className = 'cookie-details-label';
    599           name.textContent = localStrings.getString(pair[1]);
    600           data.className = 'cookie-details-value';
    601           data.textContent = '';
    602           tr.appendChild(name);
    603           tr.appendChild(data);
    604           tbody.appendChild(tr);
    605           info[pair[0]] = data;
    606         }
    607         this.infoNodes[type] = {table: table, info: info};
    608       }
    609     },
    610 
    611     /**
    612      * Handles key down events and looks for left and right arrows, then
    613      * dispatches to the currently expanded item, if any.
    614      * @param {Event} e The keydown event.
    615      * @private
    616      */
    617     handleKeyLeftRight_: function(e) {
    618       var id = e.keyIdentifier;
    619       if ((id == 'Left' || id == 'Right') && this.expandedItem) {
    620         var cs = this.ownerDocument.defaultView.getComputedStyle(this);
    621         var rtl = cs.direction == 'rtl';
    622         if ((!rtl && id == 'Left') || (rtl && id == 'Right'))
    623           this.expandedItem.selectedIndex--;
    624         else
    625           this.expandedItem.selectedIndex++;
    626         this.scrollIndexIntoView(this.expandedItem.listIndex);
    627         // Prevent the page itself from scrolling.
    628         e.preventDefault();
    629       }
    630     },
    631 
    632     /**
    633      * Called on selection model selection changes.
    634      * @param {Event} ce The selection change event.
    635      * @private
    636      */
    637     cookieSelectionChange_: function(ce) {
    638       ce.changes.forEach(function(change) {
    639           var listItem = this.getListItemByIndex(change.index);
    640           if (listItem) {
    641             if (!change.selected) {
    642               // We set a timeout here, rather than setting the item unexpanded
    643               // immediately, so that if another item gets set expanded right
    644               // away, it will be expanded before this item is unexpanded. It
    645               // will notice that, and unexpand this item in sync with its own
    646               // expansion. Later, this callback will end up having no effect.
    647               window.setTimeout(function() {
    648                 if (!listItem.selected || !listItem.lead)
    649                   listItem.expanded = false;
    650               }, 0);
    651             } else if (listItem.lead) {
    652               listItem.expanded = true;
    653             }
    654           }
    655         }, this);
    656     },
    657 
    658     /**
    659      * Called on selection model lead changes.
    660      * @param {Event} pe The lead change event.
    661      * @private
    662      */
    663     cookieLeadChange_: function(pe) {
    664       if (pe.oldValue != -1) {
    665         var listItem = this.getListItemByIndex(pe.oldValue);
    666         if (listItem) {
    667           // See cookieSelectionChange_ above for why we use a timeout here.
    668           window.setTimeout(function() {
    669             if (!listItem.lead || !listItem.selected)
    670               listItem.expanded = false;
    671           }, 0);
    672         }
    673       }
    674       if (pe.newValue != -1) {
    675         var listItem = this.getListItemByIndex(pe.newValue);
    676         if (listItem && listItem.selected)
    677           listItem.expanded = true;
    678       }
    679     },
    680 
    681     /**
    682      * The currently expanded item. Used by CookieListItem above.
    683      * @type {?CookieListItem}
    684      */
    685     expandedItem: null,
    686 
    687     // from cr.ui.List
    688     /** @inheritDoc */
    689     createItem: function(data) {
    690       // We use the cached expanded item in order to allow it to maintain some
    691       // state (like its fixed height, and which bubble is selected).
    692       if (this.expandedItem && this.expandedItem.origin == data)
    693         return this.expandedItem;
    694       return new CookieListItem(data, this);
    695     },
    696 
    697     // from options.DeletableItemList
    698     /** @inheritDoc */
    699     deleteItemAtIndex: function(index) {
    700       var item = this.data_[index];
    701       if (item) {
    702         var pathId = item.pathId;
    703         if (pathId)
    704           chrome.send('removeCookie', [pathId]);
    705       }
    706     },
    707 
    708     /**
    709      * Insert a cookie tree node at the given index.
    710      * Both CookiesList and CookieTreeNode implement this API.
    711      * @param {Object} data The data object for the node to add.
    712      * @param {number} index The index at which to insert the node.
    713      */
    714     insertAt: function(data, index) {
    715       this.dataModel.splice(index, 0, new CookieTreeNode(data));
    716     },
    717 
    718     /**
    719      * Remove a cookie tree node from the given index.
    720      * Both CookiesList and CookieTreeNode implement this API.
    721      * @param {number} index The index of the tree node to remove.
    722      */
    723     remove: function(index) {
    724       if (index < this.data_.length)
    725         this.dataModel.splice(index, 1);
    726     },
    727 
    728     /**
    729      * Clears the list.
    730      * Both CookiesList and CookieTreeNode implement this API.
    731      * It is used by CookiesList.loadChildren().
    732      */
    733     clear: function() {
    734       parentLookup = {};
    735       this.data_ = [];
    736       this.dataModel = new ArrayDataModel(this.data_);
    737       this.redraw();
    738     },
    739 
    740     /**
    741      * Add tree nodes by given parent.
    742      * Note: this method will be O(n^2) in the general case. Use it only to
    743      * populate an empty parent or to insert single nodes to avoid this.
    744      * @param {Object} parent The parent node.
    745      * @param {number} start Start index of where to insert nodes.
    746      * @param {Array} nodesData Nodes data array.
    747      * @private
    748      */
    749     addByParent_: function(parent, start, nodesData) {
    750       if (!parent)
    751         return;
    752 
    753       parent.startBatchUpdates();
    754       for (var i = 0; i < nodesData.length; ++i)
    755         parent.insertAt(nodesData[i], start + i);
    756       parent.endBatchUpdates();
    757 
    758       cr.dispatchSimpleEvent(this, 'change');
    759     },
    760 
    761     /**
    762      * Add tree nodes by parent id.
    763      * This is used by cookies_view.js.
    764      * Note: this method will be O(n^2) in the general case. Use it only to
    765      * populate an empty parent or to insert single nodes to avoid this.
    766      * @param {string} parentId Id of the parent node.
    767      * @param {number} start Start index of where to insert nodes.
    768      * @param {Array} nodesData Nodes data array.
    769      */
    770     addByParentId: function(parentId, start, nodesData) {
    771       var parent = parentId ? parentLookup[parentId] : this;
    772       this.addByParent_(parent, start, nodesData);
    773     },
    774 
    775     /**
    776      * Removes tree nodes by parent id.
    777      * This is used by cookies_view.js.
    778      * @param {string} parentId Id of the parent node.
    779      * @param {number} start Start index of nodes to remove.
    780      * @param {number} count Number of nodes to remove.
    781      */
    782     removeByParentId: function(parentId, start, count) {
    783       var parent = parentId ? parentLookup[parentId] : this;
    784       if (!parent)
    785         return;
    786 
    787       parent.startBatchUpdates();
    788       while (count-- > 0)
    789         parent.remove(start);
    790       parent.endBatchUpdates();
    791 
    792       cr.dispatchSimpleEvent(this, 'change');
    793     },
    794 
    795     /**
    796      * Loads the immediate children of given parent node.
    797      * This is used by cookies_view.js.
    798      * @param {string} parentId Id of the parent node.
    799      * @param {Array} children The immediate children of parent node.
    800      */
    801     loadChildren: function(parentId, children) {
    802       if (parentId)
    803         delete lookupRequests[parentId];
    804       var parent = parentId ? parentLookup[parentId] : this;
    805       if (!parent)
    806         return;
    807 
    808       parent.startBatchUpdates();
    809       parent.clear();
    810       this.addByParent_(parent, 0, children);
    811       parent.endBatchUpdates();
    812     },
    813   };
    814 
    815   return {
    816     CookiesList: CookiesList
    817   };
    818 });
    819