Bug 1462390 - Extract history from JSTerm component; r=nchevobbe draft
authorJan Odvarko <odvarko@gmail.com>
Thu, 31 May 2018 12:41:29 +0200
changeset 802103 90ecbeee7d198c779c8f19986722dcfd8970fad9
parent 802102 763f30c3421233a45ef9e67a695c5c241a2c8a3a
push id111814
push userjodvarko@mozilla.com
push dateThu, 31 May 2018 10:42:52 +0000
reviewersnchevobbe
bugs1462390
milestone62.0a1
Bug 1462390 - Extract history from JSTerm component; r=nchevobbe MozReview-Commit-ID: DTlW1h2ACoI
devtools/client/inspector/test/browser_inspector_menu-04-use-in-console.js
devtools/client/inspector/test/browser_inspector_menu-06-other.js
devtools/client/webconsole/actions/history.js
devtools/client/webconsole/actions/index.js
devtools/client/webconsole/actions/moz.build
devtools/client/webconsole/components/JSTerm.js
devtools/client/webconsole/constants.js
devtools/client/webconsole/reducers/history.js
devtools/client/webconsole/reducers/index.js
devtools/client/webconsole/reducers/moz.build
devtools/client/webconsole/reducers/prefs.js
devtools/client/webconsole/selectors/history.js
devtools/client/webconsole/selectors/moz.build
devtools/client/webconsole/store.js
devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_return_key_no_selection.js
devtools/client/webconsole/test/mochitest/browser_jsterm_history.js
devtools/client/webconsole/test/mochitest/browser_jsterm_history_persist.js
devtools/client/webconsole/test/mochitest/head.js
devtools/client/webconsole/webconsole-output-wrapper.js
--- a/devtools/client/inspector/test/browser_inspector_menu-04-use-in-console.js
+++ b/devtools/client/inspector/test/browser_inspector_menu-04-use-in-console.js
@@ -40,11 +40,11 @@ add_task(async function() {
     await inspector.once("console-var-ready");
 
     is(jstermInput.value, "temp1", "second console variable is named temp1");
 
     result = await jsterm.execute();
     isnot(result.textContent.indexOf('<p id="console-var-multi">'), -1,
           "variable temp1 references correct node");
 
-    jsterm.clearHistory();
+    hud.ui.consoleOutput.dispatchClearHistory();
   }
 });
--- a/devtools/client/inspector/test/browser_inspector_menu-06-other.js
+++ b/devtools/client/inspector/test/browser_inspector_menu-06-other.js
@@ -1,13 +1,17 @@
 /* vim: set ts=2 et sw=2 tw=80: */
 /* Any copyright is dedicated to the Public Domain.
 http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
+const {
+  getHistoryEntries,
+} = require("devtools/client/webconsole/selectors/history");
+
 // Tests for menuitem functionality that doesn't fit into any specific category
 const TEST_URL = URL_ROOT + "doc_inspector_menu.html";
 add_task(async function() {
   let { inspector, toolbox, testActor } = await openInspectorForURL(TEST_URL);
   await testShowDOMProperties();
   await testDuplicateNode();
   await testDeleteNode();
   await testDeleteTextNode();
@@ -25,17 +29,19 @@ add_task(async function() {
     info("Triggering 'Show DOM Properties' and waiting for inspector open");
     showDOMPropertiesNode.click();
     await consoleOpened;
 
     let webconsoleUI = toolbox.getPanel("webconsole").hud.ui;
     let messagesAdded = webconsoleUI.once("new-messages");
     await messagesAdded;
     info("Checking if 'inspect($0)' was evaluated");
-    ok(webconsoleUI.jsterm.history[0] === "inspect($0)");
+
+    let state = webconsoleUI.consoleOutput.getStore().getState();
+    ok(getHistoryEntries(state)[0] === "inspect($0)");
     await toolbox.toggleSplitConsole();
   }
   async function testDuplicateNode() {
     info("Testing 'Duplicate Node' menu item for normal elements.");
 
     await selectNode(".duplicate", inspector);
     is((await testActor.getNumberOfElementMatches(".duplicate")), 1,
        "There should initially be 1 .duplicate node");
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/actions/history.js
@@ -0,0 +1,65 @@
+/* -*- 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 {
+  APPEND_TO_HISTORY,
+  CLEAR_HISTORY,
+  HISTORY_LOADED,
+  UPDATE_HISTORY_PLACEHOLDER,
+} = 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) {
+  return {
+    type: APPEND_TO_HISTORY,
+    expression: expression,
+  };
+}
+
+/**
+ * Clear the console history altogether. Note that this will not affect
+ * other consoles that are already opened (since they have their own copy),
+ * but it will reset the array for all newly-opened consoles.
+ */
+function clearHistory() {
+  return {
+    type: CLEAR_HISTORY,
+  };
+}
+
+/**
+ * Fired when the console history from previous Firefox sessions is loaded.
+ */
+function historyLoaded(entries) {
+  return {
+    type: HISTORY_LOADED,
+    entries,
+  };
+}
+
+/**
+ * Update place-holder position in the history list.
+ */
+function updatePlaceHolder(direction, expression) {
+  return {
+    type: UPDATE_HISTORY_PLACEHOLDER,
+    direction,
+    expression,
+  };
+}
+
+module.exports = {
+  appendToHistory,
+  clearHistory,
+  historyLoaded,
+  updatePlaceHolder,
+};
--- a/devtools/client/webconsole/actions/index.js
+++ b/devtools/client/webconsole/actions/index.js
@@ -6,13 +6,14 @@
 
 "use strict";
 
 const actionModules = [
   require("./filters"),
   require("./messages"),
   require("./ui"),
   require("./notifications"),
+  require("./history"),
 ];
 
 const actions = Object.assign({}, ...actionModules);
 
 module.exports = actions;
