Bug 1024913 - Enable reverse search in jsterm; r=bgrins,flod.
authorNicolas Chevobbe <nchevobbe@mozilla.com>
Mon, 10 Sep 2018 10:24:40 +0000
changeset 435365 3025bb790d390f626897f8f3878d761254d60ec7
parent 435364 28becf26d84b6e6f767d71b3252d7643a75a4ef7
child 435366 0661deed0ac493e0df241b81560ea9bfa822e3a3
push id34611
push userdvarga@mozilla.com
push dateMon, 10 Sep 2018 16:13:23 +0000
treeherdermozilla-central@402f26a30b2d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins, flod
bugs1024913
milestone64.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 1024913 - Enable reverse search in jsterm; r=bgrins,flod. Differential Revision: https://phabricator.services.mozilla.com/D3114
devtools/client/locales/en-US/webconsole.properties
devtools/client/themes/webconsole.css
devtools/client/webconsole/actions/history.js
devtools/client/webconsole/actions/ui.js
devtools/client/webconsole/components/App.js
devtools/client/webconsole/components/JSTerm.js
devtools/client/webconsole/components/ReverseSearchInput.css
devtools/client/webconsole/components/ReverseSearchInput.js
devtools/client/webconsole/components/moz.build
devtools/client/webconsole/constants.js
devtools/client/webconsole/index.html
devtools/client/webconsole/reducers/history.js
devtools/client/webconsole/reducers/ui.js
devtools/client/webconsole/selectors/history.js
testing/talos/talos/tests/devtools/addon/content/tests/webconsole/autocomplete.js
--- a/devtools/client/locales/en-US/webconsole.properties
+++ b/devtools/client/locales/en-US/webconsole.properties
@@ -280,8 +280,40 @@ webconsole.navigated=Navigated to %S
 
 # LOCALIZATION NOTE (webconsole.closeSplitConsoleButton.tooltip): This is the tooltip for
 # the close button of the split console.
 webconsole.closeSplitConsoleButton.tooltip=Close Split Console (Esc)
 
 # LOCALIZATION NOTE (webconsole.closeSidebarButton.tooltip): This is the tooltip for
 # the close button of the sidebar.
 webconsole.closeSidebarButton.tooltip=Close Sidebar
+
+# LOCALIZATION NOTE (webconsole.reverseSearch.input.placeHolder):
+# This string is displayed in the placeholder of the reverse search input in the console.
+webconsole.reverseSearch.input.placeHolder=Search history
+
+# LOCALIZATION NOTE (webconsole.reverseSearch.result.closeButton.tooltip):
+# This string is displayed in the tooltip of the close button in the reverse search toolbar.
+# A keyboard shortcut will be shown inside the latter pair of brackets.
+webconsole.reverseSearch.closeButton.tooltip=Close (%S)
+
+# LOCALIZATION NOTE (webconsole.reverseSearch.results):
+# This string is displayed in the reverse search UI when there are at least one result
+# to the search.
+# This is a semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 index of current search result displayed.
+# #2 total number of search results.
+webconsole.reverseSearch.results=1 result;#1 of #2 results
+
+# LOCALIZATION NOTE (webconsole.reverseSearch.noResult):
+# This string is displayed in the reverse search UI when there is no results to the search.
+webconsole.reverseSearch.noResult=No results
+
+# LOCALIZATION NOTE (webconsole.reverseSearch.result.previousButton.tooltip):
+# This string is displayed in the tooltip of the "previous result" button in the reverse search toolbar.
+# A keyboard shortcut will be shown inside the latter pair of brackets.
+webconsole.reverseSearch.result.previousButton.tooltip=Previous result (%S)
+
+# LOCALIZATION NOTE (webconsole.reverseSearch.result.nextButton.tooltip):
+# This string is displayed in the tooltip of the "next result" button in the reverse search toolbar.
+# A keyboard shortcut will be shown inside the latter pair of brackets.
+webconsole.reverseSearch.result.nextButton.tooltip=Next result (%S)
--- a/devtools/client/themes/webconsole.css
+++ b/devtools/client/themes/webconsole.css
@@ -877,31 +877,27 @@ body {
 .webconsole-output-wrapper #webconsole-notificationbox {
   flex-shrink: 0;
 }
 
 .webconsole-output-wrapper .jsterm-input-container {
   min-height: 28px;
   overflow-y: auto;
   overflow-x: hidden;
+  flex-grow: 1;
 }
 
 .jsterm-cm .jsterm-input-container {
   padding-block-start: 2px;
 }
 
 .webconsole-flex-wrapper > .webconsole-output:empty ~ .jsterm-input-container {
   border-top: none;
 }
 
-/* Last item in the flex wrapper should take the whole remaining height */
-.webconsole-flex-wrapper > :last-child {
-  flex-grow: 1;
-}
-
 /* Object Inspector */
 .webconsole-output-wrapper .object-inspector.tree {
   display: inline-block;
 }
 
 .webconsole-output-wrapper .object-inspector.tree .tree-indent {
   border-inline-start-color: var(--console-output-indent-border-color);
 }
