Bug 1419083 - Add an "Open in sidebar" context menu entry on ObjectInspector. r=nchevobbe draft
authorMike Park <mikeparkms@gmail.com>
Tue, 05 Dec 2017 16:05:13 -0500
changeset 711234 14084b00562352ea8f668f76e23c2ba0a6fbdb4f
parent 711233 3bfc9caf5b984f2f7b010e2c7158028feafa1a23
child 743773 450194e43bbd846b970f0327f39301d1037828fd
push id93031
push userbmo:mpark@mozilla.com
push dateWed, 13 Dec 2017 16:34:14 +0000
reviewersnchevobbe
bugs1419083
milestone59.0a1
Bug 1419083 - Add an "Open in sidebar" context menu entry on ObjectInspector. r=nchevobbe MozReview-Commit-ID: 9a2fBjpZ6zE
devtools/client/locales/en-US/webconsole.properties
devtools/client/themes/webconsole.css
devtools/client/webconsole/new-console-output/actions/ui.js
devtools/client/webconsole/new-console-output/components/FilterBar.js
devtools/client/webconsole/new-console-output/components/SideBar.js
devtools/client/webconsole/new-console-output/components/message-types/ConsoleApiCall.js
devtools/client/webconsole/new-console-output/constants.js
devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
devtools/client/webconsole/new-console-output/reducers/ui.js
devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_close_sidebar.js
devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_object_in_sidebar.js
devtools/client/webconsole/new-console-output/test/store/ui.test.js
devtools/client/webconsole/new-console-output/utils/context-menu.js
--- a/devtools/client/locales/en-US/webconsole.properties
+++ b/devtools/client/locales/en-US/webconsole.properties
@@ -246,16 +246,22 @@ webconsole.menu.copyObject.label=Copy ob
 webconsole.menu.copyObject.accesskey=o
 
 # LOCALIZATION NOTE (webconsole.menu.selectAll.label)
 # Label used for a context-menu item that will select all the content of the webconsole
 # output.
 webconsole.menu.selectAll.label=Select all
 webconsole.menu.selectAll.accesskey=A
 
+# LOCALIZATION NOTE (webconsole.menu.openInSidebar.label)
+# Label used for a context-menu item displayed for object/variable logs. Clicking on it
+# opens the webconsole sidebar for the logged variable.
+webconsole.menu.openInSidebar.label=Open in sidebar
+webconsole.menu.openInSidebar.accesskey=V
+
 # LOCALIZATION NOTE (webconsole.clearButton.tooltip)
 # Label used for the tooltip on the clear logs button in the console top toolbar bar.
 # Clicking on it will clear the content of the console.
 webconsole.clearButton.tooltip=Clear the Web Console output
 
 # LOCALIZATION NOTE (webconsole.toggleFilterButton.tooltip)
 # Label used for the tooltip on the toggle filter bar button in the console top
 # toolbar bar. Clicking on it will toggle the visibility of an additional bar which
--- a/devtools/client/themes/webconsole.css
+++ b/devtools/client/themes/webconsole.css
@@ -1212,27 +1212,29 @@ body #output-container {
   /* Set to prevent the sidebar from extending past the right edge of the page */
   width: unset;
 }
 
 .sidebar-wrapper {
   display: grid;
   grid-template-rows: auto 1fr;
   width: 100%;
+  overflow: hidden;
 }
 
 .webconsole-sidebar-toolbar {
   grid-row: 1 / 2;
   min-height: var(--primary-toolbar-height);
   display: flex;
   justify-content: end;
 }
 
 .sidebar-contents {
   grid-row: 2 / 3;
+  overflow: scroll;
 }
 
 .webconsole-sidebar-toolbar .sidebar-close-button {
   padding: 4px 0;
   margin: 0;
   margin-inline-end: -3px;
 }
 