--- a/devtools/client/webconsole/actions/moz.build
+++ b/devtools/client/webconsole/actions/moz.build
@@ -1,12 +1,13 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'filters.js',
+    'history.js',
     'index.js',
     'messages.js',
     'notifications.js',
     'ui.js',
 )
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -9,71 +9,89 @@ const Services = require("Services");
 
 loader.lazyServiceGetter(this, "clipboardHelper",
                          "@mozilla.org/widget/clipboardhelper;1",
                          "nsIClipboardHelper");
 loader.lazyRequireGetter(this, "defer", "devtools/shared/defer");
 loader.lazyRequireGetter(this, "Debugger", "Debugger");
 loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter");
 loader.lazyRequireGetter(this, "AutocompletePopup", "devtools/client/shared/autocomplete-popup");
-loader.lazyRequireGetter(this, "asyncStorage", "devtools/shared/async-storage");
 loader.lazyRequireGetter(this, "PropTypes", "devtools/client/shared/vendor/react-prop-types");
 loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
 loader.lazyRequireGetter(this, "KeyCodes", "devtools/client/shared/keycodes", true);
 loader.lazyRequireGetter(this, "Editor", "devtools/client/sourceeditor/editor");
 
 const l10n = require("devtools/client/webconsole/webconsole-l10n");
 
-// Constants used for defining the direction of JSTerm input history navigation.
-const HISTORY_BACK = -1;
-const HISTORY_FORWARD = 1;
-
 const HELP_URL = "https://developer.mozilla.org/docs/Tools/Web_Console/Helpers";
-
-const PREF_INPUT_HISTORY_COUNT = "devtools.webconsole.inputHistoryCount";
 const PREF_AUTO_MULTILINE = "devtools.webconsole.autoMultiline";
 
 function gSequenceId() {
   return gSequenceId.n++;
 }
 gSequenceId.n = 0;
 
