Bug 1231154 - Make cookies table fields editable via double-click in storage inspector r?pbrosset
MozReview-Commit-ID: 53hv6NzhCtb
--- a/devtools/client/shared/widgets/TableWidget.js
+++ b/devtools/client/shared/widgets/TableWidget.js
@@ -16,16 +16,17 @@ const AFTER_SCROLL_DELAY = 100;
// Different types of events emitted by the Various components of the
// TableWidget.
const EVENTS = {
CELL_EDIT: "cell-edit",
COLUMN_SORTED: "column-sorted",
COLUMN_TOGGLED: "column-toggled",
HEADER_CONTEXT_MENU: "header-context-menu",
+ ROW_EDIT: "row-edit",
ROW_CONTEXT_MENU: "row-context-menu",
ROW_SELECTED: "row-selected",
ROW_UPDATED: "row-updated",
SCROLL_END: "scroll-end"
};
Object.defineProperty(this, "EVENTS", {
value: EVENTS,
enumerable: true,
@@ -238,17 +239,32 @@ TableWidget.prototype = {
column = cols[0];
cell = column.childNodes[rowIndex + 1];
} else {
column = cols[colIndex + 1];
cell = column.childNodes[rowIndex];
}
- this._editableFieldsEngine.edit(cell);
+ // The item may have been edited and had to be recreated when the user has
+ // pressed tab or shift+tab. This means that we need to recover our
+ // target. Because we never know whether a consumer will recreate an object
+ // on edit we need to act as though it does for any edit.
+ let change = this._editableFieldsEngine.onBlur();
+
+ if (change) {
+ this.once(EVENTS.ROW_EDIT, () => {
+ cell = column.children[this.selectedIndex + 1];
+
+ // Now we can call edit.
+ this._editableFieldsEngine.edit(cell);
+ });
+ } else {
+ this._editableFieldsEngine.edit(cell);
+ }
},
/**
* Keydown event handler for the table. Used for keyboard navigation amongst
* rows.
*/
onKeydown: function(event) {
// If we are in edit mode bail out.
@@ -559,19 +575,22 @@ TableWidget.prototype = {
let index = this.columns.get(this.sortedOn).push(item);
for (let [key, column] of this.columns) {
if (key != this.sortedOn) {
column.insertAt(item, index);
}
}
this.items.set(item[this.uniqueId], item);
this.tbody.removeAttribute("empty");
+
if (!suppressFlash) {
this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]);
}
+
+ this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]);
},
/**
* Removes the row associated with the `item` object.
*/
remove: function(item) {
if (typeof item == "string") {
item = this.items.get(item);
@@ -606,16 +625,17 @@ TableWidget.prototype = {
for (let column of this.columns.values()) {
if (item[column.id] != oldItem[column.id]) {
column.update(item);
changed = true;
}
}
if (changed) {
this.emit(EVENTS.ROW_UPDATED, item[this.uniqueId]);
+ this.emit(EVENTS.ROW_EDIT, item[this.uniqueId]);
}
},
/**
* Removes all of the rows from the table.
*/
clear: function() {
this.items.clear();
@@ -1275,16 +1295,19 @@ EditableFieldsEngine.prototype = {
this.textbox.value = target.value;
target.hidden = true;
this.textbox.focus();
},
/**
* Disconnect textbox on blur and send the changed event.
+ *
+ * @returns {Boolean}
+ * Has a change been made?
*/
onBlur: function() {
let target = this.textbox.nextElementSibling;
let newValue = this.textbox.value;
let oldValue = target.value;
let changed = oldValue !== newValue;
if (changed) {
@@ -1300,16 +1323,18 @@ EditableFieldsEngine.prototype = {
field: target,
oldValue: oldValue,
newValue: newValue
}
};
this.emit("change", data);
}
+
+ return changed;
},
/**
* Handle keypresses when in edit mode:
* - <escape> revert the value and close the textbox.
* - <return> apply the value and close the textbox.
* - <tab> apply the value and move the textbox to the next available field.
* - <shift><tab> apply the value and move the textbox to the previous
--- a/devtools/client/storage/test/browser.ini
+++ b/devtools/client/storage/test/browser.ini
@@ -1,18 +1,20 @@
[DEFAULT]
tags = devtools
subsuite = devtools
support-files =
+ storage-cookies.html
storage-complex-values.html
storage-listings.html
storage-overflow.html
storage-secured-iframe.html
storage-unsecured-iframe.html
storage-updates.html
head.js
[browser_storage_basic.js]
[browser_storage_dynamic_updates.js]
+[browser_storage_edit_cookies.js]
[browser_storage_overflow.js]
[browser_storage_sidebar.js]
skip-if = (os == 'win' && os_version == '6.1' && e10s && !debug) # bug 1229272
[browser_storage_values.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/storage/test/browser_storage_edit_cookies.js
@@ -0,0 +1,92 @@
+/* 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/. */
+
+// Basic test to check the editing of cookies.
+
+"use strict";
+
+const testCases = [
+ {
+ name: "test1",
+ path: "/browser",
+ host: ".example.org",
+ value: "value1",
+ isDomain: "true",
+ isSecure: "false",
+ isHttpOnly: "false"
+ },
+
+ {
+ name: "test2",
+ path: "/browser",
+ host: "test1.example.org",
+ value: "value2",
+ isDomain: "false",
+ isSecure: "false",
+ isHttpOnly: "false"
+ },
+
+ {
+ name: "test3",
+ path: "/browser",
+ host: ".example.org",
+ value: "value3",
+ isDomain: "true",
+ isSecure: "false",
+ isHttpOnly: "false"
+ },
+
+ {
+ name: "test4",
+ path: "/browser",
+ host: "test1.example.org",
+ value: "value4",
+ isDomain: "false",
+ isSecure: "false",
+ isHttpOnly: "false"
+ },
+
+ {
+ name: "test5",
+ path: "/browser",
+ host: ".example.org",
+ value: "value5",
+ isDomain: "true",
+ isSecure: "false",
+ isHttpOnly: "false"
+ }
+];
+
+function* testCookieNames() {
+ let doc = gPanelWindow.document;
+ // Expand all nodes so that the synthesized click event actually works
+ gUI.tree.expandAll();
+
+ // First tree item is already selected so no clicking and waiting for update
+ for (let testCase of testCases) {
+ ok(doc.querySelector(".table-widget-cell[data-id='" + testCase.name + "']"),
+ "Table item " + testCase.name + " should be present");
+
+ let row = getRowValues(testCase.name, true);
+
+ for (let field in testCase) {
+ is(row.get(field), testCase[field],
+ "Item " + testCase.name + " has the right " + field);
+ }
+ }
+}
+
+function* testEditCookies() {
+ let doc = gPanelWindow.document;
+
+ // TODO: Test editing cookies
+}
+
+add_task(function*() {
+ yield openTabAndSetupStorage(MAIN_DOMAIN + "storage-cookies.html");
+
+ yield testCookieNames();
+ yield testEditCookies();
+ yield finishTests();
+});
--- a/devtools/client/storage/test/head.js
+++ b/devtools/client/storage/test/head.js
@@ -534,8 +534,103 @@ function once(target, eventName, useCapt
deferred.resolve.apply(deferred, aArgs);
}, useCapture);
break;
}
}
return deferred.promise;
}
+
+/**
+ * Get values for a row.
+ *
+ * @param {String} id
+ * The uniqueId of the given row.
+ * @param {Boolean} includeHidden
+ * Include hidden columns.
+ *
+ * @return {Map}
+ * A Map of column names to values for the given row.
+ */
+function getRowValues(id, includeHidden = false) {
+ let cells = getRowCells(id, includeHidden);
+ let values = new Map();
+
+ for (let [name, cell] of cells) {
+ values.set(name, cell.value);
+ }
+
+ return values;
+}
+
+/**
+ * Get cells for a row.
+ *
+ * @param {String} id
+ * The uniqueId of the given row.
+ * @param {Boolean} includeHidden
+ * Include hidden columns.
+ *
+ * @return {Map}
+ * A Map of column names to cells for the given row.
+ */
+function getRowCells(id, includeHidden = false) {
+ let doc = gPanelWindow.document;
+ let table = gUI.table;
+ let item = doc.querySelector(".table-widget-column#" + table.uniqueId +
+ " .table-widget-cell[value='" + id + "']");
+ let index = [...item.parentNode.children].indexOf(item);
+ let cells = new Map();
+
+ for (let [name, {column}] of [...table.columns]) {
+ if (!includeHidden && column.parentNode.hidden) {
+ continue;
+ }
+ cells.set(name, column.children[index]);
+ }
+
+ return cells;
+}
+
+/**
+ * Get values for a column.
+ *
+ * @param {String} id
+ * The uniqueId of the given column.
+ *
+ * @return {Set}
+ * A Set of strings for the given column.
+ */
+function getColValues(id) {
+ let cells = getColCells(id);
+ let values = new Set();
+
+ for (let {value} of cells) {
+ values.add(value);
+ }
+
+ return values;
+}
+
+/**
+ * Get cells for a column.
+ *
+ * @param {String} id
+ * The uniqueId of the given column.
+ *
+ * @return {Set}
+ * A Set of cells for the given column.
+ */
+function getColCells(id) {
+ let doc = gPanelWindow.document;
+ let item = doc.querySelector(".table-widget-column#" + id);
+ let cols = new Set();
+
+ for (let cell of [...item.children]) {
+ if (cell.classList.contains("table-widget-column-header")) {
+ continue;
+ }
+ cols.add(cell);
+ }
+
+ return cols;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/storage/test/storage-cookies.html
@@ -0,0 +1,37 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 970517 - Storage inspector front end - tests
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector cookie test</title>
+</head>
+<body>
+<script type="application/javascript;version=1.7">
+"use strict";
+let partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1];
+let expiresIn24Hours = new Date(Date.now() + 60 * 60 * 24 * 1000).toUTCString();
+
+for (let i = 1; i <= 5; i++) {
+ let cookieString = "test" + i + "=value" + i +
+ ";expires=" + expiresIn24Hours + ";path=/browser";
+
+ if (i % 2) {
+ cookieString += ";domain=.example.org";
+ }
+
+ document.cookie = cookieString;
+}
+
+window.clear = function*() {
+ for (let i = 1; i <= 5; i++) {
+ document.cookie = "test" + i +
+ ";expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/browser";
+ }
+
+ dump("removed cookies from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
--- a/devtools/server/actors/storage.js
+++ b/devtools/server/actors/storage.js
@@ -23,16 +23,20 @@ var gTrackedMessageManager = new Map();
// Maximum number of cookies/local storage key-value-pairs that can be sent
// over the wire to the client in one request.
const MAX_STORE_OBJECT_COUNT = 50;
// Delay for the batch job that sends the accumulated update packets to the
// client (ms).
const BATCH_DELAY = 200;
+// MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that
+// precision.
+const MAX_COOKIE_EXPIRY = Math.pow(2, 62);
+
// A RegExp for characters that cannot appear in a file/directory name. This is
// used to sanitize the host name for indexed db to lookup whether the file is
// present in <profileDir>/storage/default/ location
var illegalFileNameCharacters = [
"[",
// Control characters \001 to \036
"\\x00-\\x24",
// Special characters
@@ -627,23 +631,63 @@ StorageActors.createActor({
case "reload":
this.storageActor.update("reloaded", "cookies", hosts);
break;
}
return null;
},
+ /**
+ * This method marks the table as editable.
+ *
+ * @return {Array}
+ * An array of column header ids.
+ */
+ getEditableFields: method(Task.async(function*() {
+ return [
+ "name",
+ "path",
+ "host",
+ "expires",
+ "value",
+ "isDomain",
+ "isSecure",
+ "isHttpOnly"
+ ];
+ }), {
+ request: {},
+ response: {
+ value: RetVal("json")
+ }
+ }),
+
+ /**
+ * Pass the editItem command from the content to the chrome process.
+ *
+ * @param {Object} data
+ * See cellEditInParent() for format details.
+ */
+ editItem: method(Task.async(function*(data) {
+ this.cellEditInParent(data);
+ }), {
+ request: {
+ data: Arg(0, "json"),
+ },
+ response: {}
+ }),
+
maybeSetupChildProcess: function() {
cookieHelpers.onCookieChanged = this.onCookieChanged.bind(this);
if (!DebuggerServer.isInChildProcess) {
this.getCookiesFromHost = cookieHelpers.getCookiesFromHost;
this.addCookieObservers = cookieHelpers.addCookieObservers;
this.removeCookieObservers = cookieHelpers.removeCookieObservers;
+ this.cellEditInParent = cookieHelpers.cellEditInParent;
return;
}
const { sendSyncMessage, addMessageListener } =
this.conn.parentMessageManager;
this.conn.setupInParent({
module: "devtools/server/actors/storage",
@@ -651,16 +695,18 @@ StorageActors.createActor({
});
this.getCookiesFromHost =
callParentProcess.bind(null, "getCookiesFromHost");
this.addCookieObservers =
callParentProcess.bind(null, "addCookieObservers");
this.removeCookieObservers =
callParentProcess.bind(null, "removeCookieObservers");
+ this.cellEditInParent =
+ callParentProcess.bind(null, "cellEditInParent");
addMessageListener("storage:storage-cookie-request-child",
cookieHelpers.handleParentRequest);
function callParentProcess(methodName, ...args) {
let reply = sendSyncMessage("storage:storage-cookie-request-parent", {
method: methodName,
args: args
@@ -690,22 +736,119 @@ var cookieHelpers = {
host = "";
}
let cookies = Services.cookies.getCookiesFromHost(host);
let store = [];
while (cookies.hasMoreElements()) {
let cookie = cookies.getNext().QueryInterface(Ci.nsICookie2);
+
store.push(cookie);
}
return store;
},
+ /**
+ * Apply the results of a cell edit.
+ *
+ * @param {Object} data
+ * An object in the following format:
+ * {
+ * column: "value",
+ * keyColumn: "name",
+ * oldValue: "%7BHello%7D",
+ * newValue: "%7BHelloo%7D",
+ * row: {
+ * name: "optimizelyBuckets",
+ * path: "/",
+ * host: ".mozilla.org",
+ * expires: "Mon, 02 Jun 2025 12:37:37 GMT",
+ * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT",
+ * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT",
+ * value: "%7BHelloo%7D",
+ * isDomain: "true",
+ * isSecure: "false",
+ * isHttpOnly: "false"
+ * }
+ * }
+ */
+ cellEditInParent: function(data) {
+ let {column, oldValue, newValue} = data;
+ let origName = column === "name" ? oldValue : data.row.name;
+ let origHost = column === "host" ? oldValue : data.row.host;
+ let origPath = column === "path" ? oldValue : data.row.path;
+ let cookie = null;
+
+ let enumerator = Services.cookies.getCookiesFromHost(origHost);
+ while (enumerator.hasMoreElements()) {
+ let nsiCookie = enumerator.getNext().QueryInterface(Ci.nsICookie2);
+ if (nsiCookie.name === origName && nsiCookie.host === origHost) {
+ cookie = {
+ host: nsiCookie.host,
+ path: nsiCookie.path,
+ name: nsiCookie.name,
+ value: nsiCookie.value,
+ isSecure: nsiCookie.isSecure,
+ isHttpOnly: nsiCookie.isHttpOnly,
+ isSession: nsiCookie.isSession,
+ expires: nsiCookie.expires
+ };
+ break;
+ }
+ }
+
+ if (!cookie) {
+ return;
+ }
+
+ switch (column) {
+ case "isSecure":
+ case "isHttpOnly":
+ case "isSession":
+ newValue = newValue === "true";
+ break;
+
+ case "expires":
+ newValue = Date.parse(newValue) / 1000;
+
+ if (isNaN(newValue)) {
+ newValue = MAX_COOKIE_EXPIRY;
+ }
+ break;
+
+ case "host":
+ case "name":
+ case "path":
+ // Remove the edited cookie.
+ Services.cookies.remove(origHost, origName, origPath, false);
+ break;
+ }
+
+ // Apply changes.
+ cookie[column] = newValue;
+
+ // cookie.isSession is not always set correctly on session cookies so we
+ // need to trust cookie.expires instead.
+ cookie.isSession = !cookie.expires;
+
+ // Add the edited cookie.
+ Services.cookies.add(
+ cookie.host,
+ cookie.path,
+ cookie.name,
+ cookie.value,
+ cookie.isSecure,
+ cookie.isHttpOnly,
+ cookie.isSession,
+ cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires
+ );
+ },
+
addCookieObservers: function() {
Services.obs.addObserver(cookieHelpers, "cookie-changed", false);
return null;
},
removeCookieObservers: function() {
Services.obs.removeObserver(cookieHelpers, "cookie-changed", false);
return null;
@@ -735,16 +878,19 @@ var cookieHelpers = {
case "getCookiesFromHost":
let host = msg.data.args[0];
let cookies = cookieHelpers.getCookiesFromHost(host);
return JSON.stringify(cookies);
case "addCookieObservers":
return cookieHelpers.addCookieObservers();
case "removeCookieObservers":
return cookieHelpers.removeCookieObservers();
+ case "cellEditInParent":
+ let rowdata = JSON.parse(msg.data.args[0]);
+ return cookieHelpers.cellEditInParent(rowdata);
default:
console.error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD", msg.json.method);
throw new Error("ERR_DIRECTOR_PARENT_UNKNOWN_METHOD");
}
},
};
/**