--- a/devtools/client/webconsole/actions/history.js
+++ b/devtools/client/webconsole/actions/history.js
@@ -6,16 +6,19 @@
 
 "use strict";
 
 const {
   APPEND_TO_HISTORY,
   CLEAR_HISTORY,
   HISTORY_LOADED,
   UPDATE_HISTORY_POSITION,
+  REVERSE_SEARCH_INPUT_CHANGE,
+  REVERSE_SEARCH_BACK,
+  REVERSE_SEARCH_NEXT,
 } = require("devtools/client/webconsole/constants");
 
 /**
  * Append a new value in the history of executed expressions,
  * or overwrite the most recent entry. The most recent entry may
  * contain the last edited input value that was not evaluated yet.
  */
 function appendToHistory(expression) {
@@ -52,14 +55,36 @@ function historyLoaded(entries) {
 function updateHistoryPosition(direction, expression) {
   return {
     type: UPDATE_HISTORY_POSITION,
     direction,
     expression,
   };
 }
 
+function reverseSearchInputChange(value) {
+  return {
+    type: REVERSE_SEARCH_INPUT_CHANGE,
+    value,
+  };
+}
+
+function showReverseSearchNext() {
+  return {
+    type: REVERSE_SEARCH_NEXT,
+  };
+}
+
+function showReverseSearchBack() {
+  return {
+    type: REVERSE_SEARCH_BACK
+  };
+}
+
 module.exports = {
   appendToHistory,
   clearHistory,
   historyLoaded,
   updateHistoryPosition,
+  reverseSearchInputChange,
+  showReverseSearchNext,
+  showReverseSearchBack,
 };
--- a/devtools/client/webconsole/actions/ui.js
+++ b/devtools/client/webconsole/actions/ui.js
@@ -9,34 +9,35 @@
 const { getAllUi } = require("devtools/client/webconsole/selectors/ui");
 const { getMessage } = require("devtools/client/webconsole/selectors/messages");
 
 const {
   FILTER_BAR_TOGGLE,
   INITIALIZE,
   PERSIST_TOGGLE,
   PREFS,
+  REVERSE_SEARCH_INPUT_TOGGLE,
   SELECT_NETWORK_MESSAGE_TAB,
-  SIDEBAR_CLOSE,
   SHOW_OBJECT_IN_SIDEBAR,
+  SIDEBAR_CLOSE,
+  SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE,
   TIMESTAMPS_TOGGLE,
-  SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE,
 } = require("devtools/client/webconsole/constants");
 
-function filterBarToggle(show) {
+function filterBarToggle() {
   return (dispatch, getState, {prefsService}) => {
     dispatch({
       type: FILTER_BAR_TOGGLE,
     });
     const {filterBarVisible} = getAllUi(getState());
     prefsService.setBoolPref(PREFS.UI.FILTER_BAR, filterBarVisible);
   };
 }
 
-function persistToggle(show) {
+function persistToggle() {
   return (dispatch, getState, {prefsService}) => {
     dispatch({
       type: PERSIST_TOGGLE,
     });
     const uiState = getAllUi(getState());
     prefsService.setBoolPref(PREFS.UI.PERSIST, uiState.persistLogs);
   };
 }
@@ -97,19 +98,26 @@ function showMessageObjectInSidebar(acto
 
 function showObjectInSidebar(grip) {
   return {
     type: SHOW_OBJECT_IN_SIDEBAR,
     grip,
   };
 }
 
+function reverseSearchInputToggle() {
+  return {
+    type: REVERSE_SEARCH_INPUT_TOGGLE
+  };
+}
+
 module.exports = {
   filterBarToggle,
   initialize,
   persistToggle,
+  reverseSearchInputToggle,
   selectNetworkMessageTab,
-  sidebarClose,
   showMessageObjectInSidebar,
   showObjectInSidebar,
-  timestampsToggle,
+  sidebarClose,
   splitConsoleCloseButtonToggle,
+  timestampsToggle,
 };
--- a/devtools/client/webconsole/components/App.js
+++ b/devtools/client/webconsole/components/App.js
@@ -1,84 +1,122 @@
 /* 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 Services = require("Services");
 const { Component, createFactory } = require("devtools/client/shared/vendor/react");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
 const { connect } = require("devtools/client/shared/redux/visibility-handler-connect");
 
 const actions = require("devtools/client/webconsole/actions/index");
 const ConsoleOutput = createFactory(require("devtools/client/webconsole/components/ConsoleOutput"));
 const FilterBar = createFactory(require("devtools/client/webconsole/components/FilterBar"));
 const SideBar = createFactory(require("devtools/client/webconsole/components/SideBar"));
+const ReverseSearchInput = createFactory(require("devtools/client/webconsole/components/ReverseSearchInput"));
 const JSTerm = createFactory(require("devtools/client/webconsole/components/JSTerm"));
 const NotificationBox = createFactory(require("devtools/client/shared/components/NotificationBox").NotificationBox);
 
 const l10n = require("devtools/client/webconsole/webconsole-l10n");
 const { Utils: WebConsoleUtils } = require("devtools/client/webconsole/utils");
 
 const SELF_XSS_OK = l10n.getStr("selfxss.okstring");
 const SELF_XSS_MSG = l10n.getFormatStr("selfxss.msg", [SELF_XSS_OK]);
 
 const {
   getNotificationWithValue,
   PriorityLevels,
 } = require("devtools/client/shared/components/NotificationBox");
 
 const { getAllNotifications } = require("devtools/client/webconsole/selectors/notifications");
-
 const { div } = dom;
+const isMacOS = Services.appinfo.OS === "Darwin";
 
 /**
  * Console root Application component.
  */
 class App extends Component {
   static get propTypes() {
     return {
       attachRefToHud: PropTypes.func.isRequired,
       dispatch: PropTypes.func.isRequired,
       hud: PropTypes.object.isRequired,
       notifications: PropTypes.object,
       onFirstMeaningfulPaint: PropTypes.func.isRequired,
       serviceContainer: PropTypes.object.isRequired,
       closeSplitConsole: PropTypes.func.isRequired,
       jstermCodeMirror: PropTypes.bool,
+      jstermReverseSearch: PropTypes.bool,
+      currentReverseSearchEntry: PropTypes.string,
+      reverseSearchInputVisible: PropTypes.bool,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.onClick = this.onClick.bind(this);
     this.onPaste = this.onPaste.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+  }
+
+  onKeyDown(event) {
+    const {
+      dispatch,
+      jstermReverseSearch,
+    } = this.props;
+
+    if (
+      jstermReverseSearch && (
+        (!isMacOS && event.key === "F9") ||
+        (isMacOS && event.key === "r" && event.ctrlKey === true)
+      )
+    ) {
+      dispatch(actions.reverseSearchInputToggle());
+      event.stopPropagation();
+    }
   }
 
   onClick(event) {
     const target = event.originalTarget || event.target;
     const {
+      reverseSearchInputVisible,
+      dispatch,
       hud,
     } = this.props;
 
+    if (reverseSearchInputVisible === true && !target.closest(".reverse-search")) {
+      event.preventDefault();
+      event.stopPropagation();
+      dispatch(actions.reverseSearchInputToggle());
+      return;
+    }
+
     // Do not focus on middle/right-click or 2+ clicks.
     if (event.detail !== 1 || event.button !== 0) {
       return;
     }
 
     // Do not focus if a link was clicked
     if (target.closest("a")) {
       return;
     }
 
     // Do not focus if an input field was clicked
     if (target.closest("input")) {
       return;
     }
+
+    // Do not focus if the click happened in the reverse search toolbar.
+    if (target.closest(".reverse-search")) {
+      return;
+    }
+
     // Do not focus if something other than the output region was clicked
     // (including e.g. the clear messages button in toolbar)
     if (!target.closest(".webconsole-output-wrapper")) {
       return;
     }
 
     // Do not focus if something is selected
     const selection = hud.document.defaultView.getSelection();
@@ -156,33 +194,36 @@ class App extends Component {
     const {
       attachRefToHud,
       hud,
       notifications,
       onFirstMeaningfulPaint,
       serviceContainer,
       closeSplitConsole,
       jstermCodeMirror,
+      jstermReverseSearch,
     } = this.props;
 
     const classNames = ["webconsole-output-wrapper"];
     if (jstermCodeMirror) {
       classNames.push("jsterm-cm");
     }
 
     // Render the entire Console panel. The panel consists
     // from the following parts:
     // * FilterBar - Buttons & free text for content filtering
     // * Content - List of logs & messages
-    // * SideBar - Object inspector
     // * NotificationBox - Notifications for JSTerm (self-xss warning at the moment)
     // * JSTerm - Input command line.
+    // * ReverseSearchInput - Reverse search input.
+    // * SideBar - Object inspector
     return (
       div({
         className: classNames.join(" "),
+        onKeyDown: this.onKeyDown,
         onClick: this.onClick,
         ref: node => {
           this.node = node;
         }},
         div({className: "webconsole-flex-wrapper"},
           FilterBar({
             hidePersistLogsCheckbox: hud.isBrowserConsole,
             serviceContainer: {
@@ -199,26 +240,32 @@ class App extends Component {
             notifications,
           }),
           JSTerm({
             hud,
             serviceContainer,
             onPaste: this.onPaste,
             codeMirrorEnabled: jstermCodeMirror,
           }),
+          jstermReverseSearch
+            ? ReverseSearchInput({
+              hud,
+            })
+            : null
         ),
         SideBar({
           serviceContainer,
         }),
       )
     );
   }
 }
 
 const mapStateToProps = state => ({
   notifications: getAllNotifications(state),
+  reverseSearchInputVisible: state.ui.reverseSearchInputVisible,
 });
 
 const mapDispatchToProps = dispatch => ({
   dispatch,
 });
 
 module.exports = connect(mapStateToProps, mapDispatchToProps)(App);
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -677,17 +677,20 @@ class JSTerm extends Component {
    * Sets the value of the input field (command line), and resizes the field to
    * fit its contents. This method is preferred over setting "inputNode.value"
    * directly, because it correctly resizes the field.
    *
    * @param string newValue
    *        The new value to set.
    * @returns void
    */
-  setInputValue(newValue = "") {
+  setInputValue(newValue) {
+    newValue = newValue || "";
+    this.lastInputValue = newValue;
+
     if (this.props.codeMirrorEnabled) {
       if (this.editor) {
         // In order to get the autocomplete popup to work properly, we need to set the
         // editor text and the cursor in the same operation. If we don't, the text change
         // is done before the cursor is moved, and the autocompletion call to the server
         // sends an erroneous query.
         this.editor.codeMirror.operation(() => {
           this.editor.setText(newValue);
@@ -705,19 +708,17 @@ class JSTerm extends Component {
       if (!this.inputNode) {
         return;
       }
 
       this.inputNode.value = newValue;
       this.completeNode.value = "";
     }
 
-    this.lastInputValue = newValue;
     this.resizeInput();
-
     this.emit("set-input-value");
   }
 
   /**
    * Gets the value from the input field
    * @returns string
    */
   getInputValue() {
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/components/ReverseSearchInput.css
@@ -0,0 +1,84 @@
+/* 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/. */
+
+.reverse-search {
+  display: flex;
+  font-size: inherit;
+  min-height: 26px;
+  color: var(--theme-body-color);
+  padding-block-start: 2px;
+  align-items: baseline;
+  border: 1px solid transparent;
+  transition: border-color 0.2s ease-in-out;
+}
+
+.reverse-search:focus-within {
+  border-color: var(--blue-50);
+}
+
+.reverse-search {
+  flex-shrink: 0;
+}
+
+.reverse-search input {
+  border: none;
+  flex-grow: 1;
+  padding-inline-start: var(--console-inline-start-gutter);
+  background: transparent;
+  color: currentColor;
+  background-image: var(--magnifying-glass-image);
+  background-repeat: no-repeat;
+  background-size: 12px 12px;
+  background-position: 10px 2px;
+  -moz-context-properties: fill;
+}
+
+.reverse-search input:focus {
+  border: none;
+  outline: none;
+}
+
+.reverse-search:not(.no-result) input:focus {
+  fill: var(--console-input-icon-focused);
+}
+
+.reverse-search-info {
+  flex-shrink: 0;
+  padding: 0 8px;
+  color: var(--comment-node-color);
+}
+
+.search-result-button-prev,
+.search-result-button-next,
+.reverse-search-close-button {
+  padding: 4px 0;
+  margin: 0;
+  border-radius: 0;
+}
+
+.search-result-button-prev::before {
+  background-image: url("chrome://devtools/skin/images/arrowhead-up.svg");
+  background-size: 16px;
+  fill: var(--comment-node-color);
+}
+
+.search-result-button-next::before {
+  background-image: url("chrome://devtools/skin/images/arrowhead-down.svg");
+  background-size: 16px;
+  fill: var(--comment-node-color);
+}
+
+.reverse-search-close-button::before {
+  fill: var(--comment-node-color);
+  background-image: var(--close-button-image);
+}
+
+.reverse-search.no-result input {
+  fill: var(--error-color);
+}
+
+.reverse-search.no-result,
+.reverse-search.no-result input {
+  color: var(--error-color);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/components/ReverseSearchInput.js
@@ -0,0 +1,225 @@
+/* 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";
+
+// React & Redux
+const { Component } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+const { l10n } = require("devtools/client/webconsole/utils/messages");
+const { PluralForm } = require("devtools/shared/plural-form");
+const { KeyCodes } = require("devtools/client/shared/keycodes");
+
+const actions = require("devtools/client/webconsole/actions/index");
+const {
+  getReverseSearchTotalResults,
+  getReverseSearchResultPosition,
+  getReverseSearchResult,
+} = require("devtools/client/webconsole/selectors/history");
+
+const Services = require("Services");
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+class ReverseSearchInput extends Component {
+  static get propTypes() {
+    return {
+      dispatch: PropTypes.func.isRequired,
+      hud: PropTypes.object.isRequired,
+      reverseSearchResult: PropTypes.string,
+      reverseSearchTotalResults: PropTypes.Array,
+      reverseSearchResultPosition: PropTypes.int,
+      visible: PropTypes.bool,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.onInputKeyDown = this.onInputKeyDown.bind(this);
+  }
+
+  componentDidUpdate(prevProps) {
+    const {jsterm} = this.props.hud;
+    if (
+      prevProps.reverseSearchResult !== this.props.reverseSearchResult
+      && this.props.visible
+      && this.props.reverseSearchTotalResults > 0
+    ) {
+      jsterm.setInputValue(this.props.reverseSearchResult);
+    }
+
+    if (prevProps.visible === true && this.props.visible === false) {
+      jsterm.focus();
+    }
+  }
+
+  onInputKeyDown(event) {
+    const {
+      keyCode,
+      key,
+      ctrlKey,
+      shiftKey,
+    } = event;
+
+    const {
+      dispatch,
+      hud,
+      reverseSearchTotalResults,
+    } = this.props;
+
+    // On Enter, we trigger an execute.
+    if (keyCode === KeyCodes.DOM_VK_RETURN) {
+      event.stopPropagation();
+      dispatch(actions.reverseSearchInputToggle());
+      hud.jsterm.execute();
+      return;
+    }
+
+    // On Escape (and Ctrl + c on OSX), we close the reverse search input.
+    if (
+      keyCode === KeyCodes.DOM_VK_ESCAPE || (
+        isMacOS && ctrlKey === true && key.toLowerCase() === "c"
+      )
+    ) {
+      event.stopPropagation();
+      dispatch(actions.reverseSearchInputToggle());
+      return;
+    }
+
+    const canNavigate = Number.isInteger(reverseSearchTotalResults)
+      && reverseSearchTotalResults > 1;
+
+    if (
+      (!isMacOS && key === "F9" && shiftKey === false) ||
+      (isMacOS && ctrlKey === true && key.toLowerCase() === "r")
+    ) {
+      event.stopPropagation();
+      event.preventDefault();
+      if (canNavigate) {
+        dispatch(actions.showReverseSearchBack());
+      }
+      return;
+    }
+
+    if (
+      (!isMacOS && key === "F9" && shiftKey === true) ||
+      (isMacOS && ctrlKey === true && key.toLowerCase() === "s")
+    ) {
+      event.stopPropagation();
+      event.preventDefault();
+      if (canNavigate) {
+        dispatch(actions.showReverseSearchNext());
+      }
+    }
+  }
+
+  renderSearchInformation() {
+    const {
+      reverseSearchTotalResults,
+      reverseSearchResultPosition,
+    } = this.props;
+
+    if (!Number.isInteger(reverseSearchTotalResults)) {
+      return null;
+    }
+
+    let text;
+    if (reverseSearchTotalResults === 0) {
+      text = l10n.getStr("webconsole.reverseSearch.noResult");
+    } else {
+      const resultsString = l10n.getStr("webconsole.reverseSearch.results");
+      text = PluralForm.get(reverseSearchTotalResults, resultsString)
+        .replace("#1", reverseSearchResultPosition)
+        .replace("#2", reverseSearchTotalResults);
+    }
+
+    return dom.div({className: "reverse-search-info"}, text);
+  }
+
+  renderNavigationButtons() {
+    const {
+      dispatch,
+      reverseSearchTotalResults,
+    } = this.props;
+
+    if (!Number.isInteger(reverseSearchTotalResults) || reverseSearchTotalResults <= 1) {
+      return null;
+    }
+
+    return [
+      dom.button({
+        className: "devtools-button search-result-button-prev",
+        title: l10n.getFormatStr("webconsole.reverseSearch.result.previousButton.tooltip",
+          [isMacOS ? "Ctrl + R" : "F9"]),
+        onClick: () => {
+          dispatch(actions.showReverseSearchBack());
+          this.inputNode.focus();
+        }
+      }),
+      dom.button({
+        className: "devtools-button search-result-button-next",
+        title: l10n.getFormatStr("webconsole.reverseSearch.result.nextButton.tooltip",
+          [isMacOS ? "Ctrl + S" : "Shift + F9"]),
+        onClick: () => {
+          dispatch(actions.showReverseSearchNext());
+          this.inputNode.focus();
+        }
+      })
+    ];
+  }
+
+  render() {
+    const {
+      dispatch,
+      visible,
+      reverseSearchTotalResults,
+    } = this.props;
+
+    if (!visible) {
+      return null;
+    }
+
+    const classNames = ["reverse-search"];
+    if (reverseSearchTotalResults === 0) {
+      classNames.push("no-result");
+    }
+
+    return dom.div({className: classNames.join(" ")},
+      dom.input({
+        ref: node => {
+          this.inputNode = node;
+        },
+        autoFocus: true,
+        placeHolder: l10n.getStr("webconsole.reverseSearch.input.placeHolder"),
+        className: "reverse-search-input devtools-monospace",
+        onKeyDown: this.onInputKeyDown,
+        onInput: ({target}) => dispatch(actions.reverseSearchInputChange(target.value))
+      }),
+      this.renderSearchInformation(),
+      this.renderNavigationButtons(),
+      dom.button({
+        className: "devtools-button reverse-search-close-button",
+        title: l10n.getFormatStr("webconsole.reverseSearch.closeButton.tooltip",
+          ["Esc" + (isMacOS ? " | Ctrl + C" : "")]),
+        onClick: () => {
+          dispatch(actions.reverseSearchInputToggle());
+        }
+      })
+    );
+  }
+}
+
+const mapStateToProps = state => ({
+  visible: state.ui.reverseSearchInputVisible,
+  reverseSearchTotalResults: getReverseSearchTotalResults(state),
+  reverseSearchResultPosition: getReverseSearchResultPosition(state),
+  reverseSearchResult: getReverseSearchResult(state),
+});
+
+const mapDispatchToProps = dispatch => ({dispatch});
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(ReverseSearchInput);
--- a/devtools/client/webconsole/components/moz.build
+++ b/devtools/client/webconsole/components/moz.build
@@ -17,10 +17,12 @@ DevToolsModules(
     'FilterCheckbox.js',
     'GripMessageBody.js',
     'JSTerm.js',
     'Message.js',
     'MessageContainer.js',
     'MessageIcon.js',
     'MessageIndent.js',
     'MessageRepeat.js',
+    'ReverseSearchInput.css',
+    'ReverseSearchInput.js',
     'SideBar.js'
 )
--- a/devtools/client/webconsole/constants.js
+++ b/devtools/client/webconsole/constants.js
@@ -1,44 +1,48 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* 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 actionTypes = {
+  APPEND_NOTIFICATION: "APPEND_NOTIFICATION",
+  APPEND_TO_HISTORY: "APPEND_TO_HISTORY",
   BATCH_ACTIONS: "BATCH_ACTIONS",
+  CLEAR_HISTORY: "CLEAR_HISTORY",
   DEFAULT_FILTERS_RESET: "DEFAULT_FILTERS_RESET",
   FILTER_BAR_TOGGLE: "FILTER_BAR_TOGGLE",
   FILTER_TEXT_SET: "FILTER_TEXT_SET",
   FILTER_TOGGLE: "FILTER_TOGGLE",
   FILTERS_CLEAR: "FILTERS_CLEAR",
+  HISTORY_LOADED: "HISTORY_LOADED",
   INITIALIZE: "INITIALIZE",
   MESSAGE_CLOSE: "MESSAGE_CLOSE",
   MESSAGE_OPEN: "MESSAGE_OPEN",
   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",
   PRIVATE_MESSAGES_CLEAR: "PRIVATE_MESSAGES_CLEAR",
+  REMOVE_NOTIFICATION: "REMOVE_NOTIFICATION",
   REMOVED_ACTORS_CLEAR: "REMOVED_ACTORS_CLEAR",
+  REVERSE_SEARCH_INPUT_TOGGLE: "REVERSE_SEARCH_INPUT_TOGGLE",
   SELECT_NETWORK_MESSAGE_TAB: "SELECT_NETWORK_MESSAGE_TAB",
+  SHOW_OBJECT_IN_SIDEBAR: "SHOW_OBJECT_IN_SIDEBAR",
   SIDEBAR_CLOSE: "SIDEBAR_CLOSE",
-  SHOW_OBJECT_IN_SIDEBAR: "SHOW_OBJECT_IN_SIDEBAR",
+  SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE: "SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE",
   TIMESTAMPS_TOGGLE: "TIMESTAMPS_TOGGLE",
-  APPEND_NOTIFICATION: "APPEND_NOTIFICATION",
-  REMOVE_NOTIFICATION: "REMOVE_NOTIFICATION",
-  SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE: "SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE",
-  APPEND_TO_HISTORY: "APPEND_TO_HISTORY",
-  CLEAR_HISTORY: "CLEAR_HISTORY",
-  HISTORY_LOADED: "HISTORY_LOADED",
   UPDATE_HISTORY_POSITION: "UPDATE_HISTORY_POSITION",
+  REVERSE_SEARCH_INPUT_CHANGE: "REVERSE_SEARCH_INPUT_CHANGE",
+  REVERSE_SEARCH_NEXT: "REVERSE_SEARCH_NEXT",
+  REVERSE_SEARCH_BACK: "REVERSE_SEARCH_BACK",
 };
 
 const prefs = {
   PREFS: {
     // Filter preferences only have the suffix since they can be used either for the
     // webconsole or the browser console.
     FILTER: {
       ERROR: "filter.error",
@@ -58,16 +62,17 @@ const prefs = {
       PERSIST: "devtools.webconsole.persistlog",
       // Max number of entries in history list.
       INPUT_HISTORY_COUNT: "devtools.webconsole.inputHistoryCount",
     },
     FEATURES: {
       // We use the same pref to enable the sidebar on webconsole and browser console.
       SIDEBAR_TOGGLE: "devtools.webconsole.sidebarToggle",
       JSTERM_CODE_MIRROR: "devtools.webconsole.jsterm.codeMirror",
+      JSTERM_REVERSE_SEARCH: "devtools.webconsole.jsterm.reverse-search",
     }
   }
 };
 
 const FILTERS = {
   CSS: "css",
   DEBUG: "debug",
   ERROR: "error",
--- a/devtools/client/webconsole/index.html
+++ b/devtools/client/webconsole/index.html
@@ -14,16 +14,17 @@
     <link rel="stylesheet" href="chrome://devtools/skin/widgets.css"/>
     <link rel="stylesheet" href="chrome://devtools/skin/webconsole.css"/>
     <link rel="stylesheet" href="chrome://devtools/skin/components-frame.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/reps/reps.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/tabs/Tabs.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/tabs/TabBar.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/NotificationBox.css"/>
     <link rel="stylesheet" href="chrome://devtools/content/netmonitor/src/assets/styles/httpi.css"/>
+    <link rel="stylesheet" href="resource://devtools/client/webconsole/components/ReverseSearchInput.css"/>
 
     <script src="chrome://devtools/content/shared/theme-switching.js"></script>
     <script type="application/javascript"
             src="resource://devtools/client/webconsole/main.js"></script>
   </head>
   <body class="theme-sidebar" role="application">
     <main id="app-wrapper" class="theme-body" role="document" aria-live="polite">
     </main>
--- a/devtools/client/webconsole/reducers/history.js
+++ b/devtools/client/webconsole/reducers/history.js
@@ -7,16 +7,20 @@
 
 const {
   APPEND_TO_HISTORY,
   CLEAR_HISTORY,
   HISTORY_LOADED,
   UPDATE_HISTORY_POSITION,
   HISTORY_BACK,
   HISTORY_FORWARD,
+  REVERSE_SEARCH_INPUT_TOGGLE,
+  REVERSE_SEARCH_INPUT_CHANGE,
+  REVERSE_SEARCH_BACK,
+  REVERSE_SEARCH_NEXT,
 } = require("devtools/client/webconsole/constants");
 
 /**
  * Create default initial state for this reducer.
  */
 function getInitialState() {
   return {
     // Array with history entries
@@ -27,29 +31,41 @@ function getInitialState() {
     // APPEND_TO_HISTORY action is fired.
     position: undefined,
 
     // Backups the original user value (if any) that can be set in
     // the input field. It might be used again if the user doesn't
     // pick up anything from the history and wants to return all
     // the way back to see the original input text.
     originalUserValue: null,
+
+    reverseSearchEnabled: false,
+    currentReverseSearchResults: null,
+    currentReverseSearchResultsPosition: null,
   };
 }
 
 function history(state = getInitialState(), action, prefsState) {
   switch (action.type) {
     case APPEND_TO_HISTORY:
       return appendToHistory(state, prefsState, action.expression);
     case CLEAR_HISTORY:
       return clearHistory(state);
     case HISTORY_LOADED:
       return historyLoaded(state, action.entries);
     case UPDATE_HISTORY_POSITION:
       return updateHistoryPosition(state, action.direction, action.expression);
+    case REVERSE_SEARCH_INPUT_TOGGLE:
+      return reverseSearchInputToggle(state);
+    case REVERSE_SEARCH_INPUT_CHANGE:
+      return reverseSearchInputChange(state, action.value);
+    case REVERSE_SEARCH_BACK:
+      return reverseSearchBack(state);
+    case REVERSE_SEARCH_NEXT:
+      return reverseSearchNext(state);
   }
   return state;
 }
 
 function appendToHistory(state, prefsState, expression) {
   // Clone state
   state = {...state};
   state.entries = [...state.entries];
@@ -122,9 +138,71 @@ function updateHistoryPosition(state, di
       ...state,
       position: state.position + 1,
     };
   }
 
   return state;
 }
 
+function reverseSearchInputToggle(state) {
+  return {
+    ...state,
+    reverseSearchEnabled: !state.reverseSearchEnabled,
+    position: state.reverseSearchEnabled === true ? state.entries.length : undefined,
+    currentReverseSearchResults: null,
+    currentReverseSearchResultsPosition: null,
+  };
+}
+
+function reverseSearchInputChange(state, searchString) {
+  if (searchString === "") {
+    return {
+      ...state,
+      position: undefined,
+      currentReverseSearchResults: null,
+      currentReverseSearchResultsPosition: null,
+    };
+  }
+
+  searchString = searchString.toLocaleLowerCase();
+  const matchingEntries = state.entries.filter(entry =>
+    entry.toLocaleLowerCase().includes(searchString));
+  // We only return unique entries, but we want to keep the latest entry in the array if
+  // it's duplicated (e.g. if we have [1,2,1], we want to get [2,1], not [1,2]).
+  // To do that, we need to reverse the matching entries array, provide it to a Set,
+  // transform it back to an array and reverse it again.
+  const uniqueEntries = new Set(matchingEntries.reverse());
+  const currentReverseSearchResults = Array.from(new Set(uniqueEntries)).reverse();
+
+  return {
+    ...state,
+    position: undefined,
+    currentReverseSearchResults,
+    currentReverseSearchResultsPosition: currentReverseSearchResults.length - 1,
+  };
+}
+
+function reverseSearchBack(state) {
+  let nextPosition = state.currentReverseSearchResultsPosition - 1;
+  if (nextPosition < 0) {
+    nextPosition = state.currentReverseSearchResults.length - 1;
+  }
+
+  return {
+    ...state,
+    currentReverseSearchResultsPosition: nextPosition
+  };
+}
+
+function reverseSearchNext(state) {
+  let previousPosition = state.currentReverseSearchResultsPosition + 1;
+  if (previousPosition >= state.currentReverseSearchResults.length) {
+    previousPosition = 0;
+  }
+
+  return {
+    ...state,
+    currentReverseSearchResultsPosition: previousPosition
+  };
+}
+
 exports.history = history;
--- a/devtools/client/webconsole/reducers/ui.js
+++ b/devtools/client/webconsole/reducers/ui.js
@@ -3,38 +3,40 @@
 /* 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 {
   FILTER_BAR_TOGGLE,
   INITIALIZE,
+  MESSAGES_CLEAR,
   PERSIST_TOGGLE,
+  REVERSE_SEARCH_INPUT_TOGGLE,
   SELECT_NETWORK_MESSAGE_TAB,
-  SIDEBAR_CLOSE,
   SHOW_OBJECT_IN_SIDEBAR,
+  SIDEBAR_CLOSE,
+  SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE,
   TIMESTAMPS_TOGGLE,
-  MESSAGES_CLEAR,
-  SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE,
 } = require("devtools/client/webconsole/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,
   closeButtonVisible: false,
+  reverseSearchInputVisible: false,
 }, 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});
@@ -53,16 +55,18 @@ function ui(state = UiState(), action) {
       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});
     case SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE:
       return Object.assign({}, state, {closeButtonVisible: action.shouldDisplayButton});
+    case REVERSE_SEARCH_INPUT_TOGGLE:
+      return {...state, reverseSearchInputVisible: !state.reverseSearchInputVisible};
   }
 
   return state;
 }
 
 module.exports = {
   UiState,
   ui,
--- a/devtools/client/webconsole/selectors/history.js
+++ b/devtools/client/webconsole/selectors/history.js
@@ -41,13 +41,49 @@ function getNextHistoryValue(state) {
 
 function getPreviousHistoryValue(state) {
   if (state.history.position > 0) {
     return state.history.entries[state.history.position - 1];
   }
   return null;
 }
 
+function getReverseSearchResult(state) {
+  const { history } = state;
+  const { currentReverseSearchResults, currentReverseSearchResultsPosition } = history;
+
+  if (!Array.isArray(currentReverseSearchResults)
+    || currentReverseSearchResults.length === 0
+    || !Number.isInteger(currentReverseSearchResultsPosition)
+  ) {
+    return null;
+  }
+  return currentReverseSearchResults[currentReverseSearchResultsPosition];
+}
+
+function getReverseSearchResultPosition(state) {
+  const { history } = state;
+  const { currentReverseSearchResultsPosition } = history;
+  if (!Number.isInteger(currentReverseSearchResultsPosition)) {
+    return currentReverseSearchResultsPosition;
+  }
+
+  return currentReverseSearchResultsPosition + 1;
+}
+
+function getReverseSearchTotalResults(state) {
+  const { history } = state;
+  const { currentReverseSearchResults } = history;
+  if (!currentReverseSearchResults) {
+    return null;
+  }
+
+  return currentReverseSearchResults.length;
+}
+
 module.exports = {
   getHistory,
   getHistoryEntries,
   getHistoryValue,
+  getReverseSearchResult,
+  getReverseSearchResultPosition,
+  getReverseSearchTotalResults,
 };
--- a/testing/talos/talos/tests/devtools/addon/content/tests/webconsole/autocomplete.js
+++ b/testing/talos/talos/tests/devtools/addon/content/tests/webconsole/autocomplete.js
@@ -48,37 +48,32 @@ module.exports = async function() {
 
 async function showAndHideAutoCompletePopup(jsterm) {
   await triggerAutocompletePopup(jsterm);
   await hideAutocompletePopup(jsterm);
 }
 
 async function triggerAutocompletePopup(jsterm) {
   const onPopupOpened = jsterm.autocompletePopup.once("popup-opened");
-  jsterm.setInputValue("window.autocompleteTest.");
-  if (!jsterm.editor) {
-    // setInputValue does not trigger the autocompletion in the old jsterm;
-    // we need to call `updateAutocompletion` in order to display the popup. And since
-    // setInputValue sets lastInputValue and updateAutocompletion checks it to trigger
-    // the autocompletion request, we reset it.
-    jsterm.lastInputValue = null;
-    jsterm.updateAutocompletion();
-  }
+  setJsTermValueForCompletion(jsterm, "window.autocompleteTest.");
   await onPopupOpened;
 
   const onPopupUpdated = jsterm.once("autocomplete-updated");
-  jsterm.setInputValue("window.autocompleteTest.item9");
-  if (!jsterm.editor) {
-    jsterm.lastInputValue = null;
-    jsterm.updateAutocompletion();
-  }
+  setJsTermValueForCompletion(jsterm, "window.autocompleteTest.item9");
   await onPopupUpdated;
 }
 
 function hideAutocompletePopup(jsterm) {
   let onPopUpClosed = jsterm.autocompletePopup.once("popup-closed");
-  jsterm.setInputValue("");
-  if (!jsterm.editor) {
-    jsterm.lastInputValue = null;
-    jsterm.updateAutocompletion();
-  }
+  setJsTermValueForCompletion(jsterm, "");
   return onPopUpClosed;
 }
+
+function setJsTermValueForCompletion(jsterm, value) {
+  // setInputValue does not trigger the autocompletion;
+  // we need to call `updateAutocompletion` in order to display the popup. And since
+  // setInputValue sets lastInputValue and updateAutocompletion checks it to trigger
+  // the autocompletion request, we reset it.
+  jsterm.setInputValue(value);
+  jsterm.lastInputValue = null;
+  jsterm.updateAutocompletion();
+}
+