+// 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");
+
+// History Modules
+const {
+  getHistory,
+  getHistoryValue
+} = require("devtools/client/webconsole/selectors/history");
+const historyActions = require("devtools/client/webconsole/actions/history");
+
+// Constants used for defining the direction of JSTerm input history navigation.
+const {
+  HISTORY_BACK,
+  HISTORY_FORWARD
+} = require("devtools/client/webconsole/constants");
 
 /**
  * Create a JSTerminal (a JavaScript command line). This is attached to an
  * existing HeadsUpDisplay (a Web Console instance). This code is responsible
  * with handling command line input and code evaluation.
  *
  * @constructor
  * @param object webConsoleFrame
  *        The WebConsoleFrame object that owns this JSTerm instance.
  */
 class JSTerm extends Component {
   static get propTypes() {
     return {
+      // Append new executed expression into history list (action).
+      appendToHistory: PropTypes.func.isRequired,
+      // Remove all entries from the history list (action).
+      clearHistory: PropTypes.func.isRequired,
+      // Returns previous or next value from the history
+      // (depending on direction argument).
+      getValueFromHistory: PropTypes.func.isRequired,
+      // History of executed expression (state).
+      history: PropTypes.object.isRequired,
+      // Console object.
       hud: PropTypes.object.isRequired,
-      // Handler for clipboard 'paste' event (also used for 'drop' event).
+      // Handler for clipboard 'paste' event (also used for 'drop' event, callback).
       onPaste: PropTypes.func,
       codeMirrorEnabled: PropTypes.bool,
+      // Update position in the history after executing an expression (action).
+      updatePlaceHolder: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     const {
       hud,
     } = props;
 
     this.hud = hud;
     this.hudId = this.hud.hudId;
-    this.inputHistoryCount = Services.prefs.getIntPref(PREF_INPUT_HISTORY_COUNT);
-    this._loadHistory();
 
     /**
      * Stores the data for the last completion.
      * @type object
      */
     this.lastCompletion = { value: null };
 
     this._keyPress = this._keyPress.bind(this);
@@ -123,21 +141,16 @@ class JSTerm extends Component {
     /**
      * Tells if the autocomplete popup was navigated since the last open.
      *
      * @private
      * @type boolean
      */
     this._autocompletePopupNavigated = false;
 
-    /**
-     * History of code that was executed.
-     * @type array
-     */
-    this.history = [];
     this.autocompletePopup = null;
     this.inputNode = null;
     this.completeNode = null;
 
     this.COMPLETE_FORWARD = 0;
     this.COMPLETE_BACKWARD = 1;
     this.COMPLETE_HINT_ONLY = 2;
     this.COMPLETE_PAGEUP = 3;
@@ -209,73 +222,24 @@ class JSTerm extends Component {
       this.inputNode.addEventListener("focus", this._focusEventHandler);
       this.focus();
     }
 
     this.hud.window.addEventListener("blur", this._blurEventHandler);
     this.lastInputValue && this.setInputValue(this.lastInputValue);
   }
 
-  shouldComponentUpdate() {
-    // XXX: For now, everything is handled in an imperative way and we only want React
-    // to do the initial rendering of the component.
+  shouldComponentUpdate(nextProps, nextState) {
+    // XXX: For now, everything is handled in an imperative way and we
+    // only want React to do the initial rendering of the component.
     // This should be modified when the actual refactoring will take place.
     return false;
   }
 
   /**
-   * Load the console history from previous sessions.
-   * @private
-   */
-  _loadHistory() {
-    this.history = [];
-    this.historyIndex = this.historyPlaceHolder = 0;
-
-    this.historyLoaded = asyncStorage.getItem("webConsoleHistory")
-      .then(value => {
-        if (Array.isArray(value)) {
-          // Since it was gotten asynchronously, there could be items already in
-          // the history.  It's not likely but stick them onto the end anyway.
-          this.history = value.concat(this.history);
-
-          // Holds the number of entries in history. This value is incremented
-          // in this.execute().
-          this.historyIndex = this.history.length;
-
-          // Holds the index of the history entry that the user is currently
-          // viewing. This is reset to this.history.length when this.execute()
-          // is invoked.
-          this.historyPlaceHolder = this.history.length;
-        }
-      }, console.error);
-  }
-
-  /**
-   * Clear the console history altogether.  Note that this will not affect
-   * other consoles that are already opened (since they have their own copy),
-   * but it will reset the array for all newly-opened consoles.
-   * @returns Promise
-   *          Resolves once the changes have been persisted.
-   */
-  clearHistory() {
-    this.history = [];
-    this.historyIndex = this.historyPlaceHolder = 0;
-    return this.storeHistory();
-  }
-
-  /**
-   * Stores the console history for future console instances.
-   * @returns Promise
-   *          Resolves once the changes have been persisted.
-   */
-  storeHistory() {
-    return asyncStorage.setItem("webConsoleHistory", this.history);
-  }
-
-  /**
    * Getter for the element that holds the messages we display.
    * @type Element
    */
   get outputNode() {
     return this.hud.outputNode;
   }
 
   /**
@@ -323,17 +287,17 @@ class JSTerm extends Component {
     let helperHasRawOutput = !!(helperResult || {}).rawOutput;
 
     if (helperResult && helperResult.type) {
       switch (helperResult.type) {
         case "clearOutput":
           this.clearOutput();
           break;
         case "clearHistory":
-          this.clearHistory();
+          this.props.clearHistory();
           break;
         case "inspectObject":
           this.inspectObjectActor(helperResult.object);
           break;
         case "error":
           try {
             errorMessage = l10n.getStr(helperResult.message);
           } catch (ex) {
@@ -389,27 +353,19 @@ class JSTerm extends Component {
     let resultCallback = msg => deferred.resolve(msg);
 
     // attempt to execute the content of the inputNode
     executeString = executeString || this.getInputValue();
     if (!executeString) {
       return null;
     }
 
-    // Append a new value in the history of executed code, or overwrite the most
-    // recent entry. The most recent entry may contain the last edited input
-    // value that was not evaluated yet.
-    this.history[this.historyIndex++] = executeString;
-    this.historyPlaceHolder = this.history.length;
+    // Append executed expression into the history list.
+    this.props.appendToHistory(executeString);
 
-    if (this.history.length > this.inputHistoryCount) {
-      this.history.splice(0, this.history.length - this.inputHistoryCount);
-      this.historyIndex = this.historyPlaceHolder = this.history.length;
-    }
-    this.storeHistory();
     WebConsoleUtils.usageCount++;
     this.setInputValue("");
     this.clearCompletion();
 
     let selectedNodeActor = null;
     let inspectorSelection = this.hud.owner.getInspectorSelection();
     if (inspectorSelection && inspectorSelection.nodeFront) {
       selectedNodeActor = inspectorSelection.nodeFront.actorID;
@@ -887,49 +843,36 @@ class JSTerm extends Component {
    *
    * @param number direction
    *        History navigation direction: HISTORY_BACK or HISTORY_FORWARD.
    *
    * @returns boolean
    *          True if the input value changed, false otherwise.
    */
   historyPeruse(direction) {
-    if (!this.history.length) {
+    let {
+      history,
+      updatePlaceHolder,
+      getValueFromHistory,
+    } = this.props;
+
+    if (!history.entries.length) {
       return false;
     }
 
-    // Up Arrow key
-    if (direction == HISTORY_BACK) {
-      if (this.historyPlaceHolder <= 0) {
-        return false;
-      }
-      let inputVal = this.history[--this.historyPlaceHolder];
+    let newInputValue = getValueFromHistory(direction);
+    let expression = this.getInputValue();
+    updatePlaceHolder(direction, expression);
 
-      // Save the current input value as the latest entry in history, only if
-      // the user is already at the last entry.
-      // Note: this code does not store changes to items that are already in
-      // history.
-      if (this.historyPlaceHolder + 1 == this.historyIndex) {
-        this.history[this.historyIndex] = this.getInputValue() || "";
-      }
-
-      this.setInputValue(inputVal);
-    } else if (direction == HISTORY_FORWARD) {
-      // Down Arrow key
-      if (this.historyPlaceHolder >= (this.history.length - 1)) {
-        return false;
-      }
-
-      let inputVal = this.history[++this.historyPlaceHolder];
-      this.setInputValue(inputVal);
-    } else {
-      throw new Error("Invalid argument 0");
+    if (newInputValue != null) {
+      this.setInputValue(newInputValue);
+      return true;
     }
 
-    return true;
+    return false;
   }
 
   /**
    * Test for multiline input.
    *
    * @return boolean
    *         True if CR or LF found in node value; else false.
    */
@@ -1399,9 +1342,27 @@ class JSTerm extends Component {
           onPaste: onPaste,
           onDrop: onPaste,
         })
       )
     );
   }
 }
 
-module.exports = JSTerm;
+// Redux connect
+
+function mapStateToProps(state) {
+  return {
+    history: getHistory(state),
+    getValueFromHistory: (direction) => getHistoryValue(state, direction),
+  };
+}
+
+function mapDispatchToProps(dispatch) {
+  return {
+    appendToHistory: (expr) => dispatch(historyActions.appendToHistory(expr)),
+    clearHistory: () => dispatch(historyActions.clearHistory()),
+    updatePlaceHolder: (direction, expression) =>
+      dispatch(historyActions.updatePlaceHolder(direction, expression)),
+  };
+}
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);
--- a/devtools/client/webconsole/constants.js
+++ b/devtools/client/webconsole/constants.js
@@ -25,16 +25,20 @@ const actionTypes = {
   REMOVED_ACTORS_CLEAR: "REMOVED_ACTORS_CLEAR",
   SELECT_NETWORK_MESSAGE_TAB: "SELECT_NETWORK_MESSAGE_TAB",
   SIDEBAR_CLOSE: "SIDEBAR_CLOSE",
   SHOW_OBJECT_IN_SIDEBAR: "SHOW_OBJECT_IN_SIDEBAR",
   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_PLACEHOLDER: "UPDATE_HISTORY_PLACEHOLDER",
 };
 
 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",
