Bug 1419083 - Add an "Open in sidebar" context menu entry on ObjectInspector. r=nchevobbe
authorMike Park <mikeparkms@gmail.com>
Tue, 05 Dec 2017 16:05:13 -0500
changeset 448206 971f3f353ca89137ade1a54152f4a0eb5cad5d20
parent 448205 375327ad5708d1170d9c2a34794e04394189a4aa
child 448207 c6b71032e0831ddd09b67391e62024bc729a1d0d
child 448228 900a295d94a71150f7eb4b37bf31c4bad16970e3
push id8527
push userCallek@gmail.com
push dateThu, 11 Jan 2018 21:05:50 +0000
treeherdermozilla-beta@95342d212a7a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe
bugs1419083
milestone59.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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;