Bug 1312377 - Remove selected site data in Settings of Site Data draft
authorFischer.json <fischer.json@gmail.com>
Mon, 19 Dec 2016 16:57:34 +0800
changeset 462918 1cc898d19ddb64c9b6730e5c608d3d993a5a22f7
parent 457090 0d823cf54df53e0cea75a74adebace956bd333d8
child 542526 a54aa00da254406af8d1ff65c3685760690db23d
push id41901
push userbmo:fliu@mozilla.com
push dateWed, 18 Jan 2017 07:24:31 +0000
bugs1312377
milestone53.0a1
Bug 1312377 - Remove selected site data in Settings of Site Data MozReview-Commit-ID: 2MlnZfajM4t
browser/components/preferences/SiteDataManager.jsm
browser/components/preferences/cookies.js
browser/components/preferences/in-content/tests/browser_advanced_siteData.js
browser/components/preferences/in-content/tests/head.js
browser/components/preferences/jar.mn
browser/components/preferences/moz.build
browser/components/preferences/siteDataRemoveSelected.js
browser/components/preferences/siteDataRemoveSelected.xul
browser/components/preferences/siteDataSettings.css
browser/components/preferences/siteDataSettings.js
browser/components/preferences/siteDataSettings.xul
browser/locales/en-US/chrome/browser/preferences/siteDataSettings.dtd
browser/themes/shared/incontentprefs/siteDataSettings.css
browser/themes/shared/jar.inc.mn
--- a/browser/components/preferences/SiteDataManager.jsm
+++ b/browser/components/preferences/SiteDataManager.jsm
@@ -3,16 +3,18 @@
 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/NetUtil.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "OfflineAppCacheHelper",
                                   "resource:///modules/offlineAppCache.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService",