@@ -47,16 +51,18 @@ const prefs = {
       NETXHR: "filter.netxhr",
     },
     UI: {
       // Filter bar UI preference only have the suffix since it can be used either for
       // the webconsole or the browser console.
       FILTER_BAR: "ui.filterbar",
       // Persist is only used by the webconsole.
       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",
     }
   }
 };
@@ -133,19 +139,26 @@ const chromeRDPEnums = {
 };
 
 const jstermCommands = {
   JSTERM_COMMANDS: {
     INSPECT: "inspectObject"
   }
 };
 
+// Constants used for defining the direction of JSTerm input history navigation.
+const historyCommands = {
+  HISTORY_BACK: -1,
+  HISTORY_FORWARD: 1,
+};
+
 // Combine into a single constants object
 module.exports = Object.assign({
   FILTERS,
   DEFAULT_FILTERS,
   DEFAULT_FILTERS_VALUES,
 },
   actionTypes,
   chromeRDPEnums,
   jstermCommands,
   prefs,
+  historyCommands,
 );
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/reducers/history.js
@@ -0,0 +1,126 @@
+/* -*- 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 {
+  APPEND_TO_HISTORY,
+  CLEAR_HISTORY,
+  HISTORY_LOADED,
+  UPDATE_HISTORY_PLACEHOLDER,
+  HISTORY_BACK,
+  HISTORY_FORWARD,
+} = require("devtools/client/webconsole/constants");
+
+/**
+ * Create default initial state for this reducer.
+ */
+function getInitialState() {
+  return {
+    // Array with history entries
+    entries: [],
+
+    // Holds the index of the history entry that the user is currently
+    // viewing. This is reset to this.history.length when APPEND_TO_HISTORY
+    // action is fired.
+    placeHolder: undefined,
+
+    // Holds the number of entries in history. This value is incremented
+    // when APPEND_TO_HISTORY action is fired and used to get previous
+    // value from the command line when the user goes backward.
+    index: 0,
+  };
+}
+
+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_PLACEHOLDER:
+      return updatePlaceHolder(state, action.direction, action.expression);
+  }
+  return state;
+}
+
+function appendToHistory(state, prefsState, expression) {
+  // Clone state
+  state = {...state};
+  state.entries = [...state.entries];
+
+  // Append new expression
+  state.entries[state.index++] = expression;
+  state.placeHolder = state.entries.length;
+
+  // Remove entries if the limit is reached
+  if (state.entries.length > prefsState.historyCount) {
+    state.entries.splice(0, state.entries.length - prefsState.historyCount);
+    state.index = state.placeHolder = state.entries.length;
+  }
+
+  return state;
+}
+
+function clearHistory(state) {
+  return getInitialState();
+}
+
+/**
+ * Handling HISTORY_LOADED action that is fired when history
+ * entries created in previous Firefox session are loaded
+ * from async-storage.
+ *
+ * Loaded entries are appended before the ones that were
+ * added to the state in this session.
+ */
+function historyLoaded(state, entries) {
+  let newEntries = [...entries, ...state.entries];
+  return {
+    ...state,
+    entries: newEntries,
+    placeHolder: newEntries.length,
+    index: newEntries.length,
+  };
+}
+
+function updatePlaceHolder(state, direction, expression) {
+  // Handle UP arrow key => HISTORY_BACK
+  // Handle DOWN arrow key => HISTORY_FORWARD
+  if (direction == HISTORY_BACK) {
+    if (state.placeHolder <= 0) {
+      return state;
+    }
+
+    // Clone state
+    state = {...state};
+
+    // Save the current input value as the latest entry in history, only if
+    // the user is already at the last entry.
+    // Note: this code does not store changes to items that are already in
+    // history.
+    if (state.placeHolder == state.index) {
+      state.entries = [...state.entries];
+      state.entries[state.index] = expression || "";
+    }
+
+    state.placeHolder--;
+  } else if (direction == HISTORY_FORWARD) {
+    if (state.placeHolder >= (state.entries.length - 1)) {
+      return state;
+    }
+
+    state = {
+      ...state,
+      placeHolder: state.placeHolder + 1,
+    };
+  }
+
+  return state;
+}
+
+exports.history = history;
--- a/devtools/client/webconsole/reducers/index.js
+++ b/devtools/client/webconsole/reducers/index.js
@@ -5,16 +5,18 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const { filters } = require("./filters");
 const { messages } = require("./messages");
 const { prefs } = require("./prefs");
 const { ui } = require("./ui");
 const { notifications } = require("./notifications");
