Bug 994555 - Adds contextmenu items to edit add and delete attributes; r=pbro
authorGrisha Pushkov <grisha@push.org.ru>
Tue, 20 Oct 2015 16:47:02 +0200
changeset 302004 d615cb506271a0b47362e8916fcad7152fcf8c0f
parent 302003 2548895af94a60093c710726736ec2dc9c75f290
child 302005 8aff0cae7bfc01ed6674d9536fba68326fbf5caa
push idunknown
push userunknown
push dateunknown
reviewerspbro
bugs994555
milestone44.0a1
Bug 994555 - Adds contextmenu items to edit add and delete attributes; r=pbro
browser/locales/en-US/chrome/browser/devtools/inspector.dtd
browser/locales/en-US/chrome/browser/devtools/inspector.properties
devtools/client/inspector/inspector-panel.js
devtools/client/inspector/inspector.xul
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_menu-01-sensitivity.js
devtools/client/inspector/test/browser_inspector_menu-05-attribute-items.js
devtools/client/inspector/test/browser_inspector_menu-05-other.js
devtools/client/inspector/test/browser_inspector_menu-06-other.js
devtools/client/inspector/test/doc_inspector_menu.html
devtools/client/markupview/markup-view.js
--- a/browser/locales/en-US/chrome/browser/devtools/inspector.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/inspector.dtd
@@ -74,16 +74,42 @@
 <!ENTITY inspectorScrollNodeIntoView.label       "Scroll Into View">
 <!ENTITY inspectorScrollNodeIntoView.accesskey   "S">
 
 <!-- LOCALIZATION NOTE (inspectorHTMLDelete.label): This is the label shown in
      the inspector contextual-menu for the item that lets users delete the
      current node -->
 <!ENTITY inspectorHTMLDelete.label          "Delete Node">
 <!ENTITY inspectorHTMLDelete.accesskey      "D">
+<!-- LOCALIZATION NOTE (inspectorAttributeSubmenu.label): This is the label
+     shown in the inspector contextual-menu for the sub-menu of the other
+     attribute items, which allow to:
+     - add new attribute
+     - edit attribute
+     - remove attribute -->
+<!ENTITY inspectorAttributeSubmenu.label      "Attribute">
+<!ENTITY inspectorAttributeSubmenu.accesskey  "A">
+
+<!-- LOCALIZATION NOTE (inspectorAddAttribute.label): This is the label shown in
+     the inspector contextual-menu for the item that lets users add attribute
+     to current node -->
+<!ENTITY inspectorAddAttribute.label        "Add Attribute">
+<!ENTITY inspectorAddAttribute.accesskey    "A">
+
+<!-- LOCALIZATION NOTE (inspectorEditAttribute.label): This is the label shown in
+     the inspector contextual-menu for the item that lets users edit attribute
+     for current node -->
+<!ENTITY inspectorEditAttribute.label        "Edit Attribute">
+<!ENTITY inspectorEditAttribute.accesskey    "E">
+
+<!-- LOCALIZATION NOTE (inspectorRemoveAttribute.label): This is the label shown in
+     the inspector contextual-menu for the item that lets users delete attribute
+     from current node -->
+<!ENTITY inspectorRemoveAttribute.label        "Remove Attribute">
+<!ENTITY inspectorRemoveAttribute.accesskey    "R">
 
 <!ENTITY inspector.selectButton.tooltip     "Select element with mouse">
 
 <!-- LOCALIZATION NOTE (inspectorSearchHTML.label2): This is the label shown as
      the placeholder in inspector search box -->
 <!ENTITY inspectorSearchHTML.label2          "Search with CSS Selectors">
 <!ENTITY inspectorSearchHTML.key            "F">
 
--- a/browser/locales/en-US/chrome/browser/devtools/inspector.properties
+++ b/browser/locales/en-US/chrome/browser/devtools/inspector.properties
@@ -99,8 +99,20 @@ inspector.menu.openUrlInNewTab.label=Ope
 inspector.menu.copyUrlToClipboard.label=Copy Link Address
 
 # LOCALIZATION NOTE (inspector.menu.selectElement.label): This is the label of a
 # menu item in the inspector contextual-menu that appears when the user right-
 # clicks on the attribute of a node in the inspector that is the ID of another
 # element in the DOM (like with <label for="input-id">), and that allows to
 # select that element in the inspector.
 inspector.menu.selectElement.label=Select Element #%S
