Bug 1231434 - Add 'Delete All' context menu entry to storage inspector. r=mratcliffe
authorJarda Snajdr <jsnajdr@gmail.com>
Mon, 11 Apr 2016 01:21:00 +0200
changeset 316334 8ff3c4cb70c382a99c2c46558242fce8cc971dd7
parent 316333 a003e040f00771f18c84b691a58c09a0c139f953
child 316335 8a367c93f87d97adbc8a88e97bb723f552023e8c
push id9480
push userjlund@mozilla.com
push dateMon, 25 Apr 2016 17:12:58 +0000
treeherdermozilla-aurora@0d6a91c76a9e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmratcliffe
bugs1231434
milestone48.0a1
Bug 1231434 - Add 'Delete All' context menu entry to storage inspector. r=mratcliffe
devtools/client/locales/en-US/storage.dtd
devtools/client/locales/en-US/storage.properties
devtools/client/shared/widgets/TableWidget.js
devtools/client/shared/widgets/TreeWidget.js
devtools/client/storage/storage.xul
devtools/client/storage/test/browser.ini
devtools/client/storage/test/browser_storage_cookies_delete_all.js
devtools/client/storage/test/browser_storage_delete.js
devtools/client/storage/test/browser_storage_delete_all.js
devtools/client/storage/test/browser_storage_delete_tree.js
devtools/client/storage/test/head.js
devtools/client/storage/ui.js
devtools/server/actors/storage.js
--- a/devtools/client/locales/en-US/storage.dtd
+++ b/devtools/client/locales/en-US/storage.dtd
@@ -1,8 +1,11 @@
 <!-- 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/. -->
 
 <!-- LOCALIZATION NOTE : This file contains the Storage Inspector strings. -->
 
 <!-- LOCALIZATION NOTE : Placeholder for the searchbox that allows you to filter the table items. -->
 <!ENTITY searchBox.placeholder         "Filter items">
+
+<!-- LOCALIZATION NOTE : Label of popup menu action to delete all storage items. -->
+<!ENTITY storage.popupMenu.deleteAllLabel "Delete All">
--- a/devtools/client/locales/en-US/storage.properties
+++ b/devtools/client/locales/en-US/storage.properties
@@ -114,8 +114,12 @@ storage.data.label=Data
 
 # LOCALIZATION NOTE (storage.parsedValue.label):
 # This is the heading displayed over the item parsed value in the sidebar
 storage.parsedValue.label=Parsed Value
 
 # LOCALIZATION NOTE (storage.popupMenu.deleteLabel):
 # Label of popup menu action to delete storage item.
 storage.popupMenu.deleteLabel=Delete ā€œ%Sā€