+                                  "resource://gre/modules/ContextualIdentityService.jsm");
 
 this.EXPORTED_SYMBOLS = [
   "SiteDataManager"
 ];
 
 this.SiteDataManager = {
 
   _qms: Services.qms,
@@ -24,16 +26,17 @@ this.SiteDataManager = {
   // A Map of sites using the persistent-storage API (have requested persistent-storage permission)
   // Key is site's origin.
   // Value is one object holding:
   //   - perm: persistent-storage permision; instance of nsIPermission
   //   - status: the permission granted/rejected status
   //   - quotaUsage: the usage of indexedDB and localStorage.
   //   - appCacheList: an array of app cache; instances of nsIApplicationCache
   //   - diskCacheList: an array. Each element is object holding metadata of http cache:
+  //       - uri: the uri of that http cache
   //       - dataSize: that http cache size
   //       - idEnhance: the id extension of that http cache
   _sites: new Map(),
 
   _updateQuotaPromise: null,
 
   _updateDiskCachePromise: null,
 
@@ -48,17 +51,17 @@ this.SiteDataManager = {
     let perm = null;
     let status = null;
     let e = Services.perms.enumerator;
     while (e.hasMoreElements()) {
       perm = e.getNext();
       status = Services.perms.testExactPermissionFromPrincipal(perm.principal, "persistent-storage");
       if (status === Ci.nsIPermissionManager.ALLOW_ACTION ||
           status === Ci.nsIPermissionManager.DENY_ACTION) {
-        this._sites.set(perm.principal.origin, {
+        this._sites.set(perm.principal.URI.spec, {
           perm,
           status,
           quotaUsage: 0,
           appCacheList: [],
           diskCacheList: []
         });
       }
     }
@@ -120,16 +123,17 @@ this.SiteDataManager = {
     this._updateDiskCachePromise = new Promise(resolve => {
       if (this._sites.size) {
         let sites = this._sites;
         let visitor = {
           onCacheEntryInfo(uri, idEnhance, dataSize) {
             for (let site of sites.values()) {
               if (site.perm.matchesURI(uri, true)) {
                 site.diskCacheList.push({
+                  uri,
                   dataSize,
                   idEnhance
                 });
                 break;
               }
             }
           },
           onCacheEntryVisitCompleted() {
@@ -156,35 +160,16 @@ this.SiteDataManager = {
                         usage += cache.dataSize;
                       }
                       usage += site.quotaUsage;
                     }
                     return usage;
                   });
   },
 
-  _removePermission(site) {
-    Services.perms.removePermission(site.perm);
-  },
-
-  _removeQuotaUsage(site) {
-    this._qms.clearStoragesForPrincipal(site.perm.principal, null, true);
-  },
-
-  removeAll() {
-    for (let site of this._sites.values()) {
-      this._removePermission(site);
-      this._removeQuotaUsage(site);
-    }
-    Services.cache2.clear();
-    Services.cookies.removeAll();
-    OfflineAppCacheHelper.clear();
-    this.updateSites();
-  },
-
   getSites() {
     return Promise.all([this._updateQuotaPromise, this._updateDiskCachePromise])
                   .then(() => {
                     let list = [];
                     for (let [origin, site] of this._sites) {
                       let cache = null;
                       let usage = site.quotaUsage;
                       for (cache of site.appCacheList) {
@@ -196,10 +181,75 @@ this.SiteDataManager = {
                       list.push({
                         usage,
                         status: site.status,
                         uri: NetUtil.newURI(origin)
                       });
                     }
                     return list;
                   });
+  },
+
+  _removePermission(site) {
+    Services.perms.removePermission(site.perm);
+  },
+
+  _removeQuotaUsage(site) {
+    this._qms.clearStoragesForPrincipal(site.perm.principal, null, true);
+  },
+
+  _removeDiskCache(site) {
+    for (let cache of site.diskCacheList) {
+      this._diskCache.asyncDoomURI(cache.uri, cache.idEnhance, null);
+    }
+  },
+
+  _removeAppCache(site) {
+    for (let cache of site.appCacheList) {
+      cache.discard();
+    }
+  },
+
+  _removeCookie(site) {
+    let host = site.perm.principal.URI.host;
+    let e = Services.cookies.getCookiesFromHost(host, {});
+    while (e.hasMoreElements()) {
+      let cookie = e.getNext();
+      if (cookie instanceof Components.interfaces.nsICookie) {
+        if (this.isPrivateCookie(cookie)) {
+          continue;
+        }
+        Services.cookies.remove(
+          cookie.host, cookie.name, cookie.path, false, cookie.originAttributes);
+      }
+    }
+  },
+
+  remove(uris) {
+    for (let uri of uris) {
+      let site = this._sites.get(uri.spec);
+      if (site) {
+        this._removePermission(site);
+        this._removeQuotaUsage(site);
+        this._removeDiskCache(site);
+        this._removeAppCache(site);
+        this._removeCookie(site);
+      }
+    }
+    this.updateSites();
+  },
+
+  removeAll() {
+    for (let site of this._sites.values()) {
+      this._removePermission(site);
+      this._removeQuotaUsage(site);
+    }
+    Services.cache2.clear();
+    Services.cookies.removeAll();
+    OfflineAppCacheHelper.clear();
+    this.updateSites();
+  },
+
+  isPrivateCookie(cookie) {
+    let { userContextId } = cookie.originAttributes;
+    return userContextId && !ContextualIdentityService.getIdentityFromId(userContextId).public;
   }
 };
--- a/browser/components/preferences/cookies.js
+++ b/browser/components/preferences/cookies.js
@@ -5,16 +5,18 @@
 
 const nsICookie = Components.interfaces.nsICookie;
 
 Components.utils.import("resource://gre/modules/AppConstants.jsm");
 Components.utils.import("resource://gre/modules/PluralForm.jsm");
 Components.utils.import("resource://gre/modules/Services.jsm")
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "SiteDataManager",
+                                  "resource:///modules/SiteDataManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ContextualIdentityService",
                                   "resource://gre/modules/ContextualIdentityService.jsm");
 
 var gCookiesWindow = {
   _cm               : Components.classes["@mozilla.org/cookiemanager;1"]
                                 .getService(Components.interfaces.nsICookieManager),
   _hosts            : {},
   _hostOrder        : [],
@@ -71,31 +73,22 @@ var gCookiesWindow = {
   _cookieEquals(aCookieA, aCookieB, aStrippedHost) {
     return aCookieA.rawHost == aStrippedHost &&
            aCookieA.name == aCookieB.name &&
            aCookieA.path == aCookieB.path &&
            ChromeUtils.isOriginAttributesEqual(aCookieA.originAttributes,
                                                aCookieB.originAttributes);
   },
 
-  _isPrivateCookie(aCookie) {
-      let { userContextId } = aCookie.originAttributes;
-      if (!userContextId) {
-        // Default identity is public.
-        return false;
-      }
-      return !ContextualIdentityService.getIdentityFromId(userContextId).public;
-  },
-
   observe(aCookie, aTopic, aData) {
     if (aTopic != "cookie-changed")
       return;
 
     if (aCookie instanceof Components.interfaces.nsICookie) {
-      if (this._isPrivateCookie(aCookie)) {
+      if (SiteDataManager.isPrivateCookie(aCookie)) {
         return;
       }
 
       var strippedHost = this._makeStrippedHost(aCookie.host);
       if (aData == "changed")
         this._handleCookieChanged(aCookie, strippedHost);
       else if (aData == "added")
         this._handleCookieAdded(aCookie, strippedHost);
@@ -479,17 +472,17 @@ var gCookiesWindow = {
   _loadCookies() {
     var e = this._cm.enumerator;
     var hostCount = { value: 0 };
     this._hosts = {};
     this._hostOrder = [];
     while (e.hasMoreElements()) {
       var cookie = e.getNext();
       if (cookie && cookie instanceof Components.interfaces.nsICookie) {
-        if (this._isPrivateCookie(cookie)) {
+        if (SiteDataManager.isPrivateCookie(cookie)) {
           continue;
         }
 
         var strippedHost = this._makeStrippedHost(cookie.host);
         this._addCookie(strippedHost, cookie, hostCount);
       } else
         break;
     }
--- a/browser/components/preferences/in-content/tests/browser_advanced_siteData.js
+++ b/browser/components/preferences/in-content/tests/browser_advanced_siteData.js
@@ -34,16 +34,22 @@ const mockOfflineAppCacheHelper = {
 };
 
 function addPersistentStoragePerm(origin) {
   let uri = NetUtil.newURI(origin);
   let principal = Services.scriptSecurityManager.createCodebasePrincipal(uri, {});
   Services.perms.addFromPrincipal(principal, "persistent-storage", Ci.nsIPermissionManager.ALLOW_ACTION);
 }
 
+function removePersistentStoragePerm(origin) {
+  let uri = NetUtil.newURI(origin);
+  let principal = Services.scriptSecurityManager.createCodebasePrincipal(uri, {});
+  Services.perms.removeFromPrincipal(principal, "persistent-storage");
+}
+
 function getPersistentStoragePermStatus(origin) {
   let uri = NetUtil.newURI(origin);
   let principal = Services.scriptSecurityManager.createCodebasePrincipal(uri, {});
   return Services.perms.testExactPermissionFromPrincipal(principal, "persistent-storage");
 }
 
 function getQuotaUsage(origin) {
   return new Promise(resolve => {
@@ -63,16 +69,44 @@ function getCacheUsage() {
         Components.interfaces.nsICacheStorageConsumptionObserver,
         Components.interfaces.nsISupportsWeakReference
       ]),
     };
     Services.cache2.asyncGetDiskConsumption(obs);
   });
 }
 
+function openSettingsDialog() {
+  let doc = gBrowser.selectedBrowser.contentDocument;
+  let settingsBtn = doc.getElementById("siteDataSettings");
+  let dialogOverlay = doc.getElementById("dialogOverlay");
+  let dialogLoadPromise = promiseLoadSubDialog("chrome://browser/content/preferences/siteDataSettings.xul");
+  let dialogInitPromise = TestUtils.topicObserved("sitedata-settings-init", () => true);
+  let fullyLoadPromise = Promise.all([ dialogLoadPromise, dialogInitPromise ]).then(() => {
+    is(dialogOverlay.style.visibility, "visible", "The Settings dialog should be visible");
+  });
+  settingsBtn.doCommand();
+  return fullyLoadPromise;
+}
+
+function promiseSettingsDialogClose() {
+  return new Promise(resolve => {
+    let doc = gBrowser.selectedBrowser.contentDocument;
+    let dialogOverlay = doc.getElementById("dialogOverlay");
+    let win = content.gSubDialog._frame.contentWindow;
+    win.addEventListener("unload", function unload() {
+      win.removeEventListener("unload", unload);
+      if (win.document.documentURI === "chrome://browser/content/preferences/siteDataSettings.xul") {
+        isnot(dialogOverlay.style.visibility, "visible", "The Settings dialog should be hidden");
+        resolve();
+      }
+    });
+  });
+}
+
 function promiseSitesUpdated() {
   return TestUtils.topicObserved("sitedatamanager:sites-updated", () => true);
 }
 
 function promiseCookiesCleared() {
   return TestUtils.topicObserved("cookie-changed", (subj, data) => {
     return data === "cleared";
   });
@@ -148,8 +182,207 @@ add_task(function* () {
   totalUsage = yield SiteDataManager.getTotalUsage();
   is(cacheUsage, 0, "The cahce usage should be removed");
   is(quotaUsage, 0, "The quota usage should be removed");
   is(totalUsage, 0, "The total usage should be removed");
   // Test accepting "Clear All Data" ends
 
   gBrowser.removeCurrentTab();
 });
+
+// Test selecting and removing all sites one by one
+add_task(function* () {
+  yield SpecialPowers.pushPrefEnv({set: [["browser.storageManager.enabled", true]]});
+  let fakeOrigins = [
+    "https://news.foo.com/",
+    "https://mails.bar.com/",
+    "https://videos.xyz.com/",
+    "https://books.foo.com/",
+    "https://account.bar.com/",
+    "https://shopping.xyz.com/"
+  ];
+  fakeOrigins.forEach(origin => addPersistentStoragePerm(origin));
+
+  let updatePromise = promiseSitesUpdated();
+  yield openPreferencesViaOpenPreferencesAPI("advanced", "networkTab", { leaveOpen: true });
+  yield updatePromise;
+  yield openSettingsDialog();
+
+  let doc = gBrowser.selectedBrowser.contentDocument;
+  let frameDoc = null;
+  let saveBtn = null;
+  let cancelBtn = null;
+  let settingsDialogClosePromise = null;
+
+  // Test the initial state
+  assertAllSitesListed();
+
+  // Test the "Cancel" button
+  settingsDialogClosePromise = promiseSettingsDialogClose();
+  frameDoc = doc.getElementById("dialogFrame").contentDocument;
+  cancelBtn = frameDoc.getElementById("cancel");
+  removeAllSitesOneByOne();
+  assertAllSitesNotListed();
+  cancelBtn.doCommand();
+  yield settingsDialogClosePromise;
+  yield openSettingsDialog();
+  assertAllSitesListed();
+
+  // Test the "Save Changes" button but cancelling save
+  let cancelPromise = promiseAlertDialogOpen("cancel");
+  settingsDialogClosePromise = promiseSettingsDialogClose();
+  frameDoc = doc.getElementById("dialogFrame").contentDocument;
+  saveBtn = frameDoc.getElementById("save");
+  removeAllSitesOneByOne();
+  assertAllSitesNotListed();
+  saveBtn.doCommand();
+  yield cancelPromise;
+  yield settingsDialogClosePromise;
+  yield openSettingsDialog();
+  assertAllSitesListed();
+
+  // Test the "Save Changes" button and accepting save
+  let acceptPromise = promiseAlertDialogOpen("accept");
+  settingsDialogClosePromise = promiseSettingsDialogClose();
+  updatePromise = promiseSitesUpdated();
+  frameDoc = doc.getElementById("dialogFrame").contentDocument;
+  saveBtn = frameDoc.getElementById("save");
+  removeAllSitesOneByOne();
+  assertAllSitesNotListed();
+  saveBtn.doCommand();
+  yield acceptPromise;
+  yield settingsDialogClosePromise;
+  yield updatePromise;
+  yield openSettingsDialog();
+  assertAllSitesNotListed();
+
+  // Always clean up the fake origins
+  fakeOrigins.forEach(origin => removePersistentStoragePerm(origin));
+  gBrowser.removeCurrentTab();
+
+  function removeAllSitesOneByOne() {
+    frameDoc = doc.getElementById("dialogFrame").contentDocument;
+    let removeBtn = frameDoc.getElementById("removeSelected");
+    let sitesList = frameDoc.getElementById("sitesList");
+    let sites = sitesList.getElementsByTagName("richlistitem");
+    for (let i = sites.length - 1; i >= 0; --i) {
+      sites[i].click();
+      removeBtn.doCommand();
+    }
+  }
+
+  function assertAllSitesListed() {
+    frameDoc = doc.getElementById("dialogFrame").contentDocument;
+    let removeBtn = frameDoc.getElementById("removeSelected");
+    let sitesList = frameDoc.getElementById("sitesList");
+    let sites = sitesList.getElementsByTagName("richlistitem");
+    is(sites.length, fakeOrigins.length, "Should list all sites");
+    is(removeBtn.disabled, false, "Should enable the removeSelected button");
+  }
+
+  function assertAllSitesNotListed() {
+    frameDoc = doc.getElementById("dialogFrame").contentDocument;
+    let removeBtn = frameDoc.getElementById("removeSelected");
+    let sitesList = frameDoc.getElementById("sitesList");
+    let sites = sitesList.getElementsByTagName("richlistitem");
+    is(sites.length, 0, "Should not list all sites");
+    is(removeBtn.disabled, true, "Should disable the removeSelected button");
+  }
+});
+
+// Test selecting and removing partial sites
+add_task(function* () {
+  yield SpecialPowers.pushPrefEnv({set: [["browser.storageManager.enabled", true]]});
+  let fakeOrigins = [
+    "https://news.foo.com/",
+    "https://mails.bar.com/",
+    "https://videos.xyz.com/",
+    "https://books.foo.com/",
+    "https://account.bar.com/",
+    "https://shopping.xyz.com/"
+  ];
+  fakeOrigins.forEach(origin => addPersistentStoragePerm(origin));
+
+  let updatePromise = promiseSitesUpdated();
+  yield openPreferencesViaOpenPreferencesAPI("advanced", "networkTab", { leaveOpen: true });
+  yield updatePromise;
+  yield openSettingsDialog();
+
+  const removeDialogURL = "chrome://browser/content/preferences/siteDataRemoveSelected.xul";
+  let doc = gBrowser.selectedBrowser.contentDocument;
+  let frameDoc = null;
+  let saveBtn = null;
+  let cancelBtn = null;
+  let removeDialogOpenPromise = null;
+  let settingsDialogClosePromise = null;
+
+  // Test the initial state
+  assertSitesListed(fakeOrigins);
+
+  // Test the "Cancel" button
+  settingsDialogClosePromise = promiseSettingsDialogClose();
+  frameDoc = doc.getElementById("dialogFrame").contentDocument;
+  cancelBtn = frameDoc.getElementById("cancel");
+  removeSelectedSite(fakeOrigins.slice(0, 4));
+  assertSitesListed(fakeOrigins.slice(4));
+  cancelBtn.doCommand();
+  yield settingsDialogClosePromise;
+  yield openSettingsDialog();
+  assertSitesListed(fakeOrigins);
+
+  // Test the "Save Changes" button but canceling save
+  removeDialogOpenPromise = promiseWindowDialogOpen("cancel", removeDialogURL);
+  settingsDialogClosePromise = promiseSettingsDialogClose();
+  frameDoc = doc.getElementById("dialogFrame").contentDocument;
+  saveBtn = frameDoc.getElementById("save");
+  removeSelectedSite(fakeOrigins.slice(0, 4));
+  assertSitesListed(fakeOrigins.slice(4));
+  saveBtn.doCommand();
+  yield removeDialogOpenPromise;
+  yield settingsDialogClosePromise;
+  yield openSettingsDialog();
+  assertSitesListed(fakeOrigins);
+
+  // Test the "Save Changes" button and accepting save
+  removeDialogOpenPromise = promiseWindowDialogOpen("accept", removeDialogURL);
+  settingsDialogClosePromise = promiseSettingsDialogClose();
+  frameDoc = doc.getElementById("dialogFrame").contentDocument;
+  saveBtn = frameDoc.getElementById("save");
+  removeSelectedSite(fakeOrigins.slice(0, 4));
+  assertSitesListed(fakeOrigins.slice(4));
+  saveBtn.doCommand();
+  yield removeDialogOpenPromise;
+  yield settingsDialogClosePromise;
+  yield openSettingsDialog();
+  assertSitesListed(fakeOrigins.slice(4));
+
+  // Always clean up the fake origins
+  fakeOrigins.forEach(origin => removePersistentStoragePerm(origin));
+  gBrowser.removeCurrentTab();
+
+  function removeSelectedSite(origins) {
+    frameDoc = doc.getElementById("dialogFrame").contentDocument;
+    let removeBtn = frameDoc.getElementById("removeSelected");
+    let sitesList = frameDoc.getElementById("sitesList");
+    origins.forEach(origin => {
+      let site = sitesList.querySelector(`richlistitem[data-origin="${origin}"]`);
+      if (site) {
+        site.click();
+        removeBtn.doCommand();
+      } else {
+        ok(false, `Should not select and remove inexisted site of ${origin}`);
+      }
+    });
+  }
+
+  function assertSitesListed(origins) {
+    frameDoc = doc.getElementById("dialogFrame").contentDocument;
+    let removeBtn = frameDoc.getElementById("removeSelected");
+    let sitesList = frameDoc.getElementById("sitesList");
+    let totalSitesNumber = sitesList.getElementsByTagName("richlistitem").length;
+    is(totalSitesNumber, origins.length, "Should list the right sites number");
+    origins.forEach(origin => {
+      let site = sitesList.querySelector(`richlistitem[data-origin="${origin}"]`);
+      ok(!!site, `Should list the site of ${origin}`);
+    });
+    is(removeBtn.disabled, false, "Should enable the removeSelected button");
+  }
+});
--- a/browser/components/preferences/in-content/tests/head.js
+++ b/browser/components/preferences/in-content/tests/head.js
@@ -159,25 +159,29 @@ function waitForCondition(aConditionFn, 
     function tryAgain() {
       setTimeout(tryNow, aCheckInterval);
     }
     let tries = 0;
     tryAgain();
   });
 }
 
-function promiseAlertDialogOpen(buttonAction) {
+function promiseWindowDialogOpen(buttonAction, url) {
   return new Promise(resolve => {
     Services.ww.registerNotification(function onOpen(subj, topic, data) {
       if (topic == "domwindowopened" && subj instanceof Ci.nsIDOMWindow) {
         subj.addEventListener("load", function onLoad() {
           subj.removeEventListener("load", onLoad);
-          if (subj.document.documentURI == "chrome://global/content/commonDialog.xul") {
+          if (subj.document.documentURI == url) {
             Services.ww.unregisterNotification(onOpen);
             let doc = subj.document.documentElement;
             doc.getButton(buttonAction).click();
             resolve();
           }
         });
       }
     });
   });
 }
+
+function promiseAlertDialogOpen(buttonAction) {
+  return promiseWindowDialogOpen(buttonAction, "chrome://global/content/commonDialog.xul");
+}
--- a/browser/components/preferences/jar.mn
+++ b/browser/components/preferences/jar.mn
@@ -24,12 +24,13 @@ browser.jar:
     content/browser/preferences/containers.js
     content/browser/preferences/permissions.js
     content/browser/preferences/sanitize.xul
     content/browser/preferences/sanitize.js
     content/browser/preferences/selectBookmark.xul
     content/browser/preferences/selectBookmark.js
     content/browser/preferences/siteDataSettings.xul
     content/browser/preferences/siteDataSettings.js
-    content/browser/preferences/siteDataSettings.css
+*   content/browser/preferences/siteDataRemoveSelected.xul
+    content/browser/preferences/siteDataRemoveSelected.js
     content/browser/preferences/siteListItem.xml
     content/browser/preferences/translation.xul
     content/browser/preferences/translation.js
--- a/browser/components/preferences/moz.build
+++ b/browser/components/preferences/moz.build
@@ -14,13 +14,13 @@ for var in ('MOZ_APP_NAME', 'MOZ_MACBUND
     DEFINES[var] = CONFIG[var]
 
 if CONFIG['MOZ_WIDGET_TOOLKIT'] in ('windows', 'gtk2', 'gtk3', 'cocoa'):
     DEFINES['HAVE_SHELL_SERVICE'] = 1
 
 JAR_MANIFESTS += ['jar.mn']
 
 EXTRA_JS_MODULES += [
-    'SiteDataManager.jsm',
+    'SiteDataManager.jsm'
 ]
 
 with Files('**'):
     BUG_COMPONENT = ('Firefox', 'Preferences')
new file mode 100644
--- /dev/null
+++ b/browser/components/preferences/siteDataRemoveSelected.js
@@ -0,0 +1,197 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */
+/* 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/. */
+const { utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+"use strict";
+
+let gSiteDataRemoveSelected = {
+
+  _tree: null,
+
+  init() {
+    // Organize items for the tree from the argument
+    let hostsTable = window.arguments[0].hostsTable;
+    let visibleItems = [];
+    let itemsTable = new Map();
+    for (let [ baseDomain, hosts ] of hostsTable) {
+      // In the beginning, only display base domains in the topmost level.
+      visibleItems.push({
+        lv: 0,
+        opened: false,
+        host: baseDomain
+      });
+      // Other hosts are in the second level.
+      let items = hosts.map(host => {
+        return { host, lv: 1 };
+      });
+      items.sort(sortByHost);
+      itemsTable.set(baseDomain, items);
+    }
+    visibleItems.sort(sortByHost);
+    this._view.itemsTable = itemsTable;
+    this._view.visibleItems = visibleItems;
+    this._tree = document.getElementById("sitesTree");
+    this._tree.view = this._view;
+
+    function sortByHost(a, b) {
+      let aHost = a.host.toLowerCase();
+      let bHost = b.host.toLowerCase();
+      return aHost.localeCompare(bHost);
+    }
+  },
+
+  ondialogaccept() {
+    window.arguments[0].allowed = true;
+  },
+
+  ondialogcancel() {
+    window.arguments[0].allowed = false;
+  },
+
+  _view: {
+    _selection: null,
+
+    itemsTable: null,
+
+    visibleItems: null,
+
+    get rowCount() {
+      return this.visibleItems.length;
+    },
+
+    getCellText(index, column) {
+      let item = this.visibleItems[index];
+      return item ? item.host : "";
+    },
+
+    isContainer(index) {
+      let item = this.visibleItems[index];
+      if (item && item.lv === 0) {
+        return true;
+      }
+      return false;
+    },
+
+    isContainerEmpty() {
+      return false;
+    },
+
+    isContainerOpen(index) {
+      let item = this.visibleItems[index];
+      if (item && item.lv === 0) {
+        return item.opened;
+      }
+      return false;
+    },
+
+    getLevel(index) {
+      let item = this.visibleItems[index];
+      return item ? item.lv : 0;
+    },
+
+    hasNextSibling(index, afterIndex) {
+      let item = this.visibleItems[index];
+      if (item) {
+        let thisLV = this.getLevel(index);
+        for (let i = afterIndex + 1; i < this.rowCount; ++i) {
+          let nextLV = this.getLevel(i);
+          if (nextLV == thisLV) {
+            return true;
+          }
+          if (nextLV < thisLV) {
+            break;
+          }
+        }
+      }
+      return false;
+    },
+
+    getParentIndex(index) {
+      if (!this.isContainer(index)) {
+        for (let i = index - 1; i >= 0; --i) {
+          if (this.isContainer(i)) {
+            return i;
+          }
+        }
+      }
+      return -1;
+    },
+
+    toggleOpenState(index) {
+      let item = this.visibleItems[index];
+      if (!this.isContainer(index)) {
+        return;
+      }
+
+      if (item.opened) {
+        item.opened = false;
+
+        let deleteCount = 0;
+        for (let i = index + 1; i < this.visibleItems.length; ++i) {
+          if (!this.isContainer(i)) {
+            ++deleteCount;
+          } else {
+            break;
+          }
+        }
+
+        if (deleteCount) {
+          this.visibleItems.splice(index + 1, deleteCount);
+          this.treeBox.rowCountChanged(index + 1, -deleteCount);
+        }
+      } else {
+        item.opened = true;
+
+        let childItems = this.itemsTable.get(item.host);
+        for (let i = 0; i < childItems.length; ++i) {
+          this.visibleItems.splice(index + i + 1, 0, childItems[i]);
+        }
+        this.treeBox.rowCountChanged(index + 1, childItems.length);
+      }
+      this.treeBox.invalidateRow(index);
+    },
+
+    get selection() {
+      return this._selection;
+    },
+    set selection(v) {
+      this._selection = v;
+      return v;
+    },
+    setTree(treeBox) {
+      this.treeBox = treeBox;
+    },
+    isSeparator(index) {
+      return false;
+    },
+    isSorted(index) {
+      return false;
+    },
+    canDrop() {
+      return false;
+    },
+    drop() {},
+    getRowProperties() {},
+    getCellProperties() {},
+    getColumnProperties() {},
+    hasPreviousSibling(index) {},
+    getImageSrc() {},
+    getProgressMode() {},
+    getCellValue() {},
+    cycleHeader() {},
+    selectionChanged() {},
+    cycleCell() {},
+    isEditable() {},
+    isSelectable() {},
+    setCellValue() {},
+    setCellText() {},
+    performAction() {},
+    performActionOnRow() {},
+    performActionOnCell() {}
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/components/preferences/siteDataRemoveSelected.xul
@@ -0,0 +1,57 @@
+<?xml version="1.0"?>
+
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/preferences/in-content/siteDataSettings.css" type="text/css"?>
+
+<!DOCTYPE dialog SYSTEM "chrome://browser/locale/preferences/siteDataSettings.dtd" >
+
+<dialog id="SiteDataRemoveSelectedDialog"
+        windowtype="Browser:SiteDataRemoveSelected"
+        width="500"
+        title="&removingDialog.title;"
+        onload="gSiteDataRemoveSelected.init();"
+        ondialogaccept="gSiteDataRemoveSelected.ondialogaccept(); return true;"
+        ondialogcancel="gSiteDataRemoveSelected.ondialogcancel(); return true;"
+        xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+  <script src="chrome://browser/content/preferences/siteDataRemoveSelected.js"/>
+
+  <stringbundle id="bundlePreferences"
+                src="chrome://browser/locale/preferences/preferences.properties"/>
+
+  <vbox id="contentContainer">
+    <hbox flex="1">
+      <vbox>
+        <image class="question-icon"/>
+      </vbox>
+      <vbox flex="1">
+        <!-- Only show this label on OS X because of no dialog title -->
+        <label id="removing-label"
+#ifndef XP_MACOSX
+               hidden="true"
+#endif
+        >&removingDialog.title;</label>
+        <separator class="thin"/>
+        <description id="removing-description">&removingDialog.description;</description>
+      </vbox>
+    </hbox>
+
+    <separator />
+
+    <vbox flex="1">
+      <label>&siteTree.label;</label>
+      <separator class="thin"/>
+      <tree id="sitesTree" flex="1" seltype="single" hidecolumnpicker="true">
+        <treecols>
+          <treecol primary="true" flex="1" hideheader="true"/>
+        </treecols>
+        <treechildren />
+      </tree>
+    </vbox>
+  </vbox>
+
+</dialog>
deleted file mode 100644
--- a/browser/components/preferences/siteDataSettings.css
+++ /dev/null
@@ -1,15 +0,0 @@
-/* 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/. */
-
-#sitesList {
-  min-height: 20em;
-}
-
-#sitesList > richlistitem {
-  -moz-binding: url("chrome://browser/content/preferences/siteListItem.xml#siteListItem");
-}
-
-.item-box {
-  padding: 5px 8px;
-}
--- a/browser/components/preferences/siteDataSettings.js
+++ b/browser/components/preferences/siteDataSettings.js
@@ -15,27 +15,40 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 "use strict";
 
 let gSiteDataSettings = {
 
   // Array of meatdata of sites. Each array element is object holding:
   // - uri: uri of site; instance of nsIURI
   // - status: persistent-storage permission status
   // - usage: disk usage which site uses
+  // - userAction: "remove" or "switch-permission"; the action user wants to take.
+  //               If not specified, means no action to take
   _sites: null,
 
   _list: null,
 
   init() {
+    function setEventListener(id, eventType, callback) {
+      document.getElementById(id)
+              .addEventListener(eventType, callback.bind(gSiteDataSettings));
+    }
+
     this._list = document.getElementById("sitesList");
     SiteDataManager.getSites().then(sites => {
       this._sites = sites;
       this._sortSites(this._sites, "decending");
       this._buildSitesList(this._sites);
+      this._updateButtonsState();
+      Services.obs.notifyObservers(null, "sitedata-settings-init", null);
     });
+
+    setEventListener("removeSelected", "command", this.removeSelected);
+    setEventListener("save", "command", this.saveChanges);
+    setEventListener("cancel", "command", this.close);
   },
 
   /**
    * Sort sites by usages
    *
    * @param sites {Array}
    * @param order {String} indicate to sort in the "decending" or "ascending" order
    */
@@ -51,19 +64,118 @@ let gSiteDataSettings = {
   _buildSitesList(sites) {
     // Clear old entries.
     while (this._list.childNodes.length > 1) {
       this._list.removeChild(this._list.lastChild);
     }
 
     let prefStrBundle = document.getElementById("bundlePreferences");
     for (let data of sites) {
+      if (data.userAction === "remove") {
+        continue;
+      }
       let statusStrId = data.status === Ci.nsIPermissionManager.ALLOW_ACTION ? "important" : "default";
       let size = DownloadUtils.convertByteUnits(data.usage);
       let item = document.createElement("richlistitem");
       item.setAttribute("data-origin", data.uri.spec);
       item.setAttribute("host", data.uri.host);
       item.setAttribute("status", prefStrBundle.getString(statusStrId));
       item.setAttribute("usage", prefStrBundle.getFormattedString("siteUsage", size));
       this._list.appendChild(item);
     }
+  },
+
+  _updateButtonsState() {
+    let items = this._list.getElementsByTagName("richlistitem");
+    let removeBtn = document.getElementById("removeSelected");
+    removeBtn.disabled = !(items.length > 0);
+  },
+
+  removeSelected() {
+    let selected = this._list.selectedItem;
+    if (selected) {
+      let origin = selected.getAttribute("data-origin");
+      for (let site of this._sites) {
+        if (site.uri.spec === origin) {
+          site.userAction = "remove";
+          break;
+        }
+      }
+      this._list.removeChild(selected);
+      this._updateButtonsState();
+    }
+  },
+
+  saveChanges() {
+    let allowed = true;
+
+    // Confirm user really wants to remove site data starts
+    let removeds = [];
+    this._sites = this._sites.filter(site => {
+      if (site.userAction === "remove") {
+        removeds.push(site.uri);
+        return false;
+      }
+      return true;
+    });
+
+    if (removeds.length > 0) {
+      if (this._sites.length == 0) {
+        // User selects all site so equivalent to clearing all data
+        let flags =
+          Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+          Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
+          Services.prompt.BUTTON_POS_0_DEFAULT;
+        let prefStrBundle = document.getElementById("bundlePreferences");
+        let title = prefStrBundle.getString("clearSiteDataPromptTitle");
+        let text = prefStrBundle.getString("clearSiteDataPromptText");
+        let btn0Label = prefStrBundle.getString("clearSiteDataNow");
+        let result = Services.prompt.confirmEx(window, title, text, flags, btn0Label, null, null, null, {});
+        allowed = result == 0;
+        if (allowed) {
+          SiteDataManager.removeAll();
+        }
+      } else {
+        // User only removes partial sites.
+        // We will remove cookies based on base domain, say, user selects "news.foo.com" to remove.
+        // The cookies under "music.foo.com" will be removed together.
+        // We have to prmopt user about this action.
+        let hostsTable = new Map();
+        // Group removed sites by base domain
+        for (let uri of removeds) {
+          let baseDomain = Services.eTLD.getBaseDomain(uri);
+          let hosts = hostsTable.get(baseDomain);
+          if (!hosts) {
+            hosts = [];
+            hostsTable.set(baseDomain, hosts);
+          }
+          hosts.push(uri.host);
+        }
+        // Pick out sites with the same base domain as removed sites
+        for (let site of this._sites) {
+          let baseDomain = Services.eTLD.getBaseDomain(site.uri);
+          let hosts = hostsTable.get(baseDomain);
+          if (hosts) {
+            hosts.push(site.uri.host);
+          }
+        }
+
+        let args = {
+          hostsTable,
+          allowed: false
+        };
+        let features = "centerscreen,chrome,modal,resizable=no";
+        window.openDialog("chrome://browser/content/preferences/siteDataRemoveSelected.xul", "", features, args);
+        allowed = args.allowed;
+        if (allowed) {
+          SiteDataManager.remove(removeds);
+        }
+      }
+    }
+    // Confirm user really wants to remove site data ends
+
+    this.close();
+  },
+
+  close() {
+    window.close();
   }
 };
--- a/browser/components/preferences/siteDataSettings.xul
+++ b/browser/components/preferences/siteDataSettings.xul
@@ -1,17 +1,17 @@
 <?xml version="1.0"?>
 
 <!-- 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/. -->
 
 <?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
 <?xml-stylesheet href="chrome://browser/skin/preferences/preferences.css" type="text/css"?>
-<?xml-stylesheet href="chrome://browser/content/preferences/siteDataSettings.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/preferences/in-content/siteDataSettings.css" type="text/css"?>
 
 <!DOCTYPE dialog SYSTEM "chrome://browser/locale/preferences/siteDataSettings.dtd" >
 
 <window id="SiteDataSettingsDialog" windowtype="Browser:SiteDataSettings"
         class="windowDialog" title="&window.title;"
         xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         style="width: 45em;"
         onload="gSiteDataSettings.init();"
@@ -30,9 +30,20 @@
       <listheader>
         <treecol flex="4" width="50" label="&hostCol.label;"/>
         <treecol flex="2" width="50" label="&statusCol.label;"/>
         <treecol flex="1" width="50" label="&usageCol.label;"/>
       </listheader>
     </richlistbox>
   </vbox>
 
+  <hbox align="start">
+    <button id="removeSelected" label="&removeSelected.label;" accesskey="&removeSelected.accesskey;"/>
+  </hbox>
+
+  <vbox align="end">
+    <hbox>
+        <button id="cancel" label="&cancel.label;" accesskey="&cancel.accesskey;"/>
+        <button id="save" label="&save.label;" accesskey="&save.accesskey;"/>
+    </hbox>
+  </vbox>
+
 </window>
--- a/browser/locales/en-US/chrome/browser/preferences/siteDataSettings.dtd
+++ b/browser/locales/en-US/chrome/browser/preferences/siteDataSettings.dtd
@@ -2,8 +2,17 @@
    - 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/. -->
 
 <!ENTITY     window.title                  "Settings - Site Data">
 <!ENTITY     settings.description          "The following websites asked to store site data in your disk. You can specify which websites are allowed to store site data. Default site data is temporary and could be deleted automatically.">
 <!ENTITY     hostCol.label                 "Site">
 <!ENTITY     statusCol.label               "Status">
 <!ENTITY     usageCol.label                "Storage">
+<!ENTITY     removeSelected.label          "Remove Selected">
+<!ENTITY     removeSelected.accesskey      "r">
+<!ENTITY     save.label                    "Save Changes">
+<!ENTITY     save.accesskey                "a">
+<!ENTITY     cancel.label                  "Cancel">
+<!ENTITY     cancel.accesskey              "C">
+<!ENTITY     removingDialog.title          "Removing site data">
+<!ENTITY     removingDialog.description    "Removing site data will also remove cookies. This may log you out of websites and remove offline web content. Are you sure you want to make the changes?">
+<!ENTITY     siteTree.label                "The following website cookies will be removed:">
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/incontentprefs/siteDataSettings.css
@@ -0,0 +1,43 @@
+/* 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/. */
+
+/**
+ * Site Data - Settings dialog
+ */
+#sitesList {
+  min-height: 20em;
+}
+
+#sitesList > richlistitem {
+  -moz-binding: url("chrome://browser/content/preferences/siteListItem.xml#siteListItem");
+}
+
+.item-box {
+  padding: 5px 8px;
+}
+
+/**
+ * Confirmation dialog of removing sites selected
+ */
+#SiteDataRemoveSelectedDialog {
+  padding: 16px;
+  -moz-binding: url("chrome://global/content/bindings/dialog.xml#dialog");
+}
+
+#contentContainer {
+  font-size: 1.2em;
+  margin-bottom: 10px;
+}
+
+.question-icon {
+  margin: 6px;
+}
+
+#removing-label {
+  font-weight: bold;
+}
+
+#sitesTree {
+  height: 15em;
+}
--- a/browser/themes/shared/jar.inc.mn
+++ b/browser/themes/shared/jar.inc.mn
@@ -70,16 +70,17 @@
 * skin/classic/browser/tracking-protection-16.svg              (../shared/identity-block/tracking-protection-16.svg)
   skin/classic/browser/newtab/close.png                        (../shared/newtab/close.png)
   skin/classic/browser/newtab/controls.svg                     (../shared/newtab/controls.svg)
   skin/classic/browser/newtab/whimsycorn.png                   (../shared/newtab/whimsycorn.png)
   skin/classic/browser/panel-icons.svg                         (../shared/panel-icons.svg)
   skin/classic/browser/preferences/in-content/favicon.ico      (../shared/incontentprefs/favicon.ico)
   skin/classic/browser/preferences/in-content/icons.svg        (../shared/incontentprefs/icons.svg)
   skin/classic/browser/preferences/in-content/search.css       (../shared/incontentprefs/search.css)
+  skin/classic/browser/preferences/in-content/siteDataSettings.css  (../shared/incontentprefs/siteDataSettings.css)
 * skin/classic/browser/preferences/in-content/containers.css   (../shared/incontentprefs/containers.css)
 * skin/classic/browser/preferences/containers.css              (../shared/preferences/containers.css)
   skin/classic/browser/fxa/default-avatar.svg                  (../shared/fxa/default-avatar.svg)
   skin/classic/browser/fxa/logo.png                            (../shared/fxa/logo.png)
   skin/classic/browser/fxa/logo@2x.png                         (../shared/fxa/logo@2x.png)
   skin/classic/browser/fxa/sync-illustration.png               (../shared/fxa/sync-illustration.png)
   skin/classic/browser/fxa/sync-illustration@2x.png            (../shared/fxa/sync-illustration@2x.png)
   skin/classic/browser/fxa/sync-illustration.svg               (../shared/fxa/sync-illustration.svg)