+
+# LOCALIZATION NOTE (inspector.menu.editAttribute.label): This is the label of a
+# sub-menu "Attribute" in the inspector contextual-menu that appears
+# when the user right-clicks on the node in the inspector, and that allows
+# to edit an attribute on this node.
+inspector.menu.editAttribute.label=Edit Attribute %S
+
+# LOCALIZATION NOTE (inspector.menu.removeAttribute.label): This is the label of a
+# sub-menu "Attribute" in the inspector contextual-menu that appears
+# when the user right-clicks on the attribute of a node in the inspector,
+# and that allows to remove this attribute.
+inspector.menu.removeAttribute.label=Remove Attribute %S
--- a/devtools/client/inspector/inspector-panel.js
+++ b/devtools/client/inspector/inspector-panel.js
@@ -72,16 +72,17 @@ const LAYOUT_CHANGE_TIMER = 250;
  */
 function InspectorPanel(iframeWindow, toolbox) {
   this._toolbox = toolbox;
   this._target = toolbox._target;
   this.panelDoc = iframeWindow.document;
   this.panelWin = iframeWindow;
   this.panelWin.inspector = this;
 
+  this.nodeMenuTriggerInfo = null;
   this._onBeforeNavigate = this._onBeforeNavigate.bind(this);
   this._target.on("will-navigate", this._onBeforeNavigate);
 
   EventEmitter.decorate(this);
 }
 
 exports.InspectorPanel = InspectorPanel;
 
@@ -642,19 +643,24 @@ InspectorPanel.prototype = {
       if (content && content.trim().length > 0) {
         return content;
       }
     }
     return null;
   },
 
   /**
-   * Disable the delete item if needed. Update the pseudo classes.
+   * Update, enable, disable, hide, show any menu item depending on the current
+   * element.
    */