+const { history } = require("./history");
 
 exports.reducers = {
   filters,
   messages,
   prefs,
   ui,
   notifications,
+  history,
 };
--- a/devtools/client/webconsole/reducers/moz.build
+++ b/devtools/client/webconsole/reducers/moz.build
@@ -1,13 +1,14 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'filters.js',
+    'history.js',
     'index.js',
     'messages.js',
     'notifications.js',
     'prefs.js',
     'ui.js',
 )
--- a/devtools/client/webconsole/reducers/prefs.js
+++ b/devtools/client/webconsole/reducers/prefs.js
@@ -4,16 +4,17 @@
  * 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 PrefState = (overrides) => Object.freeze(Object.assign({
   logLimit: 1000,
   sidebarToggle: false,
   jstermCodeMirror: false,
+  historyCount: 50,
 }, overrides));
 
 function prefs(state = PrefState(), action) {
   return state;
 }
 
 exports.PrefState = PrefState;
 exports.prefs = prefs;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/selectors/history.js
@@ -0,0 +1,50 @@
+/* -*- 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 {
+  HISTORY_BACK,
+  HISTORY_FORWARD,
+} = require("devtools/client/webconsole/constants");
+
+function getHistory(state) {
+  return state.history;
+}
+
+function getHistoryEntries(state) {
+  return state.history.entries;
+}
+
+function getHistoryValue(state, direction) {
+  if (direction == HISTORY_BACK) {
+    return getPreviousHistoryValue(state);
+  }
+  if (direction == HISTORY_FORWARD) {
+    return getNextHistoryValue(state);
+  }
+  return null;
+}
+
+function getNextHistoryValue(state) {
+  if (state.history.placeHolder < (state.history.entries.length - 1)) {
+    return state.history.entries[state.history.placeHolder + 1];
+  }
+  return null;
+}
+
+function getPreviousHistoryValue(state) {
+  if (state.history.placeHolder > 0) {
+    return state.history.entries[state.history.placeHolder - 1];
+  }
+  return null;
+}
+
+module.exports = {
+  getHistory,
+  getHistoryEntries,
+  getHistoryValue,
+};
--- a/devtools/client/webconsole/selectors/moz.build
+++ b/devtools/client/webconsole/selectors/moz.build
@@ -1,12 +1,13 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'filters.js',
+    'history.js',
     'messages.js',
     'notifications.js',
     'prefs.js',
     'ui.js',
 )
--- a/devtools/client/webconsole/store.js
+++ b/devtools/client/webconsole/store.js
@@ -19,49 +19,56 @@ const {
   MESSAGES_ADD,
   MESSAGES_CLEAR,
   PRIVATE_MESSAGES_CLEAR,
   REMOVED_ACTORS_CLEAR,
   NETWORK_MESSAGE_UPDATE,
   PREFS,
   INITIALIZE,
   FILTER_TOGGLE,
+  APPEND_TO_HISTORY,
+  CLEAR_HISTORY,
 } = require("devtools/client/webconsole/constants");
 const { reducers } = require("./reducers/index");
 const {
   getMessage,
   getAllMessagesUiById,
 } = require("devtools/client/webconsole/selectors/messages");
 const DataProvider = require("devtools/client/netmonitor/src/connector/firefox-data-provider");
 const {
   getAllNetworkMessagesUpdateById,
 } = require("devtools/client/webconsole/selectors/messages");
 const {getPrefsService} = require("devtools/client/webconsole/utils/prefs");
+const historyActions = require("devtools/client/webconsole/actions/history");
+
+loader.lazyRequireGetter(this, "asyncStorage", "devtools/shared/async-storage");
 
 /**
  * Create and configure store for the Console panel. This is the place
  * where various enhancers and middleware can be registered.
  */
 function configureStore(hud, options = {}) {
   const prefsService = getPrefsService(hud);
   const {
     getBoolPref,
     getIntPref,
   } = prefsService;
 
   const logLimit = options.logLimit
     || Math.max(getIntPref("devtools.hud.loglimit"), 1);
   const sidebarToggle = getBoolPref(PREFS.FEATURES.SIDEBAR_TOGGLE);
   const jstermCodeMirror = getBoolPref(PREFS.FEATURES.JSTERM_CODE_MIRROR);
+  const historyCount = getIntPref(PREFS.UI.INPUT_HISTORY_COUNT);
 
   const initialState = {
     prefs: PrefState({
       logLimit,
       sidebarToggle,
       jstermCodeMirror,
+      historyCount,
     }),
     filters: FilterState({
       error: getBoolPref(PREFS.FILTER.ERROR),
       warn: getBoolPref(PREFS.FILTER.WARN),
       info: getBoolPref(PREFS.FILTER.INFO),
       debug: getBoolPref(PREFS.FILTER.DEBUG),
       log: getBoolPref(PREFS.FILTER.LOG),
       css: getBoolPref(PREFS.FILTER.CSS),
@@ -70,21 +77,27 @@ function configureStore(hud, options = {
     }),
     ui: UiState({
       filterBarVisible: getBoolPref(PREFS.UI.FILTER_BAR),
       networkMessageActiveTabId: "headers",
       persistLogs: getBoolPref(PREFS.UI.PERSIST),
     })
   };
 
+  // Prepare middleware.
+  let middleware = applyMiddleware(
+    thunk.bind(null, {prefsService}),
+    historyPersistenceMiddleware,
+  );
+
   return createStore(
     createRootReducer(),
     initialState,
     compose(
-      applyMiddleware(thunk.bind(null, {prefsService})),
+      middleware,
       enableActorReleaser(hud),
       enableBatching(),
       enableNetProvider(hud),
       enableMessagesCacheClearing(hud),
       ensureCSSErrorReportingEnabled(hud),
     )
   );
 }
@@ -94,24 +107,29 @@ function thunk(options = {}, { dispatch,
     return (typeof action === "function")
       ? action(dispatch, getState, options)
       : next(action);
   };
 }
 
 function createRootReducer() {
   return function rootReducer(state, action) {
-    // We want to compute the new state for all properties except "messages".
+    // We want to compute the new state for all properties except
+    // "messages" and "history". These two reducers are handled
+    // separately since they are receiving additional arguments.
     const newState = [...Object.entries(reducers)].reduce((res, [key, reducer]) => {
-      if (key !== "messages") {
+      if (key !== "messages" && key !== "history") {
         res[key] = reducer(state[key], action);
       }
       return res;
     }, {});
 
+    // Pass prefs state as additional argument to the history reducer.
+    newState.history = reducers.history(state.history, action, newState.prefs);
+
     return Object.assign(newState, {
       // specifically pass the updated filters and prefs state as additional arguments.
       messages: reducers.messages(
         state.messages,
         action,
         newState.filters,
         newState.prefs,
       ),
@@ -222,17 +240,17 @@ function enableNetProvider(hud) {
         updateRequest: (id, data, batch) => {
           proxy.dispatchRequestUpdate(id, data);
         }
       };
 
       // Data provider implements async logic for fetching
       // data from the backend. It's created the first
       // time it's needed.
-      if (!dataProvider) {
+      if (!dataProvider && proxy.webConsoleClient) {
         dataProvider = new DataProvider({
           actions,
           webConsoleClient: proxy.webConsoleClient
         });
 
         // /!\ This is terrible, but it allows ResponsePanel to be able to call
         // `dataProvider.requestData` to fetch response content lazily.
         // `proxy.networkDataProvider` is put by WebConsoleOutputWrapper on
@@ -318,12 +336,47 @@ function enableMessagesCacheClearing(hud
 function releaseActors(removedActors, proxy) {
   if (!proxy) {
     return;
   }
 
   removedActors.forEach(actor => proxy.releaseActor(actor));
 }
 
+/**
+ * History persistence middleware is responsible for loading
+ * and maintaining history of executed expressions in JSTerm.
+ */
+function historyPersistenceMiddleware(store) {
+  let historyLoaded = false;
+  asyncStorage.getItem("webConsoleHistory").then(value => {
+    if (Array.isArray(value)) {
+      store.dispatch(historyActions.historyLoaded(value));
+    }
+    historyLoaded = true;
+  }, err => {
+    historyLoaded = true;
+    console.error(err);
+  });
+
+  return next => action => {
+    const res = next(action);
+
+    let triggerStoreActions = [
+      APPEND_TO_HISTORY,
+      CLEAR_HISTORY,
+    ];
+
+    // Save the current history entries when modified, but wait till
+    // entries from the previous session are loaded.
+    if (historyLoaded && triggerStoreActions.includes(action.type)) {
+      const state = store.getState();
+      asyncStorage.setItem("webConsoleHistory", state.history.entries);
+    }
+
+    return res;
+  };
+}
+
 // Provide the store factory for test code so that each test is working with
 // its own instance.
 module.exports.configureStore = configureStore;
 
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_return_key_no_selection.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_return_key_no_selection.js
@@ -11,18 +11,26 @@ const TEST_URI = `data:text/html;charset
 <head>
   <script>
     window.testBugA = "hello world";
     window.testBugB = "hello world 2";
   </script>
 </head>
 <body>bug 873250 - test pressing return with open popup, but no selection</body>`;
 
+const {
+  getHistoryEntries,
+} = require("devtools/client/webconsole/selectors/history");
+
 add_task(async function() {
-  let { jsterm } = await openNewTabAndConsole(TEST_URI);
+  const {
+    jsterm,
+    ui,
+  } = await openNewTabAndConsole(TEST_URI);
+
   const {
     autocompletePopup: popup,
     completeNode,
   } = jsterm;
 
   const onPopUpOpen = popup.once("popup-opened");
 
   info("wait for popup to show");
@@ -38,11 +46,14 @@ add_task(async function() {
   info("press Return and wait for popup to hide");
   const onPopUpClose = popup.once("popup-closed");
   executeSoon(() => EventUtils.synthesizeKey("KEY_Enter"));
   await onPopUpClose;
 
   ok(!popup.isOpen, "popup is not open after KEY_Enter");
   is(jsterm.getInputValue(), "", "inputNode is empty after KEY_Enter");
   is(completeNode.value, "", "completeNode is empty");
-  is(jsterm.history[jsterm.history.length - 1], "window.testBug",
+
+  const state = ui.consoleOutput.getStore().getState();
+  const entries = getHistoryEntries(state);
+  is(entries[entries.length - 1], "window.testBug",
      "jsterm history is correct");
 });
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_history.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_history.js
@@ -3,20 +3,23 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 // Tests the console history feature accessed via the up and down arrow keys.
 
 "use strict";
 
 const TEST_URI = "data:text/html;charset=UTF-8,test";
-const HISTORY_BACK = -1;
-const HISTORY_FORWARD = 1;
 const COMMANDS = ["document", "window", "window.location"];
 
+const {
+  HISTORY_BACK,
+  HISTORY_FORWARD,
+} = require("devtools/client/webconsole/constants");
+
 add_task(async function() {
   const { jsterm } = await openNewTabAndConsole(TEST_URI);
   const { inputNode } = jsterm;
   jsterm.clearOutput();
 
   for (let command of COMMANDS) {
     info(`Executing command ${command}`);
     jsterm.setInputValue(command);
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_history_persist.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_history_persist.js
@@ -9,76 +9,99 @@
 "use strict";
 
 requestLongerTimeout(2);
 
 const TEST_URI = "data:text/html;charset=utf-8,Web Console test for " +
                  "persisting history - bug 943306";
 const INPUT_HISTORY_COUNT = 10;
 
+const {
+  getHistoryEntries,
+} = require("devtools/client/webconsole/selectors/history");
+
 add_task(async function() {
   info("Setting custom input history pref to " + INPUT_HISTORY_COUNT);
   Services.prefs.setIntPref("devtools.webconsole.inputHistoryCount", INPUT_HISTORY_COUNT);
 
   // First tab: run a bunch of commands and then make sure that you can
   // navigate through their history.
   let hud1 = await openNewTabAndConsole(TEST_URI);
+  let state1 = hud1.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state1)),
+     "[]",
+     "No history on first tab initially");
+  await populateInputHistory(hud1);
 
-  is(JSON.stringify(hud1.jsterm.history), "[]", "No history on first tab initially");
-  await populateInputHistory(hud1);
-  is(JSON.stringify(hud1.jsterm.history),
+  state1 = hud1.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state1)),
      '["0","1","2","3","4","5","6","7","8","9"]',
      "First tab has populated history");
 
   // Second tab: Just make sure that you can navigate through the history
   // generated by the first tab.
   let hud2 = await openNewTabAndConsole(TEST_URI, false);
-  is(JSON.stringify(hud2.jsterm.history),
+  let state2 = hud2.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state2)),
      '["0","1","2","3","4","5","6","7","8","9"]',
      "Second tab has populated history");
   await testNavigatingHistoryInUI(hud2);
-  is(JSON.stringify(hud2.jsterm.history),
+
+  state2 = hud2.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state2)),
      '["0","1","2","3","4","5","6","7","8","9",""]',
      "An empty entry has been added in the second tab due to history perusal");
 
   // Third tab: Should have the same history as first tab, but if we run a
   // command, then the history of the first and second shouldn't be affected
   let hud3 = await openNewTabAndConsole(TEST_URI, false);
-  is(JSON.stringify(hud3.jsterm.history),
+  let state3 = hud3.ui.consoleOutput.getStore().getState();
+
+  is(JSON.stringify(getHistoryEntries(state3)),
      '["0","1","2","3","4","5","6","7","8","9"]',
      "Third tab has populated history");
 
   // Set input value separately from execute so UP arrow accurately navigates
   // history.
   hud3.jsterm.setInputValue('"hello from third tab"');
   await hud3.jsterm.execute();
 
-  is(JSON.stringify(hud1.jsterm.history),
+  state1 = hud1.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state1)),
      '["0","1","2","3","4","5","6","7","8","9"]',
      "First tab history hasn't changed due to command in third tab");
-  is(JSON.stringify(hud2.jsterm.history),
+
+  state2 = hud2.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state2)),
      '["0","1","2","3","4","5","6","7","8","9",""]',
      "Second tab history hasn't changed due to command in third tab");
-  is(JSON.stringify(hud3.jsterm.history),
+
+  state3 = hud3.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state3)),
      '["1","2","3","4","5","6","7","8","9","\\"hello from third tab\\""]',
      "Third tab has updated history (and purged the first result) after " +
      "running a command");
 
   // Fourth tab: Should have the latest command from the third tab, followed
   // by the rest of the history from the first tab.
   let hud4 = await openNewTabAndConsole(TEST_URI, false);
-  is(JSON.stringify(hud4.jsterm.history),
+  let state4 = hud4.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state4)),
      '["1","2","3","4","5","6","7","8","9","\\"hello from third tab\\""]',
      "Fourth tab has most recent history");
 
-  await hud4.jsterm.clearHistory();
-  is(JSON.stringify(hud4.jsterm.history), "[]", "Clearing history for a tab works");
+  await hud4.jsterm.props.clearHistory();
+  state4 = hud4.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state4)),
+     "[]",
+     "Clearing history for a tab works");
 
   let hud5 = await openNewTabAndConsole(TEST_URI, false);
