Bug 1312377 - Remove selected site data in Settings of Site Data, r?jaws draft
authorFischer.json <fischer.json@gmail.com>
Mon, 19 Dec 2016 16:57:34 +0800
changeset 466701 5cd8605ffa8c40d03beb63d1ca8cdb70ed188613
parent 466497 52a34f9a6cf112377299ab32132384e2dc1f543b
child 543486 20ad7fdab528b21ecd6bab21706c1d867d39e2fc
push id42964
push userbmo:fliu@mozilla.com
push dateThu, 26 Jan 2017 09:24:52 +0000
reviewersjaws
bugs1312377
milestone54.0a1
Bug 1312377 - Remove selected site data in Settings of Site Data, r?jaws 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
@@ -110,16 +110,22 @@ const mockSiteDataManager = {
 };
 
 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 => {
@@ -139,16 +145,43 @@ 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() {
+      if (win.document.documentURI === "chrome://browser/content/preferences/siteDataSettings.xul") {
+        isnot(dialogOverlay.style.visibility, "visible", "The Settings dialog should be hidden");
+        resolve();
+      }
+    }, { once: true });
+  });
+}
+
 function promiseSitesUpdated() {
   return TestUtils.topicObserved("sitedatamanager:sites-updated", () => true);
 }
 
 function promiseCookiesCleared() {
   return TestUtils.topicObserved("cookie-changed", (subj, data) => {
     return data === "cleared";
   });
@@ -232,26 +265,19 @@ add_task(function* () {
 
 add_task(function* () {
   yield SpecialPowers.pushPrefEnv({set: [["browser.storageManager.enabled", true]]});
 
   mockSiteDataManager.register();
   let updatePromise = promiseSitesUpdated();
   yield openPreferencesViaOpenPreferencesAPI("advanced", "networkTab", { leaveOpen: true });
   yield updatePromise;
+  yield openSettingsDialog();
 
-  // Open the siteDataSettings subdialog
   let doc = gBrowser.selectedBrowser.contentDocument;
-  let settingsBtn = doc.getElementById("siteDataSettings");
-  let dialogOverlay = doc.getElementById("dialogOverlay");
-  let dialogPromise = promiseLoadSubDialog("chrome://browser/content/preferences/siteDataSettings.xul");
-  settingsBtn.doCommand();
-  yield dialogPromise;
-  is(dialogOverlay.style.visibility, "visible", "The dialog should be visible");
-
   let dialogFrame = doc.getElementById("dialogFrame");
   let frameDoc = dialogFrame.contentDocument;
   let hostCol = frameDoc.getElementById("hostCol");
   let usageCol = frameDoc.getElementById("usageCol");
   let statusCol = frameDoc.getElementById("statusCol");
   let sitesList = frameDoc.getElementById("sitesList");
   let mockSites = mockSiteDataManager.sites;
 
@@ -330,26 +356,19 @@ add_task(function* () {
 
 add_task(function* () {
   yield SpecialPowers.pushPrefEnv({set: [["browser.storageManager.enabled", true]]});
 
   mockSiteDataManager.register();
   let updatePromise = promiseSitesUpdated();
   yield openPreferencesViaOpenPreferencesAPI("advanced", "networkTab", { leaveOpen: true });
   yield updatePromise;
+  yield openSettingsDialog();
 
-  // Open the siteDataSettings subdialog
   let doc = gBrowser.selectedBrowser.contentDocument;
-  let settingsBtn = doc.getElementById("siteDataSettings");
-  let dialogOverlay = doc.getElementById("dialogOverlay");
-  let dialogPromise = promiseLoadSubDialog("chrome://browser/content/preferences/siteDataSettings.xul");
-  settingsBtn.doCommand();
-  yield dialogPromise;
-  is(dialogOverlay.style.visibility, "visible", "The dialog should be visible");
-
   let frameDoc = doc.getElementById("dialogFrame").contentDocument;
   let searchBox = frameDoc.getElementById("searchBox");
   let mockOrigins = Array.from(mockSiteDataManager.sites.keys());
 
   searchBox.value = "xyz";
   searchBox.doCommand();
   assertSitesListed(mockOrigins.filter(o => o.includes("xyz")));
 
@@ -369,8 +388,207 @@ add_task(function* () {
     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 instanceof XULElement, `Should list the site of ${origin}`);
     });
   }
 });
+
+// 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));
+  yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+  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));
+  yield BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+  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
@@ -156,24 +156,28 @@ 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() {
-          if (subj.document.documentURI == "chrome://global/content/commonDialog.xul") {
+        subj.addEventListener("load", function onLoad() {
+          if (subj.document.documentURI == url) {
             Services.ww.unregisterNotification(onOpen);
             let doc = subj.document.documentElement;
             doc.getButton(buttonAction).click();
             resolve();
           }
         }, {once: true});
       }
     });
   });
 }
+
+function promiseAlertDialogOpen(buttonAction) {
+  return promiseWindowDialogOpen(buttonAction, "chrome://global/content/commonDialog.xul");
+}
--- a/browser/components/preferences/jar.mn
+++ b/browser/components/preferences/jar.mn
@@ -25,11 +25,13 @@ browser.jar:
     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({
+        level: 0,
+        opened: false,
+        host: baseDomain
+      });
+      // Other hosts are in the second level.
+      let items = hosts.map(host => {
+        return { host, level: 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.level === 0) {
+        return true;
+      }
+      return false;
+    },
+
+    isContainerEmpty() {
+      return false;
+    },
+
+    isContainerOpen(index) {
+      let item = this.visibleItems[index];
+      if (item && item.level === 0) {
+        return item.opened;
+      }
+      return false;
+    },
+
+    getLevel(index) {
+      let item = this.visibleItems[index];
+      return item ? item.level : 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,58 @@
+<?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/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" >
+
+<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>
--- a/browser/components/preferences/siteDataSettings.css
+++ b/browser/components/preferences/siteDataSettings.css
@@ -1,19 +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/. */
 
-#searchBoxContainer {
-  -moz-box-align: center;
-}
-
-#sitesList {
-  min-height: 20em;
-}
-
 #sitesList > richlistitem {
   -moz-binding: url("chrome://browser/content/preferences/siteListItem.xml#siteListItem");
 }
 
-.item-box {
-  padding: 5px 8px;
+#SiteDataRemoveSelectedDialog {
+  -moz-binding: url("chrome://global/content/bindings/dialog.xml#dialog");
 }
--- a/browser/components/preferences/siteDataSettings.js
+++ b/browser/components/preferences/siteDataSettings.js
@@ -15,16 +15,18 @@ 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 "update-permission"; the action user wants to take.
+  //               If not specified, means no action to take
   _sites: null,
 
   _list: null,
   _searchBox: null,
 
   init() {
     function setEventListener(id, eventType, callback) {
       document.getElementById(id)
@@ -33,22 +35,33 @@ let gSiteDataSettings = {
 
     this._list = document.getElementById("sitesList");
     this._searchBox = document.getElementById("searchBox");
     SiteDataManager.getSites().then(sites => {
       this._sites = sites;
       let sortCol = document.getElementById("hostCol");
       this._sortSites(this._sites, sortCol);
       this._buildSitesList(this._sites);
+      this._updateButtonsState();
+      Services.obs.notifyObservers(null, "sitedata-settings-init", null);
     });
 
     setEventListener("hostCol", "click", this.onClickTreeCol);
     setEventListener("usageCol", "click", this.onClickTreeCol);
     setEventListener("statusCol", "click", this.onClickTreeCol);
     setEventListener("searchBox", "command", this.onCommandSearch);
+    setEventListener("cancel", "command", this.close);
+    setEventListener("save", "command", this.saveChanges);
+    setEventListener("removeSelected", "command", this.removeSelected);
+  },
+
+  _updateButtonsState() {
+    let items = this._list.getElementsByTagName("richlistitem");
+    let removeBtn = document.getElementById("removeSelected");
+    removeBtn.disabled = !(items.length > 0);
   },
 
   /**
    * @param sites {Array}
    * @param col {XULElement} the <treecol> being sorted on
    */
   _sortSites(sites, col) {
     let isCurrentSortCol = col.getAttribute("data-isCurrentSortCol")
@@ -105,16 +118,20 @@ let gSiteDataSettings = {
     let prefStrBundle = document.getElementById("bundlePreferences");
     let keyword = this._searchBox.value.toLowerCase().trim();
     for (let data of sites) {
       let host = data.uri.host;
       if (keyword && !host.includes(keyword)) {
         continue;
       }
 
+      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", host);
       item.setAttribute("status", prefStrBundle.getString(statusStrId));
       item.setAttribute("usage", prefStrBundle.getFormattedString("siteUsage", size));
       this._list.appendChild(item);
@@ -123,10 +140,100 @@ let gSiteDataSettings = {
 
   onClickTreeCol(e) {
     this._sortSites(this._sites, e.target);
     this._buildSitesList(this._sites);
   },
 
   onCommandSearch() {
     this._buildSitesList(this._sites);
+  },
+
+  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 removals = [];
+    this._sites = this._sites.filter(site => {
+      if (site.userAction === "remove") {
+        removals.push(site.uri);
+        return false;
+      }
+      return true;
+    });
+
+    if (removals.length > 0) {
+      if (this._sites.length == 0) {
+        // User selects all sites 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 removals) {
+          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(removals);
+        }
+      }
+    }
+    // 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
@@ -2,16 +2,17 @@
 
 <!-- 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();"
@@ -36,9 +37,20 @@
       <listheader>
         <treecol flex="4" width="50" label="&hostCol.label;" id="hostCol"/>
         <treecol flex="2" width="50" label="&statusCol.label;" id="statusCol"/>
         <treecol flex="1" width="50" label="&usageCol.label;" id="usageCol"/>
       </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
@@ -4,8 +4,17 @@
 
 <!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     search.label                  "Search:">
 <!ENTITY     search.accesskey              "S">
+<!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,38 @@
+/* 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;
+}
+
+.item-box {
+  padding: 5px 8px;
+}
+
+/**
+ * Confirmation dialog of removing sites selected
+ */
+#SiteDataRemoveSelectedDialog {
+  padding: 16px;
+}
+
+#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/notification-icons.svg                  (../shared/notification-icons.svg)
 * 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/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)