--- a/devtools/client/webconsole/new-console-output/actions/ui.js
+++ b/devtools/client/webconsole/new-console-output/actions/ui.js
@@ -2,25 +2,27 @@
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* 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/. */
 
 "use strict";
 
 const { getAllUi } = require("devtools/client/webconsole/new-console-output/selectors/ui");
+const { getMessage } = require("devtools/client/webconsole/new-console-output/selectors/messages");
 const Services = require("Services");
 
 const {
   FILTER_BAR_TOGGLE,
   INITIALIZE,
   PERSIST_TOGGLE,
   PREFS,
   SELECT_NETWORK_MESSAGE_TAB,
-  SIDEBAR_TOGGLE,
+  SIDEBAR_CLOSE,
+  SHOW_OBJECT_IN_SIDEBAR,
   TIMESTAMPS_TOGGLE,
 } = require("devtools/client/webconsole/new-console-output/constants");
 
 function filterBarToggle(show) {
   return (dispatch, getState) => {
     dispatch({
       type: FILTER_BAR_TOGGLE,
     });
@@ -54,22 +56,40 @@ function selectNetworkMessageTab(id) {
 }
 
 function initialize() {
   return {
     type: INITIALIZE
   };
 }
 
-function sidebarToggle(show) {
+function sidebarClose(show) {
   return {
-    type: SIDEBAR_TOGGLE,
+    type: SIDEBAR_CLOSE,
+  };
+}
+
+function showObjectInSidebar(actorId, messageId) {
+  return (dispatch, getState) => {
+    let { parameters } = getMessage(getState(), messageId);
+    if (Array.isArray(parameters)) {
+      for (let parameter of parameters) {
+        if (parameter.actor === actorId) {
+          dispatch({
+            type: SHOW_OBJECT_IN_SIDEBAR,
+            grip: parameter
+          });
+          return;
+        }
+      }
+    }
   };
 }
 
 module.exports = {
   filterBarToggle,
   initialize,
   persistToggle,
   selectNetworkMessageTab,
-  sidebarToggle,
+  sidebarClose,
+  showObjectInSidebar,
   timestampsToggle,
 };
--- a/devtools/client/webconsole/new-console-output/components/FilterBar.js
+++ b/devtools/client/webconsole/new-console-output/components/FilterBar.js
@@ -27,25 +27,23 @@ class FilterBar extends Component {
       dispatch: PropTypes.func.isRequired,
       filter: PropTypes.object.isRequired,
       serviceContainer: PropTypes.shape({
         attachRefToHud: PropTypes.func.isRequired,
       }).isRequired,
       filterBarVisible: PropTypes.bool.isRequired,
       persistLogs: PropTypes.bool.isRequired,
       filteredMessagesCount: PropTypes.object.isRequired,
-      sidebarToggle: PropTypes.bool,
     };
   }
 
   constructor(props) {
     super(props);
     this.onClickMessagesClear = this.onClickMessagesClear.bind(this);
     this.onClickFilterBarToggle = this.onClickFilterBarToggle.bind(this);
-    this.onClickSidebarToggle = this.onClickSidebarToggle.bind(this);
     this.onClickRemoveAllFilters = this.onClickRemoveAllFilters.bind(this);
     this.onClickRemoveTextFilter = this.onClickRemoveTextFilter.bind(this);
     this.onSearchInput = this.onSearchInput.bind(this);
     this.onChangePersistToggle = this.onChangePersistToggle.bind(this);
     this.renderFiltersConfigBar = this.renderFiltersConfigBar.bind(this);
     this.renderFilteredMessagesBar = this.renderFilteredMessagesBar.bind(this);
   }
 
@@ -82,20 +80,16 @@ class FilterBar extends Component {
   onClickMessagesClear() {
     this.props.dispatch(actions.messagesClear());
   }
 
   onClickFilterBarToggle() {
     this.props.dispatch(actions.filterBarToggle());
   }
 
-  onClickSidebarToggle() {
-    this.props.dispatch(actions.sidebarToggle());
-  }
-
   onClickRemoveAllFilters() {
     this.props.dispatch(actions.defaultFiltersReset());
   }
 
   onClickRemoveTextFilter() {
     this.props.dispatch(actions.filterTextSet(""));
   }
 
@@ -221,17 +215,16 @@ class FilterBar extends Component {
   }
 
   render() {
     const {
       filter,
       filterBarVisible,
       persistLogs,
       filteredMessagesCount,
-      sidebarToggle,
     } = this.props;
 
     let children = [
       dom.div({
         className: "devtools-toolbar webconsole-filterbar-primary",
         key: "primary-bar",
       },
         dom.button({
@@ -256,23 +249,16 @@ class FilterBar extends Component {
           onInput: this.onSearchInput
         }),
         FilterCheckbox({
           label: l10n.getStr("webconsole.enablePersistentLogs.label"),
           title: l10n.getStr("webconsole.enablePersistentLogs.tooltip"),
           onChange: this.onChangePersistToggle,
           checked: persistLogs,
         }),
-        sidebarToggle ?
-          dom.button({
-            className: "devtools-button webconsole-sidebar-button",
-            title: l10n.getStr("webconsole.toggleFilterButton.tooltip"),
-            onClick: this.onClickSidebarToggle
-          }, "Toggle Sidebar")
-          : null,
       )
     ];
 
     if (filteredMessagesCount.global > 0) {
       children.push(this.renderFilteredMessagesBar());
     }
 
     if (filterBarVisible) {
@@ -293,13 +279,12 @@ class FilterBar extends Component {
 
 function mapStateToProps(state) {
   let uiState = getAllUi(state);
   return {
     filter: getAllFilters(state),
     filterBarVisible: uiState.filterBarVisible,
     persistLogs: uiState.persistLogs,
     filteredMessagesCount: getFilteredMessagesCount(state),
-    sidebarToggle: state.prefs.sidebarToggle,
   };
 }
 
 module.exports = connect(mapStateToProps)(FilterBar);
--- a/devtools/client/webconsole/new-console-output/components/SideBar.js
+++ b/devtools/client/webconsole/new-console-output/components/SideBar.js
@@ -9,48 +9,50 @@ const PropTypes = require("devtools/clie
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 const actions = require("devtools/client/webconsole/new-console-output/actions/index");
 const SplitBox = createFactory(require("devtools/client/shared/components/splitter/SplitBox"));
 
 class SideBar extends Component {
   static get propTypes() {
     return {
       dispatch: PropTypes.func.isRequired,
-      sidebarVisible: PropTypes.bool
+      sidebarVisible: PropTypes.bool,
+      grip: PropTypes.object,
     };
   }
 
   constructor(props) {
     super(props);
-    this.onClickSidebarToggle = this.onClickSidebarToggle.bind(this);
+    this.onClickSidebarClose = this.onClickSidebarClose.bind(this);
   }
 
-  onClickSidebarToggle() {
-    this.props.dispatch(actions.sidebarToggle());
+  onClickSidebarClose() {
+    this.props.dispatch(actions.sidebarClose());
   }
 
   render() {
     let {
       sidebarVisible,
+      grip,
     } = this.props;
 
     let endPanel = dom.aside({
       className: "sidebar-wrapper"
     },
       dom.header({
         className: "devtools-toolbar webconsole-sidebar-toolbar"
       },
         dom.button({
           className: "devtools-button sidebar-close-button",
-          onClick: this.onClickSidebarToggle
+          onClick: this.onClickSidebarClose
         })
       ),
       dom.aside({
         className: "sidebar-contents"
-      }, "Sidebar WIP")
+      }, JSON.stringify(grip, null, 2))
     );
 
     return (
       sidebarVisible ?
         SplitBox({
           className: "sidebar",
           endPanel,
           endPanelControl: true,
@@ -61,12 +63,13 @@ class SideBar extends Component {
         : null
     );
   }
 }
 
 function mapStateToProps(state, props) {
   return {
     sidebarVisible: state.ui.sidebarVisible,
+    grip: state.ui.gripInSidebar,
   };
 }
 
 module.exports = connect(mapStateToProps)(SideBar);
--- a/devtools/client/webconsole/new-console-output/components/message-types/ConsoleApiCall.js
+++ b/devtools/client/webconsole/new-console-output/components/message-types/ConsoleApiCall.js
@@ -112,16 +112,17 @@ function ConsoleApiCall(props) {
     frame,
     stacktrace,
     attachment,
     serviceContainer,
     dispatch,
     indent,
     timeStamp,
     timestampsVisible,
+    parameters,
   });
 }
 
 function formatReps(options = {}) {
   const {
     dispatch,
     loadedObjectProperties,
     loadedObjectEntries,
--- a/devtools/client/webconsole/new-console-output/constants.js
+++ b/devtools/client/webconsole/new-console-output/constants.js
@@ -19,17 +19,18 @@ const actionTypes = {
   MESSAGE_TABLE_RECEIVE: "MESSAGE_TABLE_RECEIVE",
   MESSAGES_ADD: "MESSAGES_ADD",
   MESSAGES_CLEAR: "MESSAGES_CLEAR",
   NETWORK_MESSAGE_UPDATE: "NETWORK_MESSAGE_UPDATE",
   NETWORK_UPDATE_REQUEST: "NETWORK_UPDATE_REQUEST",
   PERSIST_TOGGLE: "PERSIST_TOGGLE",
   REMOVED_ACTORS_CLEAR: "REMOVED_ACTORS_CLEAR",
   SELECT_NETWORK_MESSAGE_TAB: "SELECT_NETWORK_MESSAGE_TAB",
-  SIDEBAR_TOGGLE: "SIDEBAR_TOGGLE",
+  SIDEBAR_CLOSE: "SIDEBAR_CLOSE",
+  SHOW_OBJECT_IN_SIDEBAR: "SHOW_OBJECT_IN_SIDEBAR",
   TIMESTAMPS_TOGGLE: "TIMESTAMPS_TOGGLE",
 };
 
 const prefs = {
   PREFS: {
     FILTER: {
       ERROR: "devtools.webconsole.filter.error",
       WARN: "devtools.webconsole.filter.warn",
--- a/devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
+++ b/devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
@@ -114,21 +114,33 @@ NewConsoleOutputWrapper.prototype = {
         let messageVariable = target.closest(".objectBox");
         // Ensure that console.group and console.groupCollapsed commands are not captured
         let variableText = (messageVariable
           && !(messageEl.classList.contains("startGroup"))
           && !(messageEl.classList.contains("startGroupCollapsed")))
             ? messageVariable.textContent : null;
 
         // Retrieve closes actor id from the DOM.
-        let actorEl = target.closest("[data-link-actor-id]");
+        let actorEl = target.closest("[data-link-actor-id]") ||
+                      target.querySelector("[data-link-actor-id]");
         let actor = actorEl ? actorEl.dataset.linkActorId : null;
 
+        let rootObjectInspector = target.closest(".object-inspector");
+        let rootActor = rootObjectInspector ?
+                        rootObjectInspector.querySelector("[data-link-actor-id]") : null;
+        let rootActorId = rootActor ? rootActor.dataset.linkActorId : null;
+
+        let sidebarTogglePref = store.getState().prefs.sidebarToggle;
+        let openSidebar = sidebarTogglePref ? (messageId) => {
+          store.dispatch(actions.showObjectInSidebar(rootActorId, messageId));
+        } : null;
+
         let menu = createContextMenu(this.jsterm, this.parentNode,
-          { actor, clipboardText, variableText, message, serviceContainer });
+          { actor, clipboardText, variableText, message,
+            serviceContainer, openSidebar, rootActorId });
 
         // Emit the "menu-open" event for testing.
         menu.once("open", () => this.emit("menu-open"));
         menu.popup(screenX, screenY, this.toolbox);
 
         return menu;
       };
 
--- a/devtools/client/webconsole/new-console-output/reducers/ui.js
+++ b/devtools/client/webconsole/new-console-output/reducers/ui.js
@@ -5,50 +5,60 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const {
   FILTER_BAR_TOGGLE,
   INITIALIZE,
   PERSIST_TOGGLE,
   SELECT_NETWORK_MESSAGE_TAB,
-  SIDEBAR_TOGGLE,
+  SIDEBAR_CLOSE,
+  SHOW_OBJECT_IN_SIDEBAR,
   TIMESTAMPS_TOGGLE,
   MESSAGES_CLEAR,
 } = require("devtools/client/webconsole/new-console-output/constants");
 
 const {
   PANELS,
 } = require("devtools/client/netmonitor/src/constants");
 
 const UiState = (overrides) => Object.freeze(Object.assign({
   filterBarVisible: false,
   initialized: false,
   networkMessageActiveTabId: PANELS.HEADERS,
   persistLogs: false,
   sidebarVisible: false,
   timestampsVisible: true,
+  gripInSidebar: null
 }, overrides));
 
 function ui(state = UiState(), action) {
   switch (action.type) {
     case FILTER_BAR_TOGGLE:
       return Object.assign({}, state, {filterBarVisible: !state.filterBarVisible});
     case PERSIST_TOGGLE:
       return Object.assign({}, state, {persistLogs: !state.persistLogs});
     case TIMESTAMPS_TOGGLE:
       return Object.assign({}, state, {timestampsVisible: action.visible});
     case SELECT_NETWORK_MESSAGE_TAB:
       return Object.assign({}, state, {networkMessageActiveTabId: action.id});
-    case SIDEBAR_TOGGLE:
-      return Object.assign({}, state, {sidebarVisible: !state.sidebarVisible});
+    case SIDEBAR_CLOSE:
+      return Object.assign({}, state, {
+        sidebarVisible: !state.sidebarVisible,
+        gripInSidebar: null
+      });
     case INITIALIZE:
       return Object.assign({}, state, {initialized: true});
     case MESSAGES_CLEAR:
-      return Object.assign({}, state, {sidebarVisible: false});
+      return Object.assign({}, state, {sidebarVisible: false, gripInSidebar: null});
+    case SHOW_OBJECT_IN_SIDEBAR:
+      if (action.grip === state.gripInSidebar) {
+        return state;
+      }
+      return Object.assign({}, state, {sidebarVisible: true, gripInSidebar: action.grip});
   }
 
   return state;
 }
 
 module.exports = {
   UiState,
   ui,
--- a/devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
@@ -255,16 +255,17 @@ skip-if = true # Bug 1405252
 [browser_webconsole_context_menu_copy_entire_message.js]
 subsuite = clipboard
 skip-if = (os == 'linux' && bits == 32 && debug) # bug 1328915, disable linux32 debug devtools for timeouts
 [browser_webconsole_context_menu_copy_link_location.js]
 subsuite = clipboard
 skip-if = (os == 'linux' && bits == 32 && debug) # bug 1328915, disable linux32 debug devtools for timeouts
 [browser_webconsole_context_menu_copy_object.js]
 subsuite = clipboard
+[browser_webconsole_context_menu_object_in_sidebar.js]
 [browser_webconsole_context_menu_open_url.js]
 [browser_webconsole_context_menu_store_as_global.js]
 [browser_webconsole_csp_ignore_reflected_xss_message.js]
 skip-if = (e10s && debug) || (e10s && os == 'win') # Bug 1221499 enabled these on windows
 [browser_webconsole_cspro.js]
 skip-if = true # Bug 1408932
 # old console skip-if = e10s && (os == 'win' || os == 'mac') # Bug 1243967
 [browser_webconsole_document_focus.js]
--- a/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_close_sidebar.js
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_close_sidebar.js
@@ -58,14 +58,24 @@ add_task(async function () {
   let onSidebarShown = waitForNodeMutation(wrapper, { childList: true });
   closeButton.click();
   await onSidebarShown;
   sidebar = hud.ui.document.querySelector(".sidebar");
   ok(!sidebar, "Sidebar hidden after clicking on close button");
 });
 
 async function showSidebar(hud) {
-  let toggleButton = hud.ui.document.querySelector(".webconsole-sidebar-button");
+  let onMessage = waitForMessage(hud, "Object");
+  ContentTask.spawn(gBrowser.selectedBrowser, {}, function () {
+    content.wrappedJSObject.console.log({a: 1});
+  });
+  await onMessage;
+
+  let objectNode = hud.ui.outputNode.querySelector(".object-inspector .objectBox");
   let wrapper = hud.ui.document.querySelector(".webconsole-output-wrapper");
   let onSidebarShown = waitForNodeMutation(wrapper, { childList: true });
-  toggleButton.click();
+
+  let contextMenu = await openContextMenu(hud, objectNode);
+  let openInSidebar = contextMenu.querySelector("#console-menu-open-sidebar");
+  openInSidebar.click();
   await onSidebarShown;
+  await hideContextMenu(hud);
 }
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_object_in_sidebar.js
@@ -0,0 +1,90 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that the "Open in sidebar" context menu entry is active for
+// the correct objects and opens the sidebar when clicked.
+
+"use strict";
+
+const TEST_URI =
+  "data:text/html;charset=utf8," +
+  "<script>console.log({a:1},100,{b:1},'foo',false,null,undefined);</script>";
+
+add_task(async function () {
+  // Should be removed when sidebar work is complete
+  await pushPref("devtools.webconsole.sidebarToggle", true);
+
+  let hud = await openNewTabAndConsole(TEST_URI);
+
+  let message = findMessage(hud, "foo");
+  let [objectA, objectB] =
+    message.querySelectorAll(".object-inspector .objectBox-object");
+  let number = findMessage(hud, "100", ".objectBox");
+  let string = findMessage(hud, "foo", ".objectBox");
+  let bool = findMessage(hud, "false", ".objectBox");
+  let nullMessage = findMessage(hud, "null", ".objectBox");
+  let undefinedMsg = findMessage(hud, "undefined", ".objectBox");
+
+  info("Showing sidebar for {a:1}");
+  await showSidebarWithContextMenu(hud, objectA, true);
+
+  let sidebarText = hud.ui.document.querySelector(".sidebar-contents").textContent;
+  ok(sidebarText.includes('"a":'), "Sidebar is shown for {a:1}");
+
+  info("Showing sidebar for {a:1} again");
+  await showSidebarWithContextMenu(hud, objectA, false);
+  ok(hud.ui.document.querySelector(".sidebar"),
+     "Sidebar is still shown after clicking on same object");
+  is(hud.ui.document.querySelector(".sidebar-contents").textContent, sidebarText,
+     "Sidebar is not updated after clicking on same object");
+
+  info("Showing sidebar for {b:1}");
+  await showSidebarWithContextMenu(hud, objectB, false);
+  isnot(hud.ui.document.querySelector(".sidebar-contents").textContent, sidebarText,
+        "Sidebar is updated for {b:1}");
+  sidebarText = hud.ui.document.querySelector(".sidebar-contents").textContent;
+  ok(sidebarText.includes('"b":'), "Sidebar contents shown for {b:1}");
+
+  info("Checking context menu entry is disabled for number");
+  let numberContextMenuEnabled = await isContextMenuEntryEnabled(hud, number);
+  ok(!numberContextMenuEnabled, "Context menu entry is disabled for number");
+
+  info("Checking context menu entry is disabled for string");
+  let stringContextMenuEnabled = await isContextMenuEntryEnabled(hud, string);
+  ok(!stringContextMenuEnabled, "Context menu entry is disabled for string");
+
+  info("Checking context menu entry is disabled for bool");
+  let boolContextMenuEnabled = await isContextMenuEntryEnabled(hud, bool);
+  ok(!boolContextMenuEnabled, "Context menu entry is disabled for bool");
+
+  info("Checking context menu entry is disabled for null message");
+  let nullContextMenuEnabled = await isContextMenuEntryEnabled(hud, nullMessage);
+  ok(!nullContextMenuEnabled, "Context menu entry is disabled for nullMessage");
+
+  info("Checking context menu entry is disabled for undefined message");
+  let undefinedContextMenuEnabled = await isContextMenuEntryEnabled(hud, undefinedMsg);
+  ok(!undefinedContextMenuEnabled, "Context menu entry is disabled for undefinedMsg");
+});
+
+async function showSidebarWithContextMenu(hud, node, expectMutation) {
+  let wrapper = hud.ui.document.querySelector(".webconsole-output-wrapper");
+  let onSidebarShown = waitForNodeMutation(wrapper, { childList: true });
+
+  let contextMenu = await openContextMenu(hud, node);
+  let openInSidebar = contextMenu.querySelector("#console-menu-open-sidebar");
+  openInSidebar.click();
+  if (expectMutation) {
+    await onSidebarShown;
+  }
+  await hideContextMenu(hud);
+}
+
+async function isContextMenuEntryEnabled(hud, node) {
+  let contextMenu = await openContextMenu(hud, node);
+  let openInSidebar = contextMenu.querySelector("#console-menu-open-sidebar");
+  let enabled = !openInSidebar.attributes.disabled;
+  await hideContextMenu(hud);
+  return enabled;
+}
--- a/devtools/client/webconsole/new-console-output/test/store/ui.test.js
+++ b/devtools/client/webconsole/new-console-output/test/store/ui.test.js
@@ -2,36 +2,98 @@
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const expect = require("expect");
 
 const actions = require("devtools/client/webconsole/new-console-output/actions/index");
 const { setupStore } = require("devtools/client/webconsole/new-console-output/test/helpers");
+const { getAllMessagesById } = require("devtools/client/webconsole/new-console-output/selectors/messages");
+const { stubPackets, stubPreparedMessages } = require("devtools/client/webconsole/new-console-output/test/fixtures/stubs/index");
 
 describe("Testing UI", () => {
   let store;
 
   beforeEach(() => {
     store = setupStore();
   });
 
   describe("Toggle sidebar", () => {
     it("sidebar is toggled on and off", () => {
-      store.dispatch(actions.sidebarToggle());
+      store.dispatch(actions.sidebarClose());
       expect(store.getState().ui.sidebarVisible).toEqual(true);
-      store.dispatch(actions.sidebarToggle());
+      store.dispatch(actions.sidebarClose());
       expect(store.getState().ui.sidebarVisible).toEqual(false);
     });
   });
 
   describe("Hide sidebar on clear", () => {
     it("sidebar is hidden on clear", () => {
-      store.dispatch(actions.sidebarToggle());
+      store.dispatch(actions.sidebarClose());
       expect(store.getState().ui.sidebarVisible).toEqual(true);
       store.dispatch(actions.messagesClear());
       expect(store.getState().ui.sidebarVisible).toEqual(false);
       store.dispatch(actions.messagesClear());
       expect(store.getState().ui.sidebarVisible).toEqual(false);
     });
   });
+
+  describe("Show object in sidebar", () => {
+    it("sidebar is shown with correct object", () => {
+      const packet = stubPackets.get("inspect({a: 1})");
+      const message = stubPreparedMessages.get("inspect({a: 1})");
+      store.dispatch(actions.messageAdd(packet));
+
+      const messages = getAllMessagesById(store.getState());
+      const actorId = message.parameters[0].actor;
+      const messageId = messages.first().id;
+      store.dispatch(actions.showObjectInSidebar(actorId, messageId));
+
+      expect(store.getState().ui.sidebarVisible).toEqual(true);
+      expect(store.getState().ui.gripInSidebar).toEqual(message.parameters[0]);
+    });
+
+    it("sidebar is not updated for the same object", () => {
+      const packet = stubPackets.get("inspect({a: 1})");
+      const message = stubPreparedMessages.get("inspect({a: 1})");
+      store.dispatch(actions.messageAdd(packet));
+
+      const messages = getAllMessagesById(store.getState());
+      const actorId = message.parameters[0].actor;
+      const messageId = messages.first().id;
+      store.dispatch(actions.showObjectInSidebar(actorId, messageId));
+
+      expect(store.getState().ui.sidebarVisible).toEqual(true);
+      expect(store.getState().ui.gripInSidebar).toEqual(message.parameters[0]);
+      let state = store.getState().ui;
+
+      store.dispatch(actions.showObjectInSidebar(actorId, messageId));
+      expect(store.getState().ui).toEqual(state);
+    });
+
+    it("sidebar shown and updated for new object", () => {
+      const packet = stubPackets.get("inspect({a: 1})");
+      const message = stubPreparedMessages.get("inspect({a: 1})");
+      store.dispatch(actions.messageAdd(packet));
+
+      const messages = getAllMessagesById(store.getState());
+      const actorId = message.parameters[0].actor;
+      const messageId = messages.first().id;
+      store.dispatch(actions.showObjectInSidebar(actorId, messageId));
+
+      expect(store.getState().ui.sidebarVisible).toEqual(true);
+      expect(store.getState().ui.gripInSidebar).toEqual(message.parameters[0]);
+
+      const newPacket = stubPackets.get("new Date(0)");
+      const newMessage = stubPreparedMessages.get("new Date(0)");
+      store.dispatch(actions.messageAdd(newPacket));
+
+      const newMessages = getAllMessagesById(store.getState());
+      const newActorId = newMessage.parameters[0].actor;
+      const newMessageId = newMessages.last().id;
+      store.dispatch(actions.showObjectInSidebar(newActorId, newMessageId));
+
+      expect(store.getState().ui.sidebarVisible).toEqual(true);
+      expect(store.getState().ui.gripInSidebar).toEqual(newMessage.parameters[0]);
+    });
+  });
 });
--- a/devtools/client/webconsole/new-console-output/utils/context-menu.js
+++ b/devtools/client/webconsole/new-console-output/utils/context-menu.js
@@ -27,23 +27,28 @@ const { l10n } = require("devtools/clien
  * @param {Object} options
  *        - {String} actor (optional) actor id to use for context menu actions
  *        - {String} clipboardText (optional) text to "Copy" if no selection is available
  *        - {String} variableText (optional) which is the textual frontend
  *            representation of the variable
  *        - {Object} message (optional) message object containing metadata such as:
  *          - {String} source
  *          - {String} request
+ *        - {Function} openSidebar (optional) function that will open the object
+ *            inspector sidebar
+ *        - {String} rootActorId (optional) actor id for the root object being clicked on
  */
 function createContextMenu(jsterm, parentNode, {
   actor,
   clipboardText,
   variableText,
   message,
-  serviceContainer
+  serviceContainer,
+  openSidebar,
+  rootActorId,
 }) {
   let win = parentNode.ownerDocument.defaultView;
   let selection = win.getSelection();
 
   let { source, request } = message || {};
 
   let menu = new Menu({
     id: "webconsole-menu"
@@ -160,12 +165,23 @@ function createContextMenu(jsterm, paren
     accesskey: l10n.getStr("webconsole.menu.selectAll.accesskey"),
     disabled: false,
     click: () => {
       let webconsoleOutput = parentNode.querySelector(".webconsole-output");
       selection.selectAllChildren(webconsoleOutput);
     },
   }));
 
+  // Open object in sidebar.
+  if (openSidebar) {
+    menu.append(new MenuItem({
+      id: "console-menu-open-sidebar",
+      label: l10n.getStr("webconsole.menu.openInSidebar.label"),
+      acesskey: l10n.getStr("webconsole.menu.openInSidebar.accesskey"),
+      disabled: !rootActorId,
+      click: () => openSidebar(message.messageId),
+    }));
+  }
+
   return menu;
 }
 
 exports.createContextMenu = createContextMenu;