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