-  _setupNodeMenu: function() {
+  _setupNodeMenu: function(event) {
+    let markupContainer = this.markup.getContainer(this.selection.nodeFront);
+    this.nodeMenuTriggerInfo =
+      markupContainer.editor.getInfoAtNode(event.target.triggerNode);
+
     let isSelectionElement = this.selection.isElementNode() &&
                              !this.selection.isPseudoElementNode();
     let isEditableElement = isSelectionElement &&
                             !this.selection.isAnonymousNode();
     let isDuplicatableElement = isSelectionElement &&
                                 !this.selection.isAnonymousNode() &&
                                 !this.selection.isRoot();
     let isScreenshotable = isSelectionElement &&
@@ -691,19 +697,18 @@ InspectorPanel.prototype = {
     let copyOuterHTML = this.panelDoc.getElementById("node-menu-copyouter");
     let scrollIntoView = this.panelDoc.getElementById("node-menu-scrollnodeintoview");
     let expandAll = this.panelDoc.getElementById("node-menu-expand");
     let collapse = this.panelDoc.getElementById("node-menu-collapse");
 
     expandAll.setAttribute("disabled", "true");
     collapse.setAttribute("disabled", "true");
 
-    let markUpContainer = this.markup.importNode(this.selection.nodeFront, false);
-    if (this.selection.isNode() && markUpContainer.hasChildren) {
-      if (markUpContainer.expanded) {
+    if (this.selection.isNode() && markupContainer.hasChildren) {
+      if (markupContainer.expanded) {
         collapse.removeAttribute("disabled");
       }
       expandAll.removeAttribute("disabled");
     }
 
     this._target.actorHasMethod("domwalker", "duplicateNode").then(value => {
       duplicateNode.hidden = !value;
     });
@@ -779,22 +784,62 @@ InspectorPanel.prototype = {
       pasteAfter.disabled = true;
       pasteFirstChild.disabled = true;
       pasteLastChild.disabled = true;
     }
 
     // Enable the "copy image data-uri" item if the selection is previewable
     // which essentially checks if it's an image or canvas tag
     let copyImageData = this.panelDoc.getElementById("node-menu-copyimagedatauri");
-    let markupContainer = this.markup.getContainer(this.selection.nodeFront);
     if (isSelectionElement && markupContainer && markupContainer.isPreviewable()) {
       copyImageData.removeAttribute("disabled");
     } else {
       copyImageData.setAttribute("disabled", "true");
     }
+
+    // Enable / disable "Add Attribute", "Edit Attribute"
+    // and "Remove Attribute" items
+    this._setupAttributeMenu(isEditableElement);
+  },
+
+  _setupAttributeMenu: function(isEditableElement) {
+    let addAttribute = this.panelDoc.getElementById("node-menu-add-attribute");
+    let editAttribute = this.panelDoc.getElementById("node-menu-edit-attribute");
+    let removeAttribute = this.panelDoc.getElementById("node-menu-remove-attribute");
+    let nodeInfo = this.nodeMenuTriggerInfo;
+
+    // Enable "Add Attribute" for all editable elements
+    if (isEditableElement) {
+      addAttribute.removeAttribute("disabled");
+    } else {
+      addAttribute.setAttribute("disabled", "true");
+    }
+
+    // Enable "Edit Attribute" and "Remove Attribute" only on attribute click
+    if (isEditableElement && nodeInfo && nodeInfo.type === "attribute") {
+      editAttribute.removeAttribute("disabled");
+      editAttribute.setAttribute("label",
+        strings.formatStringFromName(
+          "inspector.menu.editAttribute.label", [`"${nodeInfo.name}"`], 1));
+
+      removeAttribute.removeAttribute("disabled");
+      removeAttribute.setAttribute("label",
+        strings.formatStringFromName(
+          "inspector.menu.removeAttribute.label", [`"${nodeInfo.name}"`], 1));
+    } else {
+      editAttribute.setAttribute("disabled", "true");
+      editAttribute.setAttribute("label",
+        strings.formatStringFromName(
+          "inspector.menu.editAttribute.label", [''], 1));
+
+      removeAttribute.setAttribute("disabled", "true");
+      removeAttribute.setAttribute("label",
+        strings.formatStringFromName(
+          "inspector.menu.removeAttribute.label", [''], 1));
+    }
   },
 
   _resetNodeMenu: function() {
     // Remove any extra items
     while (this.lastNodemenuItem.nextSibling) {
       let toDelete = this.lastNodemenuItem.nextSibling;
       toDelete.parentNode.removeChild(toDelete);
     }
@@ -1219,16 +1264,43 @@ InspectorPanel.prototype = {
     if (this.markup) {
       this.markup.deleteNode(this.selection.nodeFront);
     } else {
       // remove the node from content
       this.walker.removeNode(this.selection.nodeFront);
     }
   },
 
+  /**
+   * Add attribute to node.
+   * Used for node context menu and shouldn't be called directly.
+   */
+  onAddAttribute: function() {
+    let container = this.markup.getContainer(this.selection.nodeFront);
+    container.addAttribute();
+  },
+
+  /**
+   * Edit attribute for node.
+   * Used for node context menu and shouldn't be called directly.
+   */
+  onEditAttribute: function() {
+    let container = this.markup.getContainer(this.selection.nodeFront);
+    container.editAttribute(this.nodeMenuTriggerInfo.name);
+  },
+
+  /**
+   * Remove attribute from node.
+   * Used for node context menu and shouldn't be called directly.
+   */
+  onRemoveAttribute: function() {
+    let container = this.markup.getContainer(this.selection.nodeFront);
+    container.removeAttribute(this.nodeMenuTriggerInfo.name);
+  },
+
   expandNode: function() {
     this.markup.expandAll(this.selection.nodeFront);
   },
 
   collapseNode: function() {
     this.markup.collapseNode(this.selection.nodeFront);
   },
 
--- a/devtools/client/inspector/inspector.xul
+++ b/devtools/client/inspector/inspector.xul
@@ -105,16 +105,33 @@
         oncommand="inspector.screenshotNode()" />
       <menuitem id="node-menu-duplicatenode"
         label="&inspectorDuplicateNode.label;"
         oncommand="inspector.duplicateNode()"/>
       <menuitem id="node-menu-delete"
         label="&inspectorHTMLDelete.label;"
         accesskey="&inspectorHTMLDelete.accesskey;"
         oncommand="inspector.deleteNode()"/>
+      <menu label="&inspectorAttributeSubmenu.label;"
+        accesskey="&inspectorAttributeSubmenu.accesskey;">
+        <menupopup>
+          <menuitem id="node-menu-add-attribute"
+            label="&inspectorAddAttribute.label;"
+            accesskey="&inspectorAddAttribute.accesskey;"
+            oncommand="inspector.onAddAttribute()"/>
+          <menuitem id="node-menu-edit-attribute"
+            label="&inspectorEditAttribute.label;"
+            accesskey="&inspectorEditAttribute.accesskey;"
+            oncommand="inspector.onEditAttribute()"/>
+          <menuitem id="node-menu-remove-attribute"
+            label="&inspectorRemoveAttribute.label;"
+            accesskey="&inspectorRemoveAttribute.accesskey;"
+            oncommand="inspector.onRemoveAttribute()"/>
+        </menupopup>
+      </menu>
       <menuseparator id="node-menu-link-separator"/>
       <menuitem id="node-menu-link-follow"
         oncommand="inspector.onFollowLink()"/>
       <menuitem id="node-menu-link-copy"
         oncommand="inspector.onCopyLink()"/>
       <menuseparator/>
       <menuitem id="node-menu-pseudo-hover"
         label=":hover" type="checkbox"
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -81,17 +81,18 @@ skip-if = e10s # GCLI isn't e10s compati
 [browser_inspector_inspect-object-element.js]
 [browser_inspector_invalidate.js]
 [browser_inspector_keyboard-shortcuts-copy-outerhtml.js]
 [browser_inspector_keyboard-shortcuts.js]
 [browser_inspector_menu-01-sensitivity.js]
 [browser_inspector_menu-02-copy-items.js]
 [browser_inspector_menu-03-paste-items.js]
 [browser_inspector_menu-04-use-in-console.js]
-[browser_inspector_menu-05-other.js]
+[browser_inspector_menu-05-attribute-items.js]
+[browser_inspector_menu-06-other.js]
 [browser_inspector_navigation.js]
 [browser_inspector_pane-toggle-01.js]
 [browser_inspector_pane-toggle-02.js]
 [browser_inspector_pane-toggle-03.js]
 [browser_inspector_picker-stop-on-destroy.js]
 [browser_inspector_picker-stop-on-tool-change.js]
 [browser_inspector_pseudoclass-lock.js]
 [browser_inspector_pseudoclass-menu.js]
--- a/devtools/client/inspector/test/browser_inspector_menu-01-sensitivity.js
+++ b/devtools/client/inspector/test/browser_inspector_menu-01-sensitivity.js
@@ -27,22 +27,34 @@ const ALL_MENU_ITEMS = [
   "node-menu-copyouter",
   "node-menu-copyuniqueselector",
   "node-menu-copyimagedatauri",
   "node-menu-delete",
   "node-menu-pseudo-hover",
   "node-menu-pseudo-active",
   "node-menu-pseudo-focus",
   "node-menu-scrollnodeintoview",
-  "node-menu-screenshotnode"
+  "node-menu-screenshotnode",
+  "node-menu-add-attribute",
+  "node-menu-edit-attribute",
+  "node-menu-remove-attribute"
 ].concat(PASTE_MENU_ITEMS, ACTIVE_ON_DOCTYPE_ITEMS);
 
 const INACTIVE_ON_DOCTYPE_ITEMS =
   ALL_MENU_ITEMS.filter(item => ACTIVE_ON_DOCTYPE_ITEMS.indexOf(item) === -1);
 
+/**
+ * Test cases, each item of this array may define the following properties:
+ *   desc: string that will be logged
+ *   selector: selector of the node to be selected
+ *   disabled: items that should have disabled state
+ *   clipboardData: clipboard content
+ *   clipboardDataType: clipboard content type
+ *   attributeTrigger: attribute that will be used as context menu trigger
+ */
 const TEST_CASES = [
   {
     desc: "doctype node with empty clipboard",
     selector: null,
     disabled: INACTIVE_ON_DOCTYPE_ITEMS,
   },
   {
     desc: "doctype node with html on clipboard",
@@ -50,138 +62,183 @@ const TEST_CASES = [
     clipboardDataType: "html",
     selector: null,
     disabled: INACTIVE_ON_DOCTYPE_ITEMS,
   },
   {
     desc: "element node HTML on the clipboard",
     clipboardData: "<p>some text</p>",
     clipboardDataType: "html",
-    disabled: ["node-menu-copyimagedatauri"],
+    disabled: [
+      "node-menu-copyimagedatauri",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
+    ],
     selector: "#sensitivity",
   },
   {
     desc: "<html> element",
     clipboardData: "<p>some text</p>",
     clipboardDataType: "html",
     selector: "html",
     disabled: [
       "node-menu-copyimagedatauri",
       "node-menu-pastebefore",
       "node-menu-pasteafter",
       "node-menu-pastefirstchild",
       "node-menu-pastelastchild",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
     ],
   },
   {
     desc: "<body> with HTML on clipboard",
     clipboardData: "<p>some text</p>",
     clipboardDataType: "html",
     selector: "body",
     disabled: [
       "node-menu-copyimagedatauri",
       "node-menu-pastebefore",
       "node-menu-pasteafter",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
     ]
   },
   {
     desc: "<img> with HTML on clipboard",
     clipboardData: "<p>some text</p>",
     clipboardDataType: "html",
     selector: "img",
-    disabled: []
+    disabled: [
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
+    ]
   },
   {
     desc: "<head> with HTML on clipboard",
     clipboardData: "<p>some text</p>",
     clipboardDataType: "html",
     selector: "head",
     disabled: [
       "node-menu-copyimagedatauri",
       "node-menu-pastebefore",
       "node-menu-pasteafter",
       "node-menu-screenshotnode",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
     ],
   },
   {
     desc: "<head> with no html on clipboard",
     selector: "head",
     disabled: PASTE_MENU_ITEMS.concat([
       "node-menu-copyimagedatauri",
       "node-menu-screenshotnode",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
     ]),
   },
   {
     desc: "<element> with text on clipboard",
     clipboardData: "some text",
     clipboardDataType: undefined,
     selector: "#paste-area",
-    disabled: ["node-menu-copyimagedatauri"],
+    disabled: [
+      "node-menu-copyimagedatauri",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
+    ]
   },
   {
     desc: "<element> with base64 encoded image data uri on clipboard",
     clipboardData:
       "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABC" +
       "AAAAAA6fptVAAAACklEQVQYV2P4DwABAQEAWk1v8QAAAABJRU5ErkJggg==",
     clipboardDataType: undefined,
     selector: "#paste-area",
-    disabled: PASTE_MENU_ITEMS.concat(["node-menu-copyimagedatauri"]),
+    disabled: PASTE_MENU_ITEMS.concat([
+      "node-menu-copyimagedatauri",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
+    ]),
   },
   {
     desc: "<element> with empty string on clipboard",
     clipboardData: "",
     clipboardDataType: undefined,
     selector: "#paste-area",
-    disabled: PASTE_MENU_ITEMS.concat(["node-menu-copyimagedatauri"]),
+    disabled: PASTE_MENU_ITEMS.concat([
+      "node-menu-copyimagedatauri",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
+    ]),
   },
   {
     desc: "<element> with whitespace only on clipboard",
     clipboardData: " \n\n\t\n\n  \n",
     clipboardDataType: undefined,
     selector: "#paste-area",
-    disabled: PASTE_MENU_ITEMS.concat(["node-menu-copyimagedatauri"]),
+    disabled: PASTE_MENU_ITEMS.concat([
+      "node-menu-copyimagedatauri",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
+    ]),
   },
   {
     desc: "<element> that isn't visible on the page, empty clipboard",
     selector: "#hiddenElement",
     disabled: PASTE_MENU_ITEMS.concat([
       "node-menu-copyimagedatauri",
       "node-menu-screenshotnode",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
     ]),
   },
   {
     desc: "<element> nested in another hidden element, empty clipboard",
     selector: "#nestedHiddenElement",
     disabled: PASTE_MENU_ITEMS.concat([
       "node-menu-copyimagedatauri",
       "node-menu-screenshotnode",
+      "node-menu-edit-attribute",
+      "node-menu-remove-attribute"
     ]),
+  },
+  {
+    desc: "<element> with context menu triggered on attribute, empty clipboard",
+    selector: "#attributes",
+    disabled: PASTE_MENU_ITEMS.concat(["node-menu-copyimagedatauri"]),
+    attributeTrigger: "data-edit"
   }
 ];
 
 var clipboard = require("sdk/clipboard");
 registerCleanupFunction(() => {
   clipboard = null;
 });
 
 add_task(function *() {
   let { inspector } = yield openInspectorForURL(TEST_URL);
   for (let test of TEST_CASES) {
-    let { desc, disabled, selector } = test;
+    let { desc, disabled, selector, attributeTrigger } = test;
 
     info(`Test ${desc}`);
     setupClipboard(test.clipboardData, test.clipboardDataType);
 
     let front = yield getNodeFrontForSelector(selector, inspector);
 
     info("Selecting the specified node.");
     yield selectNode(front, inspector);
 
     info("Simulating context menu click on the selected node container.");
-    contextMenuClick(getContainerForNodeFront(front, inspector).tagLine);
+    let nodeFrontContainer = getContainerForNodeFront(front, inspector);
+    let contextMenuTrigger = attributeTrigger
+      ? nodeFrontContainer.tagLine.querySelector(`[data-attr="${attributeTrigger}"]`)
+      : nodeFrontContainer.tagLine;
+    contextMenuClick(contextMenuTrigger);
 
     for (let menuitem of ALL_MENU_ITEMS) {
       let elt = inspector.panelDoc.getElementById(menuitem);
       let shouldBeDisabled = disabled.indexOf(menuitem) !== -1;
       let isDisabled = elt.hasAttribute("disabled");
 
       is(isDisabled, shouldBeDisabled,
         `#${menuitem} should be ${shouldBeDisabled ? "disabled" : "enabled"} `);
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_menu-05-attribute-items.js
@@ -0,0 +1,75 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that attribute items work in the context menu
+
+const TEST_URL = TEST_URL_ROOT + "doc_inspector_menu.html";
+
+add_task(function* () {
+  let { inspector, toolbox, testActor } = yield openInspectorForURL(TEST_URL);
+  yield selectNode("#attributes", inspector);
+
+  yield testAddAttribute();
+  yield testEditAttribute();
+  yield testRemoveAttribute();
+
+  function* testAddAttribute() {
+    info("Testing 'Add Attribute' menu item");
+    let addAttribute = getMenuItem("node-menu-add-attribute");
+
+    info("Triggering 'Add Attribute' and waiting for mutation to occur");
+    dispatchCommandEvent(addAttribute);
+    EventUtils.synthesizeKey('class="u-hidden"', {});
+    let onMutation = inspector.once("markupmutation");
+    EventUtils.synthesizeKey('VK_RETURN', {});
+    yield onMutation;
+
+    let hasAttribute = testActor.hasNode("#attributes.u-hidden");
+    ok(hasAttribute, "attribute was successfully added");
+  }
+
+  function* testEditAttribute() {
+    info("Testing 'Edit Attribute' menu item");
+    let editAttribute = getMenuItem("node-menu-edit-attribute");
+
+    info("Triggering 'Edit Attribute' and waiting for mutation to occur");
+    inspector.nodeMenuTriggerInfo = {
+      type: "attribute",
+      name: "data-edit"
+    };
+    dispatchCommandEvent(editAttribute);
+    EventUtils.synthesizeKey("data-edit='edited'", {});
+    let onMutation = inspector.once("markupmutation");
+    EventUtils.synthesizeKey('VK_RETURN', {});
+    yield onMutation;
+
+    let isAttributeChanged =
+      yield testActor.hasNode("#attributes[data-edit='edited']");
+    ok(isAttributeChanged, "attribute was successfully edited");
+  }
+
+  function* testRemoveAttribute() {
+    info("Testing 'Remove Attribute' menu item");
+    let removeAttribute = getMenuItem("node-menu-remove-attribute");
+
+    info("Triggering 'Remove Attribute' and waiting for mutation to occur");
+    inspector.nodeMenuTriggerInfo = {
+      type: "attribute",
+      name: "data-remove"
+    };
+    let onMutation = inspector.once("markupmutation");
+    dispatchCommandEvent(removeAttribute);
+    yield onMutation;
+
+    let hasAttribute = yield testActor.hasNode("#attributes[data-remove]")
+    ok(!hasAttribute, "attribute was successfully removed");
+  }
+
+  function getMenuItem(id) {
+    let attribute = inspector.panelDoc.getElementById(id);
+    ok(attribute, "Menu item '" + id + "' found");
+    return attribute;
+  }
+});
rename from devtools/client/inspector/test/browser_inspector_menu-05-other.js
rename to devtools/client/inspector/test/browser_inspector_menu-06-other.js
--- a/devtools/client/inspector/test/doc_inspector_menu.html
+++ b/devtools/client/inspector/test/doc_inspector_menu.html
@@ -18,11 +18,12 @@
       <p class="duplicate">This will be duplicated</p>
       <p id="delete">This has to be deleted</p>
       <img id="copyimage" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQYV2P4DwABAQEAWk1v8QAAAABJRU5ErkJggg==" />
       <div id="hiddenElement" style="display: none;">
         <p id="nestedHiddenElement">Visible element nested inside a non-visible element</p>
       </div>
       <p id="console-var">Paragraph for testing console variables</p>
       <p id="console-var-multi">Paragraph for testing multiple console variables</p>
+      <p id="attributes" data-edit="original" data-remove="thing">Attributes are going to be changed here</p>
     </div>
   </body>
 </html>
--- a/devtools/client/markupview/markup-view.js
+++ b/devtools/client/markupview/markup-view.js
@@ -2300,16 +2300,46 @@ MarkupElementContainer.prototype = Herit
   setSingleTextChild: function(singleTextChild) {
     this.singleTextChild = singleTextChild;
     this.editor.updateTextEditor();
   },
 
   clearSingleTextChild: function() {
     this.singleTextChild = undefined;
     this.editor.updateTextEditor();
+  },
+
+  /**
+   * Trigger new attribute field for input.
+   */
+  addAttribute: function() {
+    this.editor.newAttr.editMode();
+  },
+
+  /**
+   * Trigger attribute field for editing.
+   */
+  editAttribute: function(attrName) {
+    this.editor.attrElements.get(attrName).editMode();
+  },
+
+  /**
+   * Remove attribute from container.
+   * This is an undoable action.
+   */
+  removeAttribute: function(attrName) {
+    let doMods = this.editor._startModifyingAttributes();
+    let undoMods = this.editor._startModifyingAttributes();
+    this.editor._saveAttribute(attrName, undoMods);
+    doMods.removeAttribute(attrName);
+    this.undo.do(() => {
+      doMods.apply();
+    }, () => {
+      undoMods.apply();
+    });
   }
 });
 
 /**
  * Dummy container node used for the root document element.
  */
 function RootContainer(aMarkupView, aNode) {
   this.doc = aMarkupView.doc;
@@ -2356,16 +2386,23 @@ function GenericEditor(aContainer, aNode
   } else {
     this.tag.textContent = aNode.nodeName;
   }
 }
 
 GenericEditor.prototype = {
   destroy: function() {
     this.elt.remove();
+  },
+
+  /**
+   * Stub method for consistency with ElementEditor.
+   */
+  getInfoAtNode: function() {
+    return null;
   }
 };
 
 /**
  * Creates a simple text editor node, used for TEXT and COMMENT
  * nodes.
  *
  * @param MarkupContainer aContainer The container owning this editor.
@@ -2438,17 +2475,24 @@ TextEditor.prototype = {
         if (this.selected) {
           this.value.textContent = str;
           this.markup.emit("text-expand")
         }
       }).then(null, console.error);
     }
   },
 
-  destroy: function() {}
+  destroy: function() {},
+
+  /**
+   * Stub method for consistency with ElementEditor.
+   */
+  getInfoAtNode: function() {
+    return null;
+  }
 };
 
 /**
  * Creates an editor for an Element node.
  *
  * @param MarkupContainer aContainer The container owning this editor.
  * @param Element aNode The node being edited.
  */
@@ -2492,28 +2536,24 @@ function ElementEditor(aContainer, aNode
     stopOnReturn: true,
     contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
     popup: this.markup.popup,
     done: (aVal, aCommit) => {
       if (!aCommit) {
         return;
       }
 
-      try {
-        let doMods = this._startModifyingAttributes();
-        let undoMods = this._startModifyingAttributes();
-        this._applyAttributes(aVal, null, doMods, undoMods);
-        this.container.undo.do(() => {
-          doMods.apply();
-        }, function() {
-          undoMods.apply();
-        });
-      } catch(x) {
-        console.error(x);
-      }
+      let doMods = this._startModifyingAttributes();
+      let undoMods = this._startModifyingAttributes();
+      this._applyAttributes(aVal, null, doMods, undoMods);
+      this.container.undo.do(() => {
+        doMods.apply();
+      }, function() {
+        undoMods.apply();
+      });
     }
   });
 
   let tagName = this.node.nodeName.toLowerCase();
   this.tag.textContent = tagName;
   this.closeTag.textContent = tagName;
   this.eventNode.style.display = this.node.hasEventListeners ? "inline-block" : "none";
 
@@ -2535,16 +2575,45 @@ ElementEditor.prototype = {
     }
 
     flashElementOn(this.getAttributeElement(attrName));
 
     this.animationTimers[attrName] = setTimeout(() => {
       flashElementOff(this.getAttributeElement(attrName));
     }, this.markup.CONTAINER_FLASHING_DURATION);
   },
+  /**
+   * Returns information about node in the editor.
+   *
+   * @param {DOMNode} node
+   *        The node to get information from.
+   *
+   * @return {Object}
+   *         An object literal with the following information:
+   *         {type: "attribute", name: "rel", value: "index", el: node}
+   */
+  getInfoAtNode: function(node) {
+    if (!node) {
+      return null;
+    }
+
+    let type = null;
+    let name = null;
+    let value = null;
+
+    // Attribute
+    let attribute = node.closest('.attreditor');
+    if (attribute) {
+      type = "attribute";
+      name = attribute.querySelector('.attr-name').textContent;
+      value = attribute.querySelector('.attr-value').textContent;
+    }
+
+    return {type, name, value, el: node};
+  },
 
   /**
    * Update the state of the editor from the node.
    */
   update: function() {
     let nodeAttributes = this.node.attributes || [];
 
     // Keep the data model in sync with attributes on the node.
@@ -2692,29 +2761,25 @@ ElementEditor.prototype = {
         }
 
         let doMods = this._startModifyingAttributes();
         let undoMods = this._startModifyingAttributes();
 
         // Remove the attribute stored in this editor and re-add any attributes
         // parsed out of the input element. Restore original attribute if
         // parsing fails.
-        try {
-          this.refocusOnEdit(aAttr.name, attr, direction);
-          this._saveAttribute(aAttr.name, undoMods);
-          doMods.removeAttribute(aAttr.name);
-          this._applyAttributes(aVal, attr, doMods, undoMods);
-          this.container.undo.do(() => {
-            doMods.apply();
-          }, () => {
-            undoMods.apply();
-          });
-        } catch(ex) {
-          console.error(ex);
-        }
+        this.refocusOnEdit(aAttr.name, attr, direction);
+        this._saveAttribute(aAttr.name, undoMods);
+        doMods.removeAttribute(aAttr.name);
+        this._applyAttributes(aVal, attr, doMods, undoMods);
+        this.container.undo.do(() => {
+          doMods.apply();
+        }, () => {
+          undoMods.apply();
+        });
       }
     });
 
     // Figure out where we should place the attribute.
     let before = aBefore;
     if (aAttr.name == "id") {
       before = this.attrList.firstChild;
     } else if (aAttr.name == "class") {