+
+# LOCALIZATION NOTE (storage.popupMenu.deleteAllLabel):
+# Label of popup menu action to delete all storage items.
+storage.popupMenu.deleteAllFromLabel=Delete All From ā€œ%Sā€
--- a/devtools/client/shared/widgets/TableWidget.js
+++ b/devtools/client/shared/widgets/TableWidget.js
@@ -798,16 +798,19 @@ TableWidget.prototype = {
 
   /**
    * Removes the row associated with the `item` object.
    */
   remove: function(item) {
     if (typeof item == "string") {
       item = this.items.get(item);
     }
+    if (!item) {
+      return;
+    }
     let removed = this.items.delete(item[this.uniqueId]);
 
     if (!removed) {
       return;
     }
     for (let column of this.columns.values()) {
       column.remove(item);
       column.updateZebra();
--- a/devtools/client/shared/widgets/TreeWidget.js
+++ b/devtools/client/shared/widgets/TreeWidget.js
@@ -12,30 +12,33 @@ const EventEmitter = require("devtools/s
 /**
  * A tree widget with keyboard navigation and collapsable structure.
  *
  * @param {nsIDOMNode} node
  *        The container element for the tree widget.
  * @param {Object} options
  *        - emptyText {string}: text to display when no entries in the table.
  *        - defaultType {string}: The default type of the tree items. For ex.
- *        'js'
+ *          'js'
  *        - sorted {boolean}: Defaults to true. If true, tree items are kept in
- *        lexical order. If false, items will be kept in insertion order.
+ *          lexical order. If false, items will be kept in insertion order.
+ *        - contextMenuId {string}: ID of context menu to be displayed on
+ *          tree items.
  */
 function TreeWidget(node, options = {}) {
   EventEmitter.decorate(this);
 
   this.document = node.ownerDocument;
   this.window = this.document.defaultView;
   this._parent = node;
 
   this.emptyText = options.emptyText || "";
   this.defaultType = options.defaultType;
   this.sorted = options.sorted !== false;
+  this.contextMenuId = options.contextMenuId;
 
   this.setupRoot();
 
   this.placeholder = this.document.createElementNS(HTML_NS, "label");
   this.placeholder.className = "tree-widget-empty-text";
   this._parent.appendChild(this.placeholder);
 
   if (this.emptyText) {
@@ -48,40 +51,41 @@ function TreeWidget(node, options = {}) 
 TreeWidget.prototype = {
 
   _selectedLabel: null,
   _selectedItem: null,
 
   /**
    * Select any node in the tree.
    *
-   * @param {array} id
+   * @param {array} ids
    *        An array of ids leading upto the selected item
    */
-  set selectedItem(id) {
+  set selectedItem(ids) {
     if (this._selectedLabel) {
       this._selectedLabel.classList.remove("theme-selected");
     }
     let currentSelected = this._selectedLabel;
-    if (id == -1) {
+    if (ids == -1) {
       this._selectedLabel = this._selectedItem = null;
       return;
     }
-    if (!Array.isArray(id)) {
+    if (!Array.isArray(ids)) {
       return;
     }
-    this._selectedLabel = this.root.setSelectedItem(id);
+    this._selectedLabel = this.root.setSelectedItem(ids);
     if (!this._selectedLabel) {
       this._selectedItem = null;
     } else {
       if (currentSelected != this._selectedLabel) {
         this.ensureSelectedVisible();
       }
-      this._selectedItem =
-      JSON.parse(this._selectedLabel.parentNode.getAttribute("data-id"));
+      this._selectedItem = ids;
+      this.emit("select", this._selectedItem,
+        this.attachments.get(JSON.stringify(ids)));
     }
   },
 
   /**
    * Gets the selected item in the tree.
    *
    * @return {array}
    *        An array of ids leading upto the selected item
@@ -115,19 +119,26 @@ TreeWidget.prototype = {
     this.root = null;
   },
 
   /**
    * Sets up the root container of the TreeWidget.
    */
   setupRoot: function() {
     this.root = new TreeItem(this.document);
+    if (this.contextMenuId) {
+      this.root.children.addEventListener("contextmenu", (event) => {
+        let menu = this.document.getElementById(this.contextMenuId);
+        menu.openPopupAtScreen(event.screenX, event.screenY, true);
+      });
+    }
+
     this._parent.appendChild(this.root.children);
 
-    this.root.children.addEventListener("click", e => this.onClick(e));
+    this.root.children.addEventListener("mousedown", e => this.onClick(e));
     this.root.children.addEventListener("keypress", e => this.onKeypress(e));
   },
 
   /**
    * Sets the text to be shown when no node is present in the tree
    */
   setPlaceholderText: function(text) {
     this.placeholder.textContent = text;
@@ -310,39 +321,34 @@ TreeWidget.prototype = {
       if (target == this.root.children) {
         return;
       }
       target = target.parentNode;
     }
     if (!target) {
       return;
     }
+
     if (target.hasAttribute("expanded")) {
       target.removeAttribute("expanded");
     } else {
       target.setAttribute("expanded", "true");
     }
-    if (this._selectedLabel) {
-      this._selectedLabel.classList.remove("theme-selected");
-    }
+
     if (this._selectedLabel != target) {
       let ids = target.parentNode.getAttribute("data-id");
-      this._selectedItem = JSON.parse(ids);
-      this.emit("select", this._selectedItem, this.attachments.get(ids));
-      this._selectedLabel = target;
+      this.selectedItem = JSON.parse(ids);
     }
-    target.classList.add("theme-selected");
   },
 
   /**
    * Keypress handler for this tree. Used to select next and previous visible
    * items, as well as collapsing and expanding any item.
    */
   onKeypress: function(event) {
-    let currentSelected = this._selectedLabel;
     switch (event.keyCode) {
       case event.DOM_VK_UP:
         this.selectPreviousItem();
         break;
 
       case event.DOM_VK_DOWN:
         this.selectNextItem();
         break;
@@ -362,21 +368,16 @@ TreeWidget.prototype = {
         } else {
           this.selectPreviousItem();
         }
         break;
 
       default: return;
     }
     event.preventDefault();
-    if (this._selectedLabel != currentSelected) {
-      let ids = JSON.stringify(this._selectedItem);
-      this.emit("select", this._selectedItem, this.attachments.get(ids));
-      this.ensureSelectedVisible();
-    }
   },
 
   /**
    * Scrolls the viewport of the tree so that the selected item is always
    * visible.
    */
   ensureSelectedVisible: function() {
     let {top, bottom} = this._selectedLabel.getBoundingClientRect();
--- a/devtools/client/storage/storage.xul
+++ b/devtools/client/storage/storage.xul
@@ -18,18 +18,25 @@
 
   <script type="application/javascript;version=1.8"
           src="chrome://devtools/content/shared/theme-switching.js"/>
   <script type="text/javascript" src="chrome://global/content/globalOverlay.js"/>
 
   <commandset id="editMenuCommands"/>
 
   <popupset id="storagePopupSet">
+    <menupopup id="storage-tree-popup">
+      <menuitem id="storage-tree-popup-delete-all"
+                label="&storage.popupMenu.deleteAllLabel;"/>
+    </menupopup>
     <menupopup id="storage-table-popup">
       <menuitem id="storage-table-popup-delete"/>
+      <menuitem id="storage-table-popup-delete-all-from"/>
+      <menuitem id="storage-table-popup-delete-all"
+                label="&storage.popupMenu.deleteAllLabel;"/>
     </menupopup>
   </popupset>
 
   <box flex="1" class="devtools-responsive-container theme-body">
     <vbox id="storage-tree"/>
     <splitter class="devtools-side-splitter"/>
     <vbox flex="1">
       <hbox id="storage-toolbar" class="devtools-toolbar">
--- a/devtools/client/storage/test/browser.ini
+++ b/devtools/client/storage/test/browser.ini
@@ -10,20 +10,23 @@ support-files =
   storage-search.html
   storage-secured-iframe.html
   storage-sessionstorage.html
   storage-unsecured-iframe.html
   storage-updates.html
   head.js
 
 [browser_storage_basic.js]
+[browser_storage_cookies_delete_all.js]
 [browser_storage_cookies_edit.js]
 [browser_storage_cookies_edit_keyboard.js]
 [browser_storage_cookies_tab_navigation.js]
 [browser_storage_dynamic_updates.js]
 [browser_storage_localstorage_edit.js]
 [browser_storage_delete.js]
+[browser_storage_delete_all.js]
+[browser_storage_delete_tree.js]
 [browser_storage_overflow.js]
 [browser_storage_search.js]
 skip-if = os == "linux" && e10s # Bug 1240804 - unhandled promise rejections
 [browser_storage_sessionstorage_edit.js]
 [browser_storage_sidebar.js]
 [browser_storage_values.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_cookies_delete_all.js
@@ -0,0 +1,74 @@
+/* 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/. */
+
+/* import-globals-from head.js */
+
+"use strict";
+
+// Test deleting all cookies
+
+function* performDelete(store, rowName, deleteAll) {
+  let contextMenu = gPanelWindow.document.getElementById(
+    "storage-table-popup");
+  let menuDeleteAllItem = contextMenu.querySelector(
+    "#storage-table-popup-delete-all");
+  let menuDeleteAllFromItem = contextMenu.querySelector(
+    "#storage-table-popup-delete-all-from");
+
+  let storeName = store.join(" > ");
+
+  yield selectTreeItem(store);
+
+  let eventWait = gUI.once("store-objects-updated");
+
+  let cells = getRowCells(rowName);
+  yield waitForContextMenu(contextMenu, cells.name, () => {
+    info(`Opened context menu in ${storeName}, row '${rowName}'`);
+    if (deleteAll) {
+      menuDeleteAllItem.click();
+    } else {
+      menuDeleteAllFromItem.click();
+      let hostName = cells.host.value;
+      ok(menuDeleteAllFromItem.getAttribute("label").includes(hostName),
+        `Context menu item label contains '${hostName}'`);
+    }
+  });
+
+  yield eventWait;
+}
+
+add_task(function* () {
+  yield openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+  info("test state before delete");
+  yield checkState([
+    [["cookies", "test1.example.org"], ["c1", "c3", "cs2", "uc1"]],
+    [["cookies", "sectest1.example.org"], ["cs2", "sc1", "uc1"]],
+  ]);
+
+  info("delete all from domain");
+  // delete only cookies that match the host exactly
+  yield performDelete(["cookies", "test1.example.org"], "c1", false);
+
+  info("test state after delete all from domain");
+  yield checkState([
+    // Domain cookies (.example.org) must not be deleted.
+    [["cookies", "test1.example.org"], ["cs2", "uc1"]],
+    [["cookies", "sectest1.example.org"], ["cs2", "sc1", "uc1"]],
+  ]);
+
+  info("delete all");
+  // delete all cookies for host, including domain cookies
+  yield performDelete(["cookies", "sectest1.example.org"], "uc1", true);
+
+  info("test state after delete all");
+  yield checkState([
+    // Domain cookies (.example.org) are deleted too, so deleting in sectest1
+    // also removes stuff from test1.
+    [["cookies", "test1.example.org"], []],
+    [["cookies", "sectest1.example.org"], []],
+  ]);
+
+  yield finishTests();
+});
--- a/devtools/client/storage/test/browser_storage_delete.js
+++ b/devtools/client/storage/test/browser_storage_delete.js
@@ -23,27 +23,28 @@ add_task(function* () {
   let contextMenu = gPanelWindow.document.getElementById("storage-table-popup");
   let menuDeleteItem = contextMenu.querySelector("#storage-table-popup-delete");
 
   for (let [ [store, host], rowName, cellToClick] of TEST_CASES) {
     info(`Selecting tree item ${store} > ${host}`);
     yield selectTreeItem([store, host]);
 
     let row = getRowCells(rowName);
-
     ok(gUI.table.items.has(rowName),
       `There is a row '${rowName}' in ${store} > ${host}`);
 
+    let eventWait = gUI.once("store-objects-updated");
+
     yield waitForContextMenu(contextMenu, row[cellToClick], () => {
       info(`Opened context menu in ${store} > ${host}, row '${rowName}'`);
       menuDeleteItem.click();
       ok(menuDeleteItem.getAttribute("label").includes(rowName),
         `Context menu item label contains '${rowName}'`);
     });
 
-    yield gUI.once("store-objects-updated");
+    yield eventWait;
 
     ok(!gUI.table.items.has(rowName),
       `There is no row '${rowName}' in ${store} > ${host} after deletion`);
   }
 
   yield finishTests();
 });
new file mode 100644
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_delete_all.js
@@ -0,0 +1,79 @@
+/* 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/. */
+
+/* import-globals-from head.js */
+
+"use strict";
+
+// Test deleting all storage items
+
+add_task(function* () {
+  yield openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+  let contextMenu = gPanelWindow.document.getElementById("storage-table-popup");
+  let menuDeleteAllItem = contextMenu.querySelector(
+    "#storage-table-popup-delete-all");
+
+  info("test state before delete");
+  const beforeState = [
+    [["localStorage", "http://test1.example.org"],
+      ["ls1", "ls2"]],
+    [["localStorage", "http://sectest1.example.org"],
+      ["iframe-u-ls1"]],
+    [["localStorage", "https://sectest1.example.org"],
+      ["iframe-s-ls1"]],
+    [["sessionStorage", "http://test1.example.org"],
+      ["ss1"]],
+    [["sessionStorage", "http://sectest1.example.org"],
+      ["iframe-u-ss1", "iframe-u-ss2"]],
+    [["sessionStorage", "https://sectest1.example.org"],
+      ["iframe-s-ss1"]],
+  ];
+
+  yield checkState(beforeState);
+
+  info("do the delete");
+  const deleteHosts = [
+    [["localStorage", "https://sectest1.example.org"], "iframe-s-ls1"],
+    [["sessionStorage", "https://sectest1.example.org"], "iframe-s-ss1"],
+  ];
+
+  for (let [store, rowName] of deleteHosts) {
+    let storeName = store.join(" > ");
+
+    yield selectTreeItem(store);
+
+    let eventWait = gUI.once("store-objects-cleared");
+
+    let cell = getRowCells(rowName).name;
+    yield waitForContextMenu(contextMenu, cell, () => {
+      info(`Opened context menu in ${storeName}, row '${rowName}'`);
+      menuDeleteAllItem.click();
+    });
+
+    yield eventWait;
+  }
+
+  info("test state after delete");
+  const afterState = [
+    // iframes from the same host, one secure, one unsecure, are independent
+    // from each other. Delete all in one doesn't touch the other one.
+    [["localStorage", "http://test1.example.org"],
+      ["ls1", "ls2"]],
+    [["localStorage", "http://sectest1.example.org"],
+      ["iframe-u-ls1"]],
+    [["localStorage", "https://sectest1.example.org"],
+      []],
+    [["sessionStorage", "http://test1.example.org"],
+      ["ss1"]],
+    [["sessionStorage", "http://sectest1.example.org"],
+      ["iframe-u-ss1", "iframe-u-ss2"]],
+    [["sessionStorage", "https://sectest1.example.org"],
+      []],
+  ];
+
+  yield checkState(afterState);
+
+  yield finishTests();
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_delete_tree.js
@@ -0,0 +1,60 @@
+/* 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/. */
+
+/* import-globals-from head.js */
+
+"use strict";
+
+// Test deleting all storage items from the tree.
+
+add_task(function* () {
+  yield openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html");
+
+  let contextMenu = gPanelWindow.document.getElementById("storage-tree-popup");
+  let menuDeleteAllItem = contextMenu.querySelector(
+    "#storage-tree-popup-delete-all");
+
+  info("test state before delete");
+  yield checkState([
+    [["cookies", "test1.example.org"], ["c1", "c3", "cs2", "uc1"]],
+    [["localStorage", "http://test1.example.org"], ["ls1", "ls2"]],
+    [["sessionStorage", "http://test1.example.org"], ["ss1"]],
+  ]);
+
+  info("do the delete");
+  const deleteHosts = [
+    ["cookies", "test1.example.org"],
+    ["localStorage", "http://test1.example.org"],
+    ["sessionStorage", "http://test1.example.org"],
+  ];
+
+  for (let store of deleteHosts) {
+    let storeName = store.join(" > ");
+
+    yield selectTreeItem(store);
+
+    let eventName = "store-objects-" +
+      (store[0] == "cookies" ? "updated" : "cleared");
+    let eventWait = gUI.once(eventName);
+
+    let selector = `[data-id='${JSON.stringify(store)}'] > .tree-widget-item`;
+    let target = gPanelWindow.document.querySelector(selector);
+    ok(target, `tree item found in ${storeName}`);
+    yield waitForContextMenu(contextMenu, target, () => {
+      info(`Opened tree context menu in ${storeName}`);
+      menuDeleteAllItem.click();
+    });
+
+    yield eventWait;
+  }
+
+  info("test state after delete");
+  yield checkState([
+    [["cookies", "test1.example.org"], []],
+    [["localStorage", "http://test1.example.org"], []],
+    [["sessionStorage", "http://test1.example.org"], []],
+  ]);
+
+  yield finishTests();
+});
--- a/devtools/client/storage/test/head.js
+++ b/devtools/client/storage/test/head.js
@@ -504,27 +504,23 @@ function matchVariablesViewProperty(prop
 
 /**
  * Click selects a row in the table.
  *
  * @param {[String]} ids
  *        The array id of the item in the tree
  */
 function* selectTreeItem(ids) {
-  // Expand tree as some/all items could be collapsed leading to click on an
-  // incorrect tree item
-  gUI.tree.expandAll();
-
-  let selector = "[data-id='" + JSON.stringify(ids) + "'] > .tree-widget-item";
-  let target = gPanelWindow.document.querySelector(selector);
-  ok(target, "tree item found with ids " + JSON.stringify(ids));
+  /* If this item is already selected, return */
+  if (gUI.tree.isSelected(ids)) {
+    return;
+  }
 
   let updated = gUI.once("store-objects-updated");
-
-  yield click(target);
+  gUI.tree.selectedItem = ids;
   yield updated;
 }
 
 /**
  * Click selects a row in the table.
  *
  * @param {String} id
  *        The id of the row in the table widget
@@ -840,13 +836,40 @@ function waitForContextMenu(popup, butto
     onHidden && onHidden();
 
     deferred.resolve(popup);
   }
 
   popup.addEventListener("popupshown", onPopupShown);
 
   info("wait for the context menu to open");
+  button.scrollIntoView();
   let eventDetails = {type: "contextmenu", button: 2};
   EventUtils.synthesizeMouse(button, 2, 2, eventDetails,
                              button.ownerDocument.defaultView);
   return deferred.promise;
 }
+
+/**
+ * Verify the storage inspector state: check that given type/host exists
+ * in the tree, and that the table contains rows with specified names.
+ *
+ * @param {Array} state Array of state specifications. For example,
+ *        [["cookies", "example.com"], ["c1", "c2"]] means to select the
+ *        "example.com" host in cookies and then verify there are "c1" and "c2"
+ *        cookies (and no other ones).
+ */
+function* checkState(state) {
+  for (let [store, names] of state) {
+    let storeName = store.join(" > ");
+    info(`Selecting tree item ${storeName}`);
+    yield selectTreeItem(store);
+
+    let items = gUI.table.items;
+
+    is(items.size, names.length,
+      `There is correct number of rows in ${storeName}`);
+    for (let name of names) {
+      ok(items.has(name),
+        `There is item with name '${name}' in ${storeName}`);
+    }
+  }
+}
--- a/devtools/client/storage/ui.js
+++ b/devtools/client/storage/ui.js
@@ -65,17 +65,20 @@ var StorageUI = this.StorageUI = functio
   EventEmitter.decorate(this);
 
   this._target = target;
   this._window = panelWin;
   this._panelDoc = panelWin.document;
   this.front = front;
 
   let treeNode = this._panelDoc.getElementById("storage-tree");
-  this.tree = new TreeWidget(treeNode, {defaultType: "dir"});
+  this.tree = new TreeWidget(treeNode, {
+    defaultType: "dir",
+    contextMenuId: "storage-tree-popup"
+  });
   this.onHostSelect = this.onHostSelect.bind(this);
   this.tree.on("select", this.onHostSelect);
 
   let tableNode = this._panelDoc.getElementById("storage-table");
   this.table = new TableWidget(tableNode, {
     emptyText: L10N.getStr("table.emptyText"),
     highlightUpdated: true,
     cellContextMenuId: "storage-table-popup"
@@ -106,32 +109,51 @@ var StorageUI = this.StorageUI = functio
   this.onUpdate = this.onUpdate.bind(this);
   this.front.on("stores-update", this.onUpdate);
   this.onCleared = this.onCleared.bind(this);
   this.front.on("stores-cleared", this.onCleared);
 
   this.handleKeypress = this.handleKeypress.bind(this);
   this._panelDoc.addEventListener("keypress", this.handleKeypress);
 
-  this.onPopupShowing = this.onPopupShowing.bind(this);
+  this.onTreePopupShowing = this.onTreePopupShowing.bind(this);
+  this._treePopup = this._panelDoc.getElementById("storage-tree-popup");
+  this._treePopup.addEventListener("popupshowing", this.onTreePopupShowing);
+
+  this.onTablePopupShowing = this.onTablePopupShowing.bind(this);
   this._tablePopup = this._panelDoc.getElementById("storage-table-popup");
-  this._tablePopup.addEventListener("popupshowing", this.onPopupShowing, false);
+  this._tablePopup.addEventListener("popupshowing", this.onTablePopupShowing);
 
   this.onRemoveItem = this.onRemoveItem.bind(this);
+  this.onRemoveAllFrom = this.onRemoveAllFrom.bind(this);
+  this.onRemoveAll = this.onRemoveAll.bind(this);
+
   this._tablePopupDelete = this._panelDoc.getElementById(
     "storage-table-popup-delete");
-  this._tablePopupDelete.addEventListener("command", this.onRemoveItem, false);
+  this._tablePopupDelete.addEventListener("command", this.onRemoveItem);
+
+  this._tablePopupDeleteAllFrom = this._panelDoc.getElementById(
+    "storage-table-popup-delete-all-from");
+  this._tablePopupDeleteAllFrom.addEventListener("command",
+    this.onRemoveAllFrom);
+
+  this._tablePopupDeleteAll = this._panelDoc.getElementById(
+    "storage-table-popup-delete-all");
+  this._tablePopupDeleteAll.addEventListener("command", this.onRemoveAll);
+
+  this._treePopupDeleteAll = this._panelDoc.getElementById(
+    "storage-tree-popup-delete-all");
+  this._treePopupDeleteAll.addEventListener("command", this.onRemoveAll);
 };
 
 exports.StorageUI = StorageUI;
 
 StorageUI.prototype = {
 
   storageTypes: null,
-  shouldResetColumns: true,
   shouldLoadMoreItems: true,
 
   set animationsEnabled(value) {
     this._panelDoc.documentElement.classList.toggle("no-animate", !value);
   },
 
   destroy: function() {
     this.table.off(TableWidget.EVENTS.ROW_SELECTED, this.displayObjectSidebar);
@@ -140,18 +162,26 @@ StorageUI.prototype = {
     this.table.destroy();
 
     this.front.off("stores-update", this.onUpdate);
     this.front.off("stores-cleared", this.onCleared);
     this._panelDoc.removeEventListener("keypress", this.handleKeypress);
     this.searchBox.removeEventListener("input", this.filterItems);
     this.searchBox = null;
 
-    this._tablePopup.removeEventListener("popupshowing", this.onPopupShowing);
+    this._treePopup.removeEventListener("popupshowing",
+      this.onTreePopupShowing);
+    this._treePopupDeleteAll.removeEventListener("command", this.onRemoveAll);
+
+    this._tablePopup.removeEventListener("popupshowing",
+      this.onTablePopupShowing);
     this._tablePopupDelete.removeEventListener("command", this.onRemoveItem);
+    this._tablePopupDeleteAllFrom.removeEventListener("command",
+      this.onRemoveAllFrom);
+    this._tablePopupDeleteAll.removeEventListener("command", this.onRemoveAll);
   },
 
   /**
    * Empties and hides the object viewer sidebar
    */
   hideSidebar: function() {
     this.view.empty();
     this.sidebar.hidden = true;
@@ -386,20 +416,20 @@ StorageUI.prototype = {
       throw new Error("Invalid reason specified");
     }
 
     storageType.getStoreObjects(host, names, fetchOpts).then(({data}) => {
       if (!data.length) {
         this.emit("store-objects-updated");
         return;
       }
-      if (this.shouldResetColumns) {
+      if (reason === REASON.POPULATE) {
         this.resetColumns(data[0], type);
+        this.table.host = host;
       }
-      this.table.host = host;
       this.populateTable(data, reason);
       this.emit("store-objects-updated");
 
       this.makeFieldsEditable();
     }, Cu.reportError);
   },
 
   /**
@@ -432,25 +462,23 @@ StorageUI.prototype = {
       for (let host in storageTypes[type].hosts) {
         this.tree.add([type, {id: host, type: "url"}]);
         for (let name of storageTypes[type].hosts[host]) {
           try {
             let names = JSON.parse(name);
             this.tree.add([type, host, ...names]);
             if (!this.tree.selectedItem) {
               this.tree.selectedItem = [type, host, names[0], names[1]];
-              this.fetchStorageObjects(type, host, [name], REASON.POPULATE);
             }
           } catch (ex) {
             // Do Nothing
           }
         }
         if (!this.tree.selectedItem) {
           this.tree.selectedItem = [type, host];
-          this.fetchStorageObjects(type, host, null, REASON.POPULATE);
         }
       }
     }
   },
 
   /**
    * Populates the selected entry from teh table in the sidebar for a more
    * detailed view.
@@ -620,17 +648,16 @@ StorageUI.prototype = {
     let [type, host] = item;
     let names = null;
     if (!host) {
       return;
     }
     if (item.length > 2) {
       names = [JSON.stringify(item.slice(2))];
     }
-    this.shouldResetColumns = true;
     this.fetchStorageObjects(type, host, names, REASON.POPULATE);
     this.itemOffset = 0;
   },
 
   /**
    * Resets the column headers in the storage table with the pased object `data`
    *
    * @param {object} data
@@ -652,17 +679,16 @@ StorageUI.prototype = {
         columns[key] = L10N.getStr("table.headers." + type + "." + key);
       } catch (e) {
         console.error("Unable to localize table header type:" + type +
                       " key:" + key);
       }
     }
     this.table.setColumns(columns, null, HIDDEN_COLUMNS);
     this.table.datatype = type;
-    this.shouldResetColumns = false;
     this.hideSidebar();
   },
 
   /**
    * Populates or updates the rows in the storage table.
    *
    * @param {array[object]} data
    *        Array of objects to be populated in the storage table
@@ -752,39 +778,96 @@ StorageUI.prototype = {
     this.fetchStorageObjects(type, host, names, REASON.NEXT_50_ITEMS);
   },
 
   /**
    * Fires before a cell context menu with the "Delete" action is shown.
    * If the current storage actor doesn't support removing items, prevent
    * showing the menu.
    */
-  onPopupShowing: function(event) {
+  onTablePopupShowing: function(event) {
     if (!this.getCurrentActor().removeItem) {
       event.preventDefault();
       return;
     }
 
+    const maxLen = ITEM_NAME_MAX_LENGTH;
+    let [type] = this.tree.selectedItem;
     let rowId = this.table.contextMenuRowId;
     let data = this.table.items.get(rowId);
     let name = data[this.table.uniqueId];
 
-    const maxLen = ITEM_NAME_MAX_LENGTH;
     if (name.length > maxLen) {
       name = name.substr(0, maxLen) + L10N.ellipsis;
     }
 
     this._tablePopupDelete.setAttribute("label",
       L10N.getFormatStr("storage.popupMenu.deleteLabel", name));
+
+    if (type === "cookies") {
+      let host = data.host;
+      if (host.length > maxLen) {
+        host = host.substr(0, maxLen) + L10N.ellipsis;
+      }
+
+      this._tablePopupDeleteAllFrom.hidden = false;
+      this._tablePopupDeleteAllFrom.setAttribute("label",
+        L10N.getFormatStr("storage.popupMenu.deleteAllFromLabel", host));
+    } else {
+      this._tablePopupDeleteAllFrom.hidden = true;
+    }
+  },
+
+  onTreePopupShowing: function(event) {
+    let showMenu = false;
+    let selectedItem = this.tree.selectedItem;
+    // Never show menu on the 1st level item
+    if (selectedItem && selectedItem.length > 1) {
+      // this.currentActor() would return wrong value here
+      let actor = this.storageTypes[selectedItem[0]];
+      if (actor.removeAll) {
+        showMenu = true;
+      }
+    }
+
+    if (!showMenu) {
+      event.preventDefault();
+    }
   },
 
   /**
    * Handles removing an item from the storage
    */
   onRemoveItem: function() {
     let [, host] = this.tree.selectedItem;
     let actor = this.getCurrentActor();
     let rowId = this.table.contextMenuRowId;
     let data = this.table.items.get(rowId);
 
     actor.removeItem(host, data[this.table.uniqueId]);
   },
+
+  /**
+   * Handles removing all items from the storage
+   */
+  onRemoveAll: function() {
+    // Cannot use this.currentActor() if the handler is called from the
+    // tree context menu: it returns correct value only after the table
+    // data from server are successfully fetched (and that's async).
+    let [type, host] = this.tree.selectedItem;
+    let actor = this.storageTypes[type];
+
+    actor.removeAll(host);
+  },
+
+  /**
+   * Handles removing all cookies with exactly the same domain as the
+   * cookie in the selected row.
+   */
+  onRemoveAllFrom: function() {
+    let [, host] = this.tree.selectedItem;
+    let actor = this.getCurrentActor();
+    let rowId = this.table.contextMenuRowId;
+    let data = this.table.items.get(rowId);
+
+    actor.removeAll(host, data.host);
+  },
 };
--- a/devtools/server/actors/storage.js
+++ b/devtools/server/actors/storage.js
@@ -681,31 +681,48 @@ StorageActors.createActor({
     },
     response: {}
   }),
 
   removeItem: method(Task.async(function*(host, name) {
     this.removeCookie(host, name);
   }), {
     request: {
-      host: Arg(0),
-      name: Arg(1),
+      host: Arg(0, "string"),
+      name: Arg(1, "string"),
+    },
+    response: {}
+  }),
+
+  removeAll: method(Task.async(function*(host, domain) {
+    this.removeAllCookies(host, domain);
+  }), {
+    request: {
+      host: Arg(0, "string"),
+      domain: Arg(1, "nullable:string")
     },
     response: {}
   }),
 
   maybeSetupChildProcess: function() {
     cookieHelpers.onCookieChanged = this.onCookieChanged.bind(this);
 
     if (!DebuggerServer.isInChildProcess) {
-      this.getCookiesFromHost = cookieHelpers.getCookiesFromHost;
-      this.addCookieObservers = cookieHelpers.addCookieObservers;
-      this.removeCookieObservers = cookieHelpers.removeCookieObservers;
-      this.editCookie = cookieHelpers.editCookie;
-      this.removeCookie = cookieHelpers.removeCookie;
+      this.getCookiesFromHost =
+        cookieHelpers.getCookiesFromHost.bind(cookieHelpers);
+      this.addCookieObservers =
+        cookieHelpers.addCookieObservers.bind(cookieHelpers);
+      this.removeCookieObservers =
+        cookieHelpers.removeCookieObservers.bind(cookieHelpers);
+      this.editCookie =
+        cookieHelpers.editCookie.bind(cookieHelpers);
+      this.removeCookie =
+        cookieHelpers.removeCookie.bind(cookieHelpers);
+      this.removeAllCookies =
+        cookieHelpers.removeAllCookies.bind(cookieHelpers);
       return;
     }
 
     const { sendSyncMessage, addMessageListener } =
       this.conn.parentMessageManager;
 
     this.conn.setupInParent({
       module: "devtools/server/actors/storage",
@@ -717,16 +734,18 @@ StorageActors.createActor({
     this.addCookieObservers =
       callParentProcess.bind(null, "addCookieObservers");
     this.removeCookieObservers =
       callParentProcess.bind(null, "removeCookieObservers");
     this.editCookie =
       callParentProcess.bind(null, "editCookie");
     this.removeCookie =
       callParentProcess.bind(null, "removeCookie");
+    this.removeAllCookies =
+      callParentProcess.bind(null, "removeAllCookies");
 
     addMessageListener("storage:storage-cookie-request-child",
                        cookieHelpers.handleParentRequest);
 
     function callParentProcess(methodName, ...args) {
       let reply = sendSyncMessage("storage:storage-cookie-request-parent", {
         method: methodName,
         args: args
@@ -870,42 +889,54 @@ var cookieHelpers = {
       cookie.value,
       cookie.isSecure,
       cookie.isHttpOnly,
       cookie.isSession,
       cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires
     );
   },
 
-  removeCookie: function(host, name) {
+  _removeCookies: function(host, opts = {}) {
     function hostMatches(cookieHost, matchHost) {
       if (cookieHost == null) {
         return matchHost == null;
       }
       if (cookieHost.startsWith(".")) {
         return matchHost.endsWith(cookieHost);
       }
       return cookieHost == host;
     }
 
     let enumerator = Services.cookies.getCookiesFromHost(host);
     while (enumerator.hasMoreElements()) {
       let cookie = enumerator.getNext().QueryInterface(Ci.nsICookie2);
-      if (hostMatches(cookie.host, host) && cookie.name === name) {
+      if (hostMatches(cookie.host, host) &&
+          (!opts.name || cookie.name === opts.name) &&
+          (!opts.domain || cookie.host === opts.domain)) {
         Services.cookies.remove(
           cookie.host,
           cookie.name,
           cookie.path,
           false,
           cookie.originAttributes
         );
       }
     }
   },
 
+  removeCookie: function(host, name) {
+    if (name !== undefined) {
+      this._removeCookies(host, { name });
+    }
+  },
+
+  removeAllCookies: function(host, domain) {
+    this._removeCookies(host, { domain });
+  },
+
   addCookieObservers: function() {
     Services.obs.addObserver(cookieHelpers, "cookie-changed", false);
     return null;
   },
 
   removeCookieObservers: function() {
     Services.obs.removeObserver(cookieHelpers, "cookie-changed", false);
     return null;
@@ -964,16 +995,21 @@ var cookieHelpers = {
         let rowdata = msg.data.args[0];
         return cookieHelpers.editCookie(rowdata);
       }
       case "removeCookie": {
         let host = msg.data.args[0];
         let name = msg.data.args[1];
         return cookieHelpers.removeCookie(host, name);
       }
+      case "removeAllCookies": {
+        let host = msg.data.args[0];
+        let domain = msg.data.args[1];
+        return cookieHelpers.removeAllCookies(host, domain);
+      }
       default:
         console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method);
         throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD");
     }
   },
 };
 
 /**
@@ -1175,16 +1211,26 @@ function getObjectForLocalOrSessionStora
       storage.removeItem(name);
     }), {
       request: {
         host: Arg(0),
         name: Arg(1),
       },
       response: {}
     }),
+
+    removeAll: method(Task.async(function*(host) {
+      let storage = this.hostVsStores.get(host);
+      storage.clear();
+    }), {
+      request: {
+        host: Arg(0)
+      },
+      response: {}
+    }),
   };
 }
 
 /**
  * The Local Storage actor and front.
  */
 StorageActors.createActor({
   typeName: "localStorage",
@@ -2073,17 +2119,17 @@ var StorageActor = exports.StorageActor 
       type: "storesUpdate",
       data: Arg(0, "storeUpdateObject")
     },
     "stores-cleared": {
       type: "storesCleared",
       data: Arg(0, "json")
     },
     "stores-reloaded": {
-      type: "storesRelaoded",
+      type: "storesReloaded",
       data: Arg(0, "json")
     }
   },
 
   initialize: function(conn, tabActor) {
     protocol.Actor.prototype.initialize.call(this, null);
 
     this.conn = conn;
@@ -2305,21 +2351,23 @@ var StorageActor = exports.StorageActor 
     }
     if (!this.boundUpdate[action]) {
       this.boundUpdate[action] = {};
     }
     if (!this.boundUpdate[action][storeType]) {
       this.boundUpdate[action][storeType] = {};
     }
     for (let host in data) {
-      if (!this.boundUpdate[action][storeType][host] || action == "deleted") {
-        this.boundUpdate[action][storeType][host] = data[host];
-      } else {
-        this.boundUpdate[action][storeType][host] =
-        this.boundUpdate[action][storeType][host].concat(data[host]);
+      if (!this.boundUpdate[action][storeType][host]) {
+        this.boundUpdate[action][storeType][host] = [];
+      }
+      for (let name of data[host]) {
+        if (!this.boundUpdate[action][storeType][host].includes(name)) {
+          this.boundUpdate[action][storeType][host].push(name);
+        }
       }
     }
     if (action == "added") {
       // If the same store name was previously deleted or changed, but now is
       // added somehow, dont send the deleted or changed update.
       this.removeNamesFromUpdateList("deleted", storeType, data);
       this.removeNamesFromUpdateList("changed", storeType, data);
     } else if (action == "changed" && this.boundUpdate.added &&