-  is(JSON.stringify(hud5.jsterm.history), "[]",
+  let state5 = hud5.ui.consoleOutput.getStore().getState();
+  is(JSON.stringify(getHistoryEntries(state5)), "[]",
      "Clearing history carries over to a new tab");
 
   info("Clearing custom input history pref");
   Services.prefs.clearUserPref("devtools.webconsole.inputHistoryCount");
 });
 
 /**
  * Populate the history by running the following commands:
--- a/devtools/client/webconsole/test/mochitest/head.js
+++ b/devtools/client/webconsole/test/mochitest/head.js
@@ -65,17 +65,17 @@ registerCleanupFunction(async function()
  */
 async function openNewTabAndConsole(url, clearJstermHistory = true) {
   let toolbox = await openNewTabAndToolbox(url, "webconsole");
   let hud = toolbox.getCurrentPanel().hud;
   hud.jsterm._lazyVariablesView = false;
 
   if (clearJstermHistory) {
     // Clearing history that might have been set in previous tests.
-    await hud.jsterm.clearHistory();
+    await hud.ui.consoleOutput.dispatchClearHistory();
   }
 
   return hud;
 }
 
 /**
  * Subscribe to the store and log out stringinfied versions of messages.
  * This is a helper function for debugging, to make is easier to see what
--- a/devtools/client/webconsole/webconsole-output-wrapper.js
+++ b/devtools/client/webconsole/webconsole-output-wrapper.js
@@ -362,16 +362,20 @@ WebConsoleOutputWrapper.prototype = {
     this.setTimeoutIfNeeded();
   },
 
   batchedMessagesAdd: function(message) {
     this.queuedMessageAdds.push(message);
     this.setTimeoutIfNeeded();
   },
 
+  dispatchClearHistory: function() {
+    store.dispatch(actions.clearHistory());
+  },
+
   /**
    * Returns a Promise that resolves once any async dispatch is finally dispatched.
    */
   waitAsyncDispatches: function() {
     if (!this.throttledDispatchPromise) {
       return Promise.resolve();
     }
     return this.throttledDispatchPromise;