diff --git a/devtools/client/framework/test/shared-head.js b/devtools/client/framework/test/shared-head.js index 04a4fba80c44..f818caf71b1c 100644 --- a/devtools/client/framework/test/shared-head.js +++ b/devtools/client/framework/test/shared-head.js @@ -306,3 +306,50 @@ function evalInDebuggee (mm, script) { } }); } + +/** + * Wait for a context menu popup to open. + * + * @param nsIDOMElement popup + * The XUL popup you expect to open. + * @param nsIDOMElement button + * The button/element that receives the contextmenu event. This is + * expected to open the popup. + * @param function onShown + * Function to invoke on popupshown event. + * @param function onHidden + * Function to invoke on popuphidden event. + * @return object + * A Promise object that is resolved after the popuphidden event + * callback is invoked. + */ +function waitForContextMenu(popup, button, onShown, onHidden) { + let deferred = promise.defer(); + + function onPopupShown() { + info("onPopupShown"); + popup.removeEventListener("popupshown", onPopupShown); + + onShown && onShown(); + + // Use executeSoon() to get out of the popupshown event. + popup.addEventListener("popuphidden", onPopupHidden); + executeSoon(() => popup.hidePopup()); + } + function onPopupHidden() { + info("onPopupHidden"); + popup.removeEventListener("popuphidden", onPopupHidden); + + onHidden && onHidden(); + + deferred.resolve(popup); + } + + popup.addEventListener("popupshown", onPopupShown); + + info("wait for the context menu to open"); + let eventDetails = {type: "contextmenu", button: 2}; + EventUtils.synthesizeMouse(button, 2, 2, eventDetails, + button.ownerDocument.defaultView); + return deferred.promise; +} diff --git a/devtools/client/locales/en-US/storage.properties b/devtools/client/locales/en-US/storage.properties index 6f2a66c31f8a..0dbd8229cb18 100644 --- a/devtools/client/locales/en-US/storage.properties +++ b/devtools/client/locales/en-US/storage.properties @@ -115,3 +115,7 @@ 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" diff --git a/devtools/client/shared/widgets/TableWidget.js b/devtools/client/shared/widgets/TableWidget.js index dcc0b52a4b3d..b70803abbd56 100644 --- a/devtools/client/shared/widgets/TableWidget.js +++ b/devtools/client/shared/widgets/TableWidget.js @@ -57,6 +57,8 @@ const MAX_VISIBLE_STRING_SIZE = 100; * - removableColumns: Whether columns are removeable. If set to false, * the context menu in the headers will not appear. * - firstColumn: key of the first column that should appear. + * - cellContextMenuId: ID of a element to be set as a + * context menu of every cell. */ function TableWidget(node, options = {}) { EventEmitter.decorate(this); @@ -66,12 +68,13 @@ function TableWidget(node, options = {}) { this._parent = node; let {initialColumns, emptyText, uniqueId, highlightUpdated, removableColumns, - firstColumn} = options; + firstColumn, cellContextMenuId} = options; this.emptyText = emptyText || ""; this.uniqueId = uniqueId || "name"; this.firstColumn = firstColumn || ""; this.highlightUpdated = highlightUpdated || false; this.removableColumns = removableColumns !== false; + this.cellContextMenuId = cellContextMenuId; this.tbody = this.document.createElementNS(XUL_NS, "hbox"); this.tbody.className = "table-widget-body theme-body"; @@ -807,6 +810,7 @@ TableWidget.prototype = { } for (let column of this.columns.values()) { column.remove(item); + column.updateZebra(); } if (this.items.size == 0) { this.tbody.setAttribute("empty", "empty"); @@ -1449,6 +1453,15 @@ function Cell(column, item, nextCell) { column.column.appendChild(this.label); } + if (column.table.cellContextMenuId) { + this.label.setAttribute("context", column.table.cellContextMenuId); + this.label.addEventListener("contextmenu", (event) => { + // Make the ID of the clicked cell available as a property on the table. + // It's then available for the popupshowing or command handler. + column.table.contextMenuRowId = this.id; + }, false); + } + this.value = item[column.id]; this.id = item[column.uniqueId]; } diff --git a/devtools/client/storage/storage.xul b/devtools/client/storage/storage.xul index aec2058cb51a..9738c4ba4a05 100644 --- a/devtools/client/storage/storage.xul +++ b/devtools/client/storage/storage.xul @@ -22,6 +22,12 @@ + + + + + + diff --git a/devtools/client/storage/test/browser.ini b/devtools/client/storage/test/browser.ini index 789dc3507425..3ab650a2bcb2 100644 --- a/devtools/client/storage/test/browser.ini +++ b/devtools/client/storage/test/browser.ini @@ -20,6 +20,7 @@ support-files = [browser_storage_cookies_tab_navigation.js] [browser_storage_dynamic_updates.js] [browser_storage_localstorage_edit.js] +[browser_storage_delete.js] [browser_storage_overflow.js] [browser_storage_search.js] skip-if = os == "linux" && e10s # Bug 1240804 - unhandled promise rejections diff --git a/devtools/client/storage/test/browser_storage_delete.js b/devtools/client/storage/test/browser_storage_delete.js new file mode 100644 index 000000000000..cdf1c06dff50 --- /dev/null +++ b/devtools/client/storage/test/browser_storage_delete.js @@ -0,0 +1,48 @@ +/* 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 ../../framework/test/shared-head.js */ +"use strict"; + +// Test deleting storage items + +const TEST_CASES = [ + [["localStorage", "http://test1.example.org"], + "ls1", "name"], + [["sessionStorage", "http://test1.example.org"], + "ss1", "name"], + [["cookies", "test1.example.org"], + "c1", "name"] +]; + +add_task(function*() { + yield openTabAndSetupStorage(MAIN_DOMAIN + "storage-listings.html"); + + 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}`); + + 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"); + + ok(!gUI.table.items.has(rowName), + `There is no row '${rowName}' in ${store} > ${host} after deletion`); + } + + yield finishTests(); +}); diff --git a/devtools/client/storage/test/head.js b/devtools/client/storage/test/head.js index 8251b94a7baa..d7c2ae213e9c 100644 --- a/devtools/client/storage/test/head.js +++ b/devtools/client/storage/test/head.js @@ -5,11 +5,10 @@ "use strict"; /* eslint no-unused-vars: [2, {"vars": "local"}] */ +/* import-globals-from ../../framework/test/shared-head.js */ -var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); -var { TargetFactory } = require("devtools/client/framework/target"); -var promise = require("promise"); -var DevToolsUtils = require("devtools/shared/DevToolsUtils"); +// shared-head.js handles imports, constants, and utility functions +Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js", this); const {TableWidget} = require("devtools/client/shared/widgets/TableWidget"); const SPLIT_CONSOLE_PREF = "devtools.toolbox.splitconsoleEnabled"; @@ -23,8 +22,6 @@ const MAIN_DOMAIN = "http://test1.example.org/" + PATH; const ALT_DOMAIN = "http://sectest1.example.org/" + PATH; const ALT_DOMAIN_SECURED = "https://sectest1.example.org:443/" + PATH; -waitForExplicitFinish(); - var gToolbox, gPanelWindow, gWindow, gUI; // Services.prefs.setBoolPref(DUMPEMIT_PREF, true); diff --git a/devtools/client/storage/ui.js b/devtools/client/storage/ui.js index 2a26faf60e24..a15c57e84ccb 100644 --- a/devtools/client/storage/ui.js +++ b/devtools/client/storage/ui.js @@ -47,6 +47,10 @@ const REASON = { UPDATE: "update" }; +// Maximum length of item name to show in context menu label - will be +// trimmed with ellipsis if it's longer. +const ITEM_NAME_MAX_LENGTH = 32; + /** * StorageUI is controls and builds the UI of the Storage Inspector. * @@ -74,6 +78,7 @@ var StorageUI = this.StorageUI = function StorageUI(front, target, panelWin) { this.table = new TableWidget(tableNode, { emptyText: L10N.getStr("table.emptyText"), highlightUpdated: true, + cellContextMenuId: "storage-table-popup" }); this.displayObjectSidebar = this.displayObjectSidebar.bind(this); @@ -105,6 +110,15 @@ var StorageUI = this.StorageUI = function StorageUI(front, target, panelWin) { this.handleKeypress = this.handleKeypress.bind(this); this._panelDoc.addEventListener("keypress", this.handleKeypress); + + this.onPopupShowing = this.onPopupShowing.bind(this); + this._tablePopup = this._panelDoc.getElementById("storage-table-popup"); + this._tablePopup.addEventListener("popupshowing", this.onPopupShowing, false); + + this.onRemoveItem = this.onRemoveItem.bind(this); + this._tablePopupDelete = this._panelDoc.getElementById( + "storage-table-popup-delete"); + this._tablePopupDelete.addEventListener("command", this.onRemoveItem, false); }; exports.StorageUI = StorageUI; @@ -130,6 +144,9 @@ StorageUI.prototype = { this._panelDoc.removeEventListener("keypress", this.handleKeypress); this.searchBox.removeEventListener("input", this.filterItems); this.searchBox = null; + + this._tablePopup.removeEventListener("popupshowing", this.onPopupShowing); + this._tablePopupDelete.removeEventListener("command", this.onRemoveItem); }, /** @@ -731,5 +748,41 @@ StorageUI.prototype = { names = [JSON.stringify(item.slice(2))]; } 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) { + if (!this.getCurrentActor().removeItem) { + event.preventDefault(); + return; + } + + 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)); + }, + + /** + * 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]); + }, }; diff --git a/devtools/client/webconsole/test/head.js b/devtools/client/webconsole/test/head.js index 9a6c33ddb4ac..47662c5df0c6 100644 --- a/devtools/client/webconsole/test/head.js +++ b/devtools/client/webconsole/test/head.js @@ -241,53 +241,6 @@ var closeConsole = Task.async(function* (tab) { } }); -/** - * Wait for a context menu popup to open. - * - * @param nsIDOMElement popup - * The XUL popup you expect to open. - * @param nsIDOMElement button - * The button/element that receives the contextmenu event. This is - * expected to open the popup. - * @param function onShown - * Function to invoke on popupshown event. - * @param function onHidden - * Function to invoke on popuphidden event. - * @return object - * A Promise object that is resolved after the popuphidden event - * callback is invoked. - */ -function waitForContextMenu(popup, button, onShown, onHidden) { - let deferred = promise.defer(); - - function onPopupShown() { - info("onPopupShown"); - popup.removeEventListener("popupshown", onPopupShown); - - onShown && onShown(); - - // Use executeSoon() to get out of the popupshown event. - popup.addEventListener("popuphidden", onPopupHidden); - executeSoon(() => popup.hidePopup()); - } - function onPopupHidden() { - info("onPopupHidden"); - popup.removeEventListener("popuphidden", onPopupHidden); - - onHidden && onHidden(); - - deferred.resolve(popup); - } - - popup.addEventListener("popupshown", onPopupShown); - - info("wait for the context menu to open"); - let eventDetails = {type: "contextmenu", button: 2}; - EventUtils.synthesizeMouse(button, 2, 2, eventDetails, - button.ownerDocument.defaultView); - return deferred.promise; -} - /** * Listen for a new tab to open and return a promise that resolves when one * does and completes the load event. diff --git a/devtools/server/actors/storage.js b/devtools/server/actors/storage.js index bdf6c9cd75b7..234cbeb70fe9 100644 --- a/devtools/server/actors/storage.js +++ b/devtools/server/actors/storage.js @@ -426,7 +426,7 @@ StorageActors.defaults = function(typeName, observationTopic, storeObjectType) { * - storeObjectType {string} * The RetVal type of the store object of this actor. * @param {object} overrides - * All the methods which you want to be differnt from the ones in + * All the methods which you want to be different from the ones in * StorageActors.defaults method plus the required ones described there. */ StorageActors.createActor = function(options = {}, overrides = {}) { @@ -674,6 +674,16 @@ StorageActors.createActor({ response: {} }), + removeItem: method(Task.async(function*(host, name) { + this.removeCookie(host, name); + }), { + request: { + host: Arg(0), + name: Arg(1), + }, + response: {} + }), + maybeSetupChildProcess: function() { cookieHelpers.onCookieChanged = this.onCookieChanged.bind(this); @@ -682,6 +692,7 @@ StorageActors.createActor({ this.addCookieObservers = cookieHelpers.addCookieObservers; this.removeCookieObservers = cookieHelpers.removeCookieObservers; this.editCookie = cookieHelpers.editCookie; + this.removeCookie = cookieHelpers.removeCookie; return; } @@ -701,6 +712,8 @@ StorageActors.createActor({ callParentProcess.bind(null, "removeCookieObservers"); this.editCookie = callParentProcess.bind(null, "editCookie"); + this.removeCookie = + callParentProcess.bind(null, "removeCookie"); addMessageListener("storage:storage-cookie-request-child", cookieHelpers.handleParentRequest); @@ -854,6 +867,32 @@ var cookieHelpers = { ); }, + removeCookie: function(host, name) { + 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) { + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes, + false + ); + } + } + }, + addCookieObservers: function() { Services.obs.addObserver(cookieHelpers, "cookie-changed", false); return null; @@ -902,17 +941,26 @@ var cookieHelpers = { handleChildRequest: function(msg) { switch (msg.json.method) { - case "getCookiesFromHost": + case "getCookiesFromHost": { let host = msg.data.args[0]; let cookies = cookieHelpers.getCookiesFromHost(host); return JSON.stringify(cookies); - case "addCookieObservers": + } + case "addCookieObservers": { return cookieHelpers.addCookieObservers(); - case "removeCookieObservers": + } + case "removeCookieObservers": { return cookieHelpers.removeCookieObservers(); - case "editCookie": + } + case "editCookie": { 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); + } default: console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method); throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD"); @@ -1113,6 +1161,17 @@ function getObjectForLocalOrSessionStorage(type) { value: new LongStringActor(this.conn, item.value || "") }; }, + + removeItem: method(Task.async(function*(host, name) { + let storage = this.hostVsStores.get(host); + storage.removeItem(name); + }), { + request: { + host: Arg(0), + name: Arg(1), + }, + response: {} + }), }; }