tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

JSTerm.js (51065B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const { debounce } = require("resource://devtools/shared/debounce.js");
      8 const isMacOS = Services.appinfo.OS === "Darwin";
      9 
     10 const lazy = {};
     11 ChromeUtils.defineESModuleGetters(lazy, {
     12  getFocusableElements: "resource://devtools/client/shared/focus.mjs",
     13 });
     14 
     15 loader.lazyRequireGetter(this, "Debugger", "Debugger");
     16 loader.lazyRequireGetter(
     17  this,
     18  "EventEmitter",
     19  "resource://devtools/shared/event-emitter.js"
     20 );
     21 loader.lazyRequireGetter(
     22  this,
     23  "AutocompletePopup",
     24  "resource://devtools/client/shared/autocomplete-popup.js"
     25 );
     26 
     27 loader.lazyRequireGetter(
     28  this,
     29  "PropTypes",
     30  "resource://devtools/client/shared/vendor/react-prop-types.js"
     31 );
     32 loader.lazyRequireGetter(
     33  this,
     34  "KeyCodes",
     35  "resource://devtools/client/shared/keycodes.js",
     36  true
     37 );
     38 loader.lazyRequireGetter(
     39  this,
     40  "Editor",
     41  "resource://devtools/client/shared/sourceeditor/editor.js"
     42 );
     43 loader.lazyRequireGetter(
     44  this,
     45  "l10n",
     46  "resource://devtools/client/webconsole/utils/messages.js",
     47  true
     48 );
     49 loader.lazyRequireGetter(
     50  this,
     51  "saveAs",
     52  "resource://devtools/shared/DevToolsUtils.js",
     53  true
     54 );
     55 loader.lazyRequireGetter(
     56  this,
     57  "beautify",
     58  "resource://devtools/shared/jsbeautify/beautify.js"
     59 );
     60 
     61 // React & Redux
     62 const {
     63  Component,
     64  createFactory,
     65 } = require("resource://devtools/client/shared/vendor/react.mjs");
     66 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
     67 const {
     68  connect,
     69 } = require("resource://devtools/client/shared/vendor/react-redux.js");
     70 
     71 // History Modules
     72 const {
     73  getHistory,
     74  getHistoryValue,
     75 } = require("resource://devtools/client/webconsole/selectors/history.js");
     76 const {
     77  getAutocompleteState,
     78 } = require("resource://devtools/client/webconsole/selectors/autocomplete.js");
     79 const actions = require("resource://devtools/client/webconsole/actions/index.js");
     80 
     81 const EvaluationContextSelector = createFactory(
     82  require("resource://devtools/client/webconsole/components/Input/EvaluationContextSelector.js")
     83 );
     84 
     85 // Constants used for defining the direction of JSTerm input history navigation.
     86 const {
     87  HISTORY_BACK,
     88  HISTORY_FORWARD,
     89 } = require("resource://devtools/client/webconsole/constants.js");
     90 
     91 const JSTERM_CODEMIRROR_ORIGIN = "jsterm";
     92 
     93 /**
     94 * Create a JSTerminal (a JavaScript command line). This is attached to an
     95 * existing HeadsUpDisplay (a Web Console instance). This code is responsible
     96 * with handling command line input and code evaluation.
     97 */
     98 class JSTerm extends Component {
     99  static get propTypes() {
    100    return {
    101      // Returns previous or next value from the history
    102      // (depending on direction argument).
    103      getValueFromHistory: PropTypes.func.isRequired,
    104      // History of executed expression (state).
    105      history: PropTypes.object.isRequired,
    106      // Console object.
    107      webConsoleUI: PropTypes.object.isRequired,
    108      // Needed for opening context menu
    109      serviceContainer: PropTypes.object.isRequired,
    110      // Handler for clipboard 'paste' event (also used for 'drop' event, callback).
    111      onPaste: PropTypes.func,
    112      // Evaluate provided expression.
    113      evaluateExpression: PropTypes.func.isRequired,
    114      // Update position in the history after executing an expression (action).
    115      updateHistoryPosition: PropTypes.func.isRequired,
    116      // Update autocomplete popup state.
    117      autocompleteUpdate: PropTypes.func.isRequired,
    118      autocompleteClear: PropTypes.func.isRequired,
    119      // Data to be displayed in the autocomplete popup.
    120      autocompleteData: PropTypes.object.isRequired,
    121      // Toggle the editor mode.
    122      editorToggle: PropTypes.func.isRequired,
    123      // Dismiss the editor onboarding UI.
    124      editorOnboardingDismiss: PropTypes.func.isRequired,
    125      // Set the last JS input value.
    126      terminalInputChanged: PropTypes.func.isRequired,
    127      // Is the input in editor mode.
    128      editorMode: PropTypes.bool,
    129      editorWidth: PropTypes.number,
    130      editorPrettifiedAt: PropTypes.number,
    131      showEditorOnboarding: PropTypes.bool,
    132      autocomplete: PropTypes.bool,
    133      autocompletePopupPosition: PropTypes.string,
    134      inputEnabled: PropTypes.bool,
    135    };
    136  }
    137 
    138  constructor(props) {
    139    super(props);
    140 
    141    const { webConsoleUI } = props;
    142 
    143    this.webConsoleUI = webConsoleUI;
    144    this.hudId = this.webConsoleUI.hudId;
    145 
    146    this._onEditorChanges = this._onEditorChanges.bind(this);
    147    this._onEditorBeforeChange = this._onEditorBeforeChange.bind(this);
    148    this._onEditorKeyHandled = this._onEditorKeyHandled.bind(this);
    149    this.onContextMenu = this.onContextMenu.bind(this);
    150    this.imperativeUpdate = this.imperativeUpdate.bind(this);
    151 
    152    // We debounce the autocompleteUpdate so we don't send too many requests to the server
    153    // as the user is typing.
    154    // The delay should be small enough to be unnoticed by the user.
    155    this.autocompleteUpdate = debounce(this.props.autocompleteUpdate, 75, this);
    156 
    157    // Updates to the terminal input which can trigger eager evaluations are
    158    // similarly debounced.
    159    this.terminalInputChanged = debounce(
    160      this.props.terminalInputChanged,
    161      75,
    162      this
    163    );
    164 
    165    // Because the autocomplete has a slight delay (75ms), there can be time where the
    166    // codeMirror completion text is out-of-date, which might lead to issue when the user
    167    // accept the autocompletion while the update of the completion text is still pending.
    168    // In order to account for that, we put any future value of the completion text in
    169    // this property.
    170    this.pendingCompletionText = null;
    171 
    172    /**
    173     * Last input value.
    174     *
    175     * @type string
    176     */
    177    this.lastInputValue = "";
    178 
    179    this.autocompletePopup = null;
    180 
    181    EventEmitter.decorate(this);
    182    webConsoleUI.jsterm = this;
    183  }
    184 
    185  componentDidMount() {
    186    if (this.props.editorMode) {
    187      this.setEditorWidth(this.props.editorWidth);
    188    }
    189 
    190    const autocompleteOptions = {
    191      onSelect: this.onAutocompleteSelect.bind(this),
    192      onClick: this.acceptProposedCompletion.bind(this),
    193      listId: "webConsole_autocompletePopupListBox",
    194      position: this.props.autocompletePopupPosition,
    195      autoSelect: true,
    196      useXulWrapper: true,
    197    };
    198 
    199    const doc = this.webConsoleUI.document;
    200    const { toolbox } = this.webConsoleUI.wrapper;
    201    const tooltipDoc = toolbox ? toolbox.doc : doc;
    202    // The popup will be attached to the toolbox document or HUD document in the case
    203    // such as the browser console which doesn't have a toolbox.
    204    this.autocompletePopup = new AutocompletePopup(
    205      tooltipDoc,
    206      autocompleteOptions
    207    );
    208 
    209    if (this.node) {
    210      const onArrowUp = () => {
    211        let inputUpdated;
    212        if (this.autocompletePopup.isOpen) {
    213          this.autocompletePopup.selectPreviousItem();
    214          return null;
    215        }
    216 
    217        if (this.props.editorMode === false && this.canCaretGoPrevious()) {
    218          inputUpdated = this.historyPeruse(HISTORY_BACK);
    219        }
    220 
    221        return inputUpdated ? null : "CodeMirror.Pass";
    222      };
    223 
    224      const onArrowDown = () => {
    225        let inputUpdated;
    226        if (this.autocompletePopup.isOpen) {
    227          this.autocompletePopup.selectNextItem();
    228          return null;
    229        }
    230 
    231        if (this.props.editorMode === false && this.canCaretGoNext()) {
    232          inputUpdated = this.historyPeruse(HISTORY_FORWARD);
    233        }
    234 
    235        return inputUpdated ? null : "CodeMirror.Pass";
    236      };
    237 
    238      const onArrowLeft = () => {
    239        if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) {
    240          this.clearCompletion();
    241        }
    242        return "CodeMirror.Pass";
    243      };
    244 
    245      const onArrowRight = () => {
    246        // We only want to complete on Right arrow if the completion text is
    247        // displayed.
    248        if (this.getAutoCompletionText()) {
    249          this.acceptProposedCompletion();
    250          return null;
    251        }
    252 
    253        this.clearCompletion();
    254        return "CodeMirror.Pass";
    255      };
    256 
    257      const onCtrlCmdEnter = () => {
    258        if (this.hasAutocompletionSuggestion()) {
    259          return this.acceptProposedCompletion();
    260        }
    261 
    262        this._execute();
    263        return null;
    264      };
    265 
    266      this.editor = new Editor({
    267        autofocus: true,
    268        enableCodeFolding: this.props.editorMode,
    269        lineNumbers: this.props.editorMode,
    270        lineWrapping: true,
    271        mode: {
    272          name: "javascript",
    273          globalVars: true,
    274        },
    275        styleActiveLine: false,
    276        tabIndex: "0",
    277        viewportMargin: Infinity,
    278        disableSearchAddon: true,
    279        extraKeys: {
    280          Enter: () => {
    281            // No need to handle shift + Enter as it's natively handled by CodeMirror.
    282 
    283            const hasSuggestion = this.hasAutocompletionSuggestion();
    284            if (
    285              !hasSuggestion &&
    286              !Debugger.isCompilableUnit(this._getValue())
    287            ) {
    288              // incomplete statement
    289              return "CodeMirror.Pass";
    290            }
    291 
    292            if (hasSuggestion) {
    293              return this.acceptProposedCompletion();
    294            }
    295 
    296            if (!this.props.editorMode) {
    297              this._execute();
    298              return null;
    299            }
    300            return "CodeMirror.Pass";
    301          },
    302 
    303          "Cmd-Enter": onCtrlCmdEnter,
    304          "Ctrl-Enter": onCtrlCmdEnter,
    305 
    306          [Editor.accel("S")]: () => {
    307            const value = this._getValue();
    308            if (!value) {
    309              return null;
    310            }
    311 
    312            const date = new Date();
    313            const suggestedName =
    314              `console-input-${date.getFullYear()}-` +
    315              `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` +
    316              `${date.getMinutes()}-${date.getSeconds()}.js`;
    317            const data = new TextEncoder().encode(value);
    318            return saveAs(window, data, suggestedName, [
    319              {
    320                pattern: "*.js",
    321                label: l10n.getStr("webconsole.input.openJavaScriptFileFilter"),
    322              },
    323            ]);
    324          },
    325 
    326          [Editor.accel("O")]: async () => this._openFile(),
    327 
    328          Tab: () => {
    329            if (this.hasEmptyInput()) {
    330              this.editor.codeMirror.getInputField().blur();
    331              return false;
    332            }
    333 
    334            if (
    335              this.props.autocompleteData &&
    336              this.props.autocompleteData.getterPath
    337            ) {
    338              this.props.autocompleteUpdate(
    339                true,
    340                this.props.autocompleteData.getterPath
    341              );
    342              return false;
    343            }
    344 
    345            const isSomethingSelected = this.editor.somethingSelected();
    346            const hasSuggestion = this.hasAutocompletionSuggestion();
    347 
    348            if (hasSuggestion && !isSomethingSelected) {
    349              this.acceptProposedCompletion();
    350              return false;
    351            }
    352 
    353            if (!isSomethingSelected) {
    354              this.insertStringAtCursor("\t");
    355              return false;
    356            }
    357 
    358            // Something is selected, let the editor handle the indent.
    359            return true;
    360          },
    361 
    362          "Shift-Tab": () => {
    363            if (this.hasEmptyInput()) {
    364              this.focusPreviousElement();
    365              return false;
    366            }
    367 
    368            const hasSuggestion = this.hasAutocompletionSuggestion();
    369 
    370            if (hasSuggestion) {
    371              return false;
    372            }
    373 
    374            return "CodeMirror.Pass";
    375          },
    376 
    377          Up: onArrowUp,
    378          "Cmd-Up": onArrowUp,
    379 
    380          Down: onArrowDown,
    381          "Cmd-Down": onArrowDown,
    382 
    383          Left: onArrowLeft,
    384          "Ctrl-Left": onArrowLeft,
    385          "Cmd-Left": onArrowLeft,
    386          "Alt-Left": onArrowLeft,
    387          // On OSX, Ctrl-A navigates to the beginning of the line.
    388          "Ctrl-A": isMacOS ? onArrowLeft : undefined,
    389 
    390          Right: onArrowRight,
    391          "Ctrl-Right": onArrowRight,
    392          "Cmd-Right": onArrowRight,
    393          "Alt-Right": onArrowRight,
    394 
    395          "Ctrl-N": () => {
    396            // Control-N differs from down arrow: it ignores autocomplete state.
    397            // Note that we preserve the default 'down' navigation within
    398            // multiline text.
    399            if (
    400              Services.appinfo.OS === "Darwin" &&
    401              this.props.editorMode === false &&
    402              this.canCaretGoNext() &&
    403              this.historyPeruse(HISTORY_FORWARD)
    404            ) {
    405              return null;
    406            }
    407 
    408            this.clearCompletion();
    409            return "CodeMirror.Pass";
    410          },
    411 
    412          "Ctrl-P": () => {
    413            // Control-P differs from up arrow: it ignores autocomplete state.
    414            // Note that we preserve the default 'up' navigation within
    415            // multiline text.
    416            if (
    417              Services.appinfo.OS === "Darwin" &&
    418              this.props.editorMode === false &&
    419              this.canCaretGoPrevious() &&
    420              this.historyPeruse(HISTORY_BACK)
    421            ) {
    422              return null;
    423            }
    424 
    425            this.clearCompletion();
    426            return "CodeMirror.Pass";
    427          },
    428 
    429          PageUp: () => {
    430            if (this.autocompletePopup.isOpen) {
    431              this.autocompletePopup.selectPreviousPageItem();
    432            } else {
    433              const { outputScroller } = this.webConsoleUI;
    434              const { scrollTop, clientHeight } = outputScroller;
    435              outputScroller.scrollTop = Math.max(0, scrollTop - clientHeight);
    436            }
    437 
    438            return null;
    439          },
    440 
    441          PageDown: () => {
    442            if (this.autocompletePopup.isOpen) {
    443              this.autocompletePopup.selectNextPageItem();
    444            } else {
    445              const { outputScroller } = this.webConsoleUI;
    446              const { scrollTop, scrollHeight, clientHeight } = outputScroller;
    447              outputScroller.scrollTop = Math.min(
    448                scrollHeight,
    449                scrollTop + clientHeight
    450              );
    451            }
    452 
    453            return null;
    454          },
    455 
    456          Home: () => {
    457            if (this.autocompletePopup.isOpen) {
    458              this.autocompletePopup.selectItemAtIndex(0);
    459              return null;
    460            }
    461 
    462            if (!this._getValue()) {
    463              this.webConsoleUI.outputScroller.scrollTop = 0;
    464              return null;
    465            }
    466 
    467            if (this.getAutoCompletionText()) {
    468              this.clearCompletion();
    469            }
    470 
    471            return "CodeMirror.Pass";
    472          },
    473 
    474          End: () => {
    475            if (this.autocompletePopup.isOpen) {
    476              this.autocompletePopup.selectItemAtIndex(
    477                this.autocompletePopup.itemCount - 1
    478              );
    479              return null;
    480            }
    481 
    482            if (!this._getValue()) {
    483              const { outputScroller } = this.webConsoleUI;
    484              outputScroller.scrollTop = outputScroller.scrollHeight;
    485              return null;
    486            }
    487 
    488            if (this.getAutoCompletionText()) {
    489              this.clearCompletion();
    490            }
    491 
    492            return "CodeMirror.Pass";
    493          },
    494 
    495          "Ctrl-Space": () => {
    496            if (!this.autocompletePopup.isOpen) {
    497              this.props.autocompleteUpdate(
    498                true,
    499                null,
    500                this._getExpressionVariables()
    501              );
    502              return null;
    503            }
    504 
    505            return "CodeMirror.Pass";
    506          },
    507 
    508          Esc: false,
    509          // Don't handle Ctrl/Cmd + F so it can be listened by a parent node
    510          [Editor.accel("F")]: false,
    511        },
    512      });
    513 
    514      this.editor.on("changes", this._onEditorChanges);
    515      this.editor.on("beforeChange", this._onEditorBeforeChange);
    516      this.editor.on("blur", this._onEditorBlur);
    517      this.editor.on("keyHandled", this._onEditorKeyHandled);
    518 
    519      this.editor.appendToLocalElement(this.node);
    520      const cm = this.editor.codeMirror;
    521      cm.on("paste", (_, event) => this.props.onPaste(event));
    522      cm.on("drop", (_, event) => this.props.onPaste(event));
    523 
    524      this.#abortController = new AbortController();
    525      const signal = this.#abortController.signal;
    526      doc.addEventListener(
    527        "visibilitychange",
    528        () => {
    529          if (
    530            doc.visibilityState == "hidden" &&
    531            this.autocompletePopup.isOpen
    532          ) {
    533            this.autocompletePopup.hidePopup();
    534          }
    535        },
    536        { signal }
    537      );
    538      this.node.addEventListener(
    539        "keydown",
    540        event => {
    541          if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) {
    542            if (this.autocompletePopup.isOpen) {
    543              this.clearCompletion();
    544              event.preventDefault();
    545              event.stopPropagation();
    546            }
    547 
    548            if (
    549              this.props.autocompleteData &&
    550              this.props.autocompleteData.getterPath
    551            ) {
    552              this.props.autocompleteClear();
    553              event.preventDefault();
    554              event.stopPropagation();
    555            }
    556          }
    557        },
    558        { signal }
    559      );
    560 
    561      this.resizeObserver = new ResizeObserver(() => {
    562        // If we don't have the node reference, or if the node isn't connected
    563        // anymore, we disconnect the resize observer (componentWillUnmount is never
    564        // called on this component, so we have to do it here).
    565        if (!this.node || !this.node.isConnected) {
    566          this.resizeObserver.disconnect();
    567          return;
    568        }
    569        // Calling `refresh` will update the cursor position, and all the selection blocks.
    570        this.editor.codeMirror.refresh();
    571      });
    572      this.resizeObserver.observe(this.node);
    573 
    574      // Update the character width needed for the popup offset calculations.
    575      this._inputCharWidth = this._getInputCharWidth();
    576      this.lastInputValue && this._setValue(this.lastInputValue);
    577    }
    578  }
    579 
    580  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
    581  UNSAFE_componentWillReceiveProps(nextProps) {
    582    this.imperativeUpdate(nextProps);
    583  }
    584 
    585  shouldComponentUpdate(nextProps) {
    586    return (
    587      this.props.showEditorOnboarding !== nextProps.showEditorOnboarding ||
    588      this.props.editorMode !== nextProps.editorMode
    589    );
    590  }
    591 
    592  // AbortController to cancel all event listener on destroy.
    593  #abortController = null;
    594 
    595  /**
    596   * Do all the imperative work needed after a Redux store update.
    597   *
    598   * @param {object} nextProps: props passed from shouldComponentUpdate.
    599   */
    600  imperativeUpdate(nextProps) {
    601    if (!nextProps) {
    602      return;
    603    }
    604 
    605    if (
    606      nextProps.autocompleteData !== this.props.autocompleteData &&
    607      nextProps.autocompleteData.pendingRequestId === null
    608    ) {
    609      this.updateAutocompletionPopup(nextProps.autocompleteData);
    610    }
    611 
    612    if (nextProps.editorMode !== this.props.editorMode) {
    613      if (this.editor) {
    614        this.editor.setOption("lineNumbers", nextProps.editorMode);
    615        this.editor.setOption("enableCodeFolding", nextProps.editorMode);
    616      }
    617 
    618      if (nextProps.editorMode && nextProps.editorWidth) {
    619        this.setEditorWidth(nextProps.editorWidth);
    620      } else {
    621        this.setEditorWidth(null);
    622      }
    623 
    624      if (this.autocompletePopup.isOpen) {
    625        this.autocompletePopup.hidePopup();
    626      }
    627    }
    628 
    629    if (
    630      nextProps.autocompletePopupPosition !==
    631        this.props.autocompletePopupPosition &&
    632      this.autocompletePopup
    633    ) {
    634      this.autocompletePopup.position = nextProps.autocompletePopupPosition;
    635    }
    636 
    637    if (
    638      nextProps.editorPrettifiedAt &&
    639      nextProps.editorPrettifiedAt !== this.props.editorPrettifiedAt
    640    ) {
    641      this._setValue(
    642        beautify.js(this._getValue(), {
    643          // Read directly from prefs because this.editor.config.indentUnit and
    644          // this.editor.getOption('indentUnit') are not really synced with
    645          // prefs.
    646          indent_size: Services.prefs.getIntPref("devtools.editor.tabsize"),
    647          indent_with_tabs: !Services.prefs.getBoolPref(
    648            "devtools.editor.expandtab"
    649          ),
    650        })
    651      );
    652    }
    653  }
    654 
    655  /**
    656   *
    657   * @param {number | null} editorWidth: The width to set the node to. If null, removes any
    658   *                                   `width` property on node style.
    659   */
    660  setEditorWidth(editorWidth) {
    661    if (!this.node) {
    662      return;
    663    }
    664 
    665    if (editorWidth) {
    666      this.node.style.width = `${editorWidth}px`;
    667    } else {
    668      this.node.style.removeProperty("width");
    669    }
    670  }
    671 
    672  focus() {
    673    if (this.editor) {
    674      this.editor.focus();
    675    }
    676  }
    677 
    678  focusPreviousElement() {
    679    const inputField = this.editor.codeMirror.getInputField();
    680 
    681    const findPreviousFocusableElement = el => {
    682      if (!el || !el.querySelectorAll) {
    683        return null;
    684      }
    685 
    686      // We only want to get visible focusable element, and for that we can assert that
    687      // the offsetParent isn't null. We can do that because we don't have fixed position
    688      // element in the console.
    689      const items = lazy
    690        .getFocusableElements(el)
    691        .filter(({ offsetParent }) => offsetParent !== null);
    692      const inputIndex = items.indexOf(inputField);
    693 
    694      if (items.length === 0 || (inputIndex > -1 && items.length === 1)) {
    695        return findPreviousFocusableElement(el.parentNode);
    696      }
    697 
    698      const index = inputIndex > 0 ? inputIndex - 1 : items.length - 1;
    699      return items[index];
    700    };
    701 
    702    const focusableEl = findPreviousFocusableElement(this.node.parentNode);
    703    if (focusableEl) {
    704      focusableEl.focus();
    705    }
    706  }
    707 
    708  /**
    709   * Execute a string. Execution happens asynchronously in the content process.
    710   */
    711  _execute() {
    712    const value = this._getValue();
    713    // In editor mode, we only evaluate the text selection if there's one. The feature isn't
    714    // enabled in inline mode as it can be confusing since input is cleared when evaluating.
    715    const executeString = this.props.editorMode
    716      ? this.getSelectedText() || value
    717      : value;
    718 
    719    if (!executeString) {
    720      return;
    721    }
    722 
    723    if (!this.props.editorMode) {
    724      // Calling this.props.terminalInputChanged instead of this.terminalInputChanged
    725      // because we want to instantly hide the instant evaluation result, and don't want
    726      // the delay we have in this.terminalInputChanged.
    727      this.props.terminalInputChanged("");
    728      this._setValue("");
    729    }
    730    this.clearCompletion();
    731    this.props.evaluateExpression(executeString);
    732  }
    733 
    734  /**
    735   * Sets the value of the input field.
    736   *
    737   * @param string newValue
    738   *        The new value to set.
    739   * @returns void
    740   */
    741  _setValue(newValue = "") {
    742    this.lastInputValue = newValue;
    743    this.terminalInputChanged(newValue);
    744 
    745    if (this.editor) {
    746      // In order to get the autocomplete popup to work properly, we need to set the
    747      // editor text and the cursor in the same operation. If we don't, the text change
    748      // is done before the cursor is moved, and the autocompletion call to the server
    749      // sends an erroneous query.
    750      this.editor.codeMirror.operation(() => {
    751        this.editor.setText(newValue);
    752 
    753        // Set the cursor at the end of the input.
    754        const lines = newValue.split("\n");
    755        this.editor.setCursor({
    756          line: lines.length - 1,
    757          ch: lines[lines.length - 1].length,
    758        });
    759        this.editor.setAutoCompletionText();
    760      });
    761    }
    762 
    763    this.emitForTests("set-input-value");
    764  }
    765 
    766  /**
    767   * Gets the value from the input field
    768   *
    769   * @returns string
    770   */
    771  _getValue() {
    772    return this.editor ? this.editor.getText() || "" : "";
    773  }
    774 
    775  /**
    776   * Open the file picker for the user to select a javascript file and open it.
    777   *
    778   */
    779  async _openFile() {
    780    const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    781    fp.init(
    782      this.webConsoleUI.document.defaultView.browsingContext,
    783      l10n.getStr("webconsole.input.openJavaScriptFile"),
    784      Ci.nsIFilePicker.modeOpen
    785    );
    786 
    787    // Append file filters
    788    fp.appendFilter(
    789      l10n.getStr("webconsole.input.openJavaScriptFileFilter"),
    790      "*.js"
    791    );
    792 
    793    function readFile(file) {
    794      return new Promise(resolve => {
    795        IOUtils.read(file.path).then(data => {
    796          const decoder = new TextDecoder();
    797          resolve(decoder.decode(data));
    798        });
    799      });
    800    }
    801 
    802    const content = await new Promise(resolve => {
    803      fp.open(rv => {
    804        if (rv == Ci.nsIFilePicker.returnOK) {
    805          const file = Cc["@mozilla.org/file/local;1"].createInstance(
    806            Ci.nsIFile
    807          );
    808          file.initWithPath(fp.file.path);
    809          readFile(file).then(resolve);
    810        }
    811      });
    812    });
    813 
    814    this._setValue(content);
    815  }
    816 
    817  getSelectionStart() {
    818    return this.getInputValueBeforeCursor().length;
    819  }
    820 
    821  getSelectedText() {
    822    return this.editor.getSelection();
    823  }
    824 
    825  /**
    826   * Even handler for the "beforeChange" event fired by codeMirror. This event is fired
    827   * when codeMirror is about to make a change to its DOM representation.
    828   */
    829  _onEditorBeforeChange(cm, change) {
    830    // If the user did not type a character that matches the completion text, then we
    831    // clear it before the change is done to prevent a visual glitch.
    832    // See Bugs 1491776 & 1558248.
    833    const { from, to, origin, text } = change;
    834    const isAddedText =
    835      from.line === to.line && from.ch === to.ch && origin === "+input";
    836 
    837    // if there was no changes (hitting delete on an empty input, or suppr when at the end
    838    // of the input), we bail out.
    839    if (
    840      !isAddedText &&
    841      origin === "+delete" &&
    842      from.line === to.line &&
    843      from.ch === to.ch
    844    ) {
    845      return;
    846    }
    847 
    848    const addedText = text.join("");
    849    const completionText = this.getAutoCompletionText();
    850 
    851    const addedCharacterMatchCompletion =
    852      isAddedText && completionText.startsWith(addedText);
    853 
    854    const addedCharacterMatchPopupItem =
    855      isAddedText &&
    856      this.autocompletePopup.items.some(({ preLabel, label }) =>
    857        label.startsWith(preLabel + addedText)
    858      );
    859    const nextSelectedAutocompleteItemIndex =
    860      addedCharacterMatchPopupItem &&
    861      this.autocompletePopup.items.findIndex(({ preLabel, label }) =>
    862        label.startsWith(preLabel + addedText)
    863      );
    864 
    865    if (addedCharacterMatchPopupItem) {
    866      this.autocompletePopup.selectItemAtIndex(
    867        nextSelectedAutocompleteItemIndex,
    868        { preventSelectCallback: true }
    869      );
    870    }
    871 
    872    if (!completionText || change.canceled || !addedCharacterMatchCompletion) {
    873      this.setAutoCompletionText("");
    874    }
    875 
    876    if (!addedCharacterMatchCompletion && !addedCharacterMatchPopupItem) {
    877      this.autocompletePopup.hidePopup();
    878    } else if (
    879      !change.canceled &&
    880      (completionText ||
    881        addedCharacterMatchCompletion ||
    882        addedCharacterMatchPopupItem)
    883    ) {
    884      // The completion text will be updated when the debounced autocomplete update action
    885      // is done, so in the meantime we set the pending value to pendingCompletionText.
    886      // See Bug 1595068 for more information.
    887      this.pendingCompletionText = completionText.substring(text.length);
    888      // And we update the preLabel of the matching autocomplete items that may be used
    889      // in the acceptProposedAutocompletion function.
    890      this.autocompletePopup.items.forEach(item => {
    891        if (item.label.startsWith(item.preLabel + addedText)) {
    892          item.preLabel += addedText;
    893        }
    894      });
    895    }
    896  }
    897 
    898  /**
    899   * Even handler for the "blur" event fired by codeMirror.
    900   */
    901  _onEditorBlur(cm) {
    902    if (cm.somethingSelected()) {
    903      // If there's a selection when the input is blurred, then we remove it by setting
    904      // the cursor at the position that matches the start of the first selection.
    905      const [{ head }] = cm.listSelections();
    906      cm.setCursor(head, { scroll: false });
    907    }
    908  }
    909 
    910  /**
    911   * Fired after a key is handled through a key map.
    912   *
    913   * @param {CodeMirror} cm: codeMirror instance
    914   * @param {string} key: The key that was handled
    915   */
    916  _onEditorKeyHandled(cm, key) {
    917    // The autocloseBracket addon handle closing brackets keys when they're typed, but
    918    // there's already an existing closing bracket.
    919    // ex:
    920    //  1. input is `foo(x|)` (where | represents the cursor)
    921    //  2. user types `)`
    922    //  3. input is now `foo(x)|` (i.e. the typed character wasn't inserted)
    923    // In such case, _onEditorBeforeChange isn't triggered, so we need to hide the popup
    924    // here. We can do that because this function won't be called when codeMirror _do_
    925    // insert the closing char.
    926    const closingKeys = [`']'`, `')'`, "'}'"];
    927    if (this.autocompletePopup.isOpen && closingKeys.includes(key)) {
    928      this.clearCompletion();
    929    }
    930  }
    931 
    932  /**
    933   * Retrieve variable declared in the expression from the CodeMirror state, in order
    934   * to display them in the autocomplete popup.
    935   */
    936  _getExpressionVariables() {
    937    const cm = this.editor.codeMirror;
    938    const { state } = cm.getTokenAt(cm.getCursor());
    939    const variables = [];
    940 
    941    if (state.context) {
    942      for (let c = state.context; c; c = c.prev) {
    943        for (let v = c.vars; v; v = v.next) {
    944          if (v.name) {
    945            variables.push(v.name);
    946          }
    947        }
    948      }
    949    }
    950 
    951    const keys = ["localVars", "globalVars"];
    952    for (const key of keys) {
    953      if (state[key]) {
    954        for (let v = state[key]; v; v = v.next) {
    955          if (v.name) {
    956            variables.push(v.name);
    957          }
    958        }
    959      }
    960    }
    961 
    962    return variables;
    963  }
    964 
    965  /**
    966   * The editor "changes" event handler.
    967   */
    968  _onEditorChanges(cm, changes) {
    969    const value = this._getValue();
    970 
    971    if (this.lastInputValue !== value) {
    972      // We don't autocomplete if the changes were made by JsTerm (e.g. autocomplete was
    973      // accepted).
    974      const isJsTermChangeOnly = changes.every(
    975        ({ origin }) => origin === JSTERM_CODEMIRROR_ORIGIN
    976      );
    977 
    978      if (
    979        !isJsTermChangeOnly &&
    980        (this.props.autocomplete || this.hasAutocompletionSuggestion())
    981      ) {
    982        this.autocompleteUpdate(false, null, this._getExpressionVariables());
    983      }
    984      this.lastInputValue = value;
    985      this.terminalInputChanged(value);
    986    }
    987  }
    988 
    989  /**
    990   * Go up/down the history stack of input values.
    991   *
    992   * @param number direction
    993   *        History navigation direction: HISTORY_BACK or HISTORY_FORWARD.
    994   *
    995   * @returns boolean
    996   *          True if the input value changed, false otherwise.
    997   */
    998  historyPeruse(direction) {
    999    const { history, updateHistoryPosition, getValueFromHistory } = this.props;
   1000 
   1001    if (!history.entries.length) {
   1002      return false;
   1003    }
   1004 
   1005    const newInputValue = getValueFromHistory(direction);
   1006    const expression = this._getValue();
   1007    updateHistoryPosition(direction, expression);
   1008 
   1009    if (newInputValue != null) {
   1010      this._setValue(newInputValue);
   1011      return true;
   1012    }
   1013 
   1014    return false;
   1015  }
   1016 
   1017  /**
   1018   * Test for empty input.
   1019   *
   1020   * @return boolean
   1021   */
   1022  hasEmptyInput() {
   1023    return this._getValue() === "";
   1024  }
   1025 
   1026  /**
   1027   * Check if the caret is at a location that allows selecting the previous item
   1028   * in history when the user presses the Up arrow key.
   1029   *
   1030   * @return boolean
   1031   *         True if the caret is at a location that allows selecting the
   1032   *         previous item in history when the user presses the Up arrow key,
   1033   *         otherwise false.
   1034   */
   1035  canCaretGoPrevious() {
   1036    if (!this.editor) {
   1037      return false;
   1038    }
   1039 
   1040    const inputValue = this._getValue();
   1041    const { line, ch } = this.editor.getCursor();
   1042    return (line === 0 && ch === 0) || (line === 0 && ch === inputValue.length);
   1043  }
   1044 
   1045  /**
   1046   * Check if the caret is at a location that allows selecting the next item in
   1047   * history when the user presses the Down arrow key.
   1048   *
   1049   * @return boolean
   1050   *         True if the caret is at a location that allows selecting the next
   1051   *         item in history when the user presses the Down arrow key, otherwise
   1052   *         false.
   1053   */
   1054  canCaretGoNext() {
   1055    if (!this.editor) {
   1056      return false;
   1057    }
   1058 
   1059    const inputValue = this._getValue();
   1060    const multiline = /[\r\n]/.test(inputValue);
   1061 
   1062    const { line, ch } = this.editor.getCursor();
   1063    return (
   1064      (!multiline && ch === 0) ||
   1065      this.editor.getDoc().getRange({ line: 0, ch: 0 }, { line, ch }).length ===
   1066        inputValue.length
   1067    );
   1068  }
   1069 
   1070  /**
   1071   * Takes the data returned by the server and update the autocomplete popup state (i.e.
   1072   * its visibility and items).
   1073   *
   1074   * @param {object} data
   1075   *        The autocompletion data as returned by the webconsole actor's autocomplete
   1076   *        service. Should be of the following shape:
   1077   *        {
   1078   *          matches: {Array} array of the properties matching the input,
   1079   *          matchProp: {String} The string used to filter the properties,
   1080   *          isElementAccess: {Boolean} True when the input is an element access,
   1081   *                           i.e. `document["addEve`.
   1082   *        }
   1083   * @fires autocomplete-updated
   1084   */
   1085  async updateAutocompletionPopup(data) {
   1086    if (!this.editor) {
   1087      return;
   1088    }
   1089 
   1090    const { matches, matchProp, isElementAccess } = data;
   1091    if (!matches.length) {
   1092      this.clearCompletion();
   1093      return;
   1094    }
   1095 
   1096    const inputUntilCursor = this.getInputValueBeforeCursor();
   1097 
   1098    const items = matches.map(label => {
   1099      let preLabel = label.substring(0, matchProp.length);
   1100      // If the user is performing an element access, and if they did not typed a quote,
   1101      // then we need to adjust the preLabel to match the quote from the label + what
   1102      // the user entered.
   1103      if (isElementAccess && /^['"`]/.test(matchProp) === false) {
   1104        preLabel = label.substring(0, matchProp.length + 1);
   1105      }
   1106      return { preLabel, label, isElementAccess };
   1107    });
   1108 
   1109    if (items.length) {
   1110      const { preLabel, label } = items[0];
   1111      let suffix = label.substring(preLabel.length);
   1112      if (isElementAccess) {
   1113        if (!matchProp) {
   1114          suffix = label;
   1115        }
   1116        const inputAfterCursor = this._getValue().substring(
   1117          inputUntilCursor.length
   1118        );
   1119        // If there's not a bracket after the cursor, add it to the completionText.
   1120        if (!inputAfterCursor.trimLeft().startsWith("]")) {
   1121          suffix = suffix + "]";
   1122        }
   1123      }
   1124      this.setAutoCompletionText(suffix);
   1125    }
   1126 
   1127    const popup = this.autocompletePopup;
   1128    // We don't want to trigger the onSelect callback since we already set the completion
   1129    // text a few lines above.
   1130    popup.setItems(items, 0, {
   1131      preventSelectCallback: true,
   1132    });
   1133 
   1134    const minimumAutoCompleteLength = 2;
   1135 
   1136    // We want to show the autocomplete popup if:
   1137    // - there are at least 2 matching results
   1138    // - OR, if there's 1 result, but whose label does not start like the input (this can
   1139    //   happen with insensitive search: `num` will match `Number`).
   1140    // - OR, if there's 1 result, but we can't show the completionText (because there's
   1141    // some text after the cursor), unless the text in the popup is the same as the input.
   1142    if (
   1143      items.length >= minimumAutoCompleteLength ||
   1144      (items.length === 1 && items[0].preLabel !== matchProp) ||
   1145      (items.length === 1 &&
   1146        !this.canDisplayAutoCompletionText() &&
   1147        items[0].label !== matchProp)
   1148    ) {
   1149      // We need to show the popup at the "." or "[".
   1150      const xOffset = -1 * matchProp.length * this._inputCharWidth;
   1151      const yOffset = 5;
   1152      const popupAlignElement =
   1153        this.props.serviceContainer.getJsTermTooltipAnchor();
   1154      this._openPopupPendingPromise = popup.openPopup(
   1155        popupAlignElement,
   1156        xOffset,
   1157        yOffset,
   1158        0,
   1159        {
   1160          preventSelectCallback: true,
   1161        }
   1162      );
   1163      await this._openPopupPendingPromise;
   1164      this._openPopupPendingPromise = null;
   1165    } else if (
   1166      items.length < minimumAutoCompleteLength &&
   1167      (popup.isOpen || this._openPopupPendingPromise)
   1168    ) {
   1169      if (this._openPopupPendingPromise) {
   1170        await this._openPopupPendingPromise;
   1171      }
   1172      popup.hidePopup();
   1173    }
   1174 
   1175    // Eager evaluation results incorporate the current autocomplete item. We need to
   1176    // trigger it here as well as in onAutocompleteSelect as we set the items with
   1177    // preventSelectCallback (which means we won't trigger onAutocompleteSelect when the
   1178    // popup is open).
   1179    this.terminalInputChanged(
   1180      this.getInputValueWithCompletionText().expression
   1181    );
   1182 
   1183    this.emit("autocomplete-updated");
   1184  }
   1185 
   1186  onAutocompleteSelect() {
   1187    const { selectedItem } = this.autocompletePopup;
   1188    if (selectedItem) {
   1189      const { preLabel, label, isElementAccess } = selectedItem;
   1190      let suffix = label.substring(preLabel.length);
   1191 
   1192      // If the user is performing an element access, we need to check if we should add
   1193      // starting and ending quotes, as well as a closing bracket.
   1194      if (isElementAccess) {
   1195        const inputBeforeCursor = this.getInputValueBeforeCursor();
   1196        if (inputBeforeCursor.trim().endsWith("[")) {
   1197          suffix = label;
   1198        }
   1199 
   1200        const inputAfterCursor = this._getValue().substring(
   1201          inputBeforeCursor.length
   1202        );
   1203        // If there's no closing bracket after the cursor, add it to the completionText.
   1204        if (!inputAfterCursor.trimLeft().startsWith("]")) {
   1205          suffix = suffix + "]";
   1206        }
   1207      }
   1208      this.setAutoCompletionText(suffix);
   1209    } else {
   1210      this.setAutoCompletionText("");
   1211    }
   1212    // Eager evaluation results incorporate the current autocomplete item.
   1213    this.terminalInputChanged(
   1214      this.getInputValueWithCompletionText().expression
   1215    );
   1216  }
   1217 
   1218  /**
   1219   * Clear the current completion information, cancel any pending autocompletion update
   1220   * and close the autocomplete popup, if needed.
   1221   *
   1222   * @fires autocomplete-updated
   1223   */
   1224  clearCompletion() {
   1225    this.autocompleteUpdate.cancel();
   1226    // Update Eager evaluation result as the completion text was removed.
   1227    this.terminalInputChanged(this._getValue());
   1228 
   1229    this.setAutoCompletionText("");
   1230    let onPopupClosed = Promise.resolve();
   1231    if (this.autocompletePopup) {
   1232      this.autocompletePopup.clearItems();
   1233 
   1234      if (this.autocompletePopup.isOpen || this._openPopupPendingPromise) {
   1235        onPopupClosed = this.autocompletePopup.once("popup-closed");
   1236 
   1237        if (this._openPopupPendingPromise) {
   1238          this._openPopupPendingPromise.then(() =>
   1239            this.autocompletePopup.hidePopup()
   1240          );
   1241        } else {
   1242          this.autocompletePopup.hidePopup();
   1243        }
   1244        onPopupClosed.then(() => this.focus());
   1245      }
   1246    }
   1247    onPopupClosed.then(() => this.emit("autocomplete-updated"));
   1248  }
   1249 
   1250  /**
   1251   * Accept the proposed input completion.
   1252   */
   1253  acceptProposedCompletion() {
   1254    const {
   1255      completionText,
   1256      numberOfCharsToMoveTheCursorForward,
   1257      numberOfCharsToReplaceCharsBeforeCursor,
   1258    } = this.getInputValueWithCompletionText();
   1259 
   1260    this.autocompleteUpdate.cancel();
   1261    this.props.autocompleteClear();
   1262 
   1263    // If the code triggering the opening of the popup was already triggered but not yet
   1264    // settled, then we need to wait until it's resolved in order to close the popup (See
   1265    // Bug 1655406).
   1266    if (this._openPopupPendingPromise) {
   1267      this._openPopupPendingPromise.then(() =>
   1268        this.autocompletePopup.hidePopup()
   1269      );
   1270    }
   1271 
   1272    if (completionText) {
   1273      this.insertStringAtCursor(
   1274        completionText,
   1275        numberOfCharsToReplaceCharsBeforeCursor
   1276      );
   1277 
   1278      if (numberOfCharsToMoveTheCursorForward) {
   1279        const { line, ch } = this.editor.getCursor();
   1280        this.editor.setCursor({
   1281          line,
   1282          ch: ch + numberOfCharsToMoveTheCursorForward,
   1283        });
   1284      }
   1285    }
   1286  }
   1287 
   1288  /**
   1289   * Returns an object containing the expression we would get if the user accepted the
   1290   * current completion text. This is more than the current input + the completion text,
   1291   * as there are special cases for element access and case-insensitive matches.
   1292   *
   1293   * @return {object}: An object of the following shape:
   1294   *         - {String} expression: The complete expression
   1295   *         - {String} completionText: the completion text only, which should be used
   1296   *                    with the next property
   1297   *         - {Integer} numberOfCharsToReplaceCharsBeforeCursor: The number of chars that
   1298   *                     should be removed from the current input before the cursor to
   1299   *                     cleanly apply the completionText. This is handy when we only want
   1300   *                     to insert the completionText.
   1301   *         - {Integer} numberOfCharsToMoveTheCursorForward: The number of chars that the
   1302   *                     cursor should be moved after the completion is done. This can
   1303   *                     be useful for element access where there's already a closing
   1304   *                     quote and/or bracket.
   1305   */
   1306  getInputValueWithCompletionText() {
   1307    const inputBeforeCursor = this.getInputValueBeforeCursor();
   1308    const inputAfterCursor = this._getValue().substring(
   1309      inputBeforeCursor.length
   1310    );
   1311    let completionText = this.getAutoCompletionText();
   1312    let numberOfCharsToReplaceCharsBeforeCursor;
   1313    let numberOfCharsToMoveTheCursorForward = 0;
   1314 
   1315    // If the autocompletion popup is open, we always get the selected element from there,
   1316    // since the autocompletion text might not be enough (e.g. `dOcUmEn` should
   1317    // autocomplete to `document`, but the autocompletion text only shows `t`).
   1318    if (this.autocompletePopup.isOpen && this.autocompletePopup.selectedItem) {
   1319      const { selectedItem } = this.autocompletePopup;
   1320      const { label, preLabel, isElementAccess } = selectedItem;
   1321 
   1322      completionText = label;
   1323      numberOfCharsToReplaceCharsBeforeCursor = preLabel.length;
   1324 
   1325      // If the user is performing an element access, we need to check if we should add
   1326      // starting and ending quotes, as well as a closing bracket.
   1327      if (isElementAccess) {
   1328        const lastOpeningBracketIndex = inputBeforeCursor.lastIndexOf("[");
   1329        if (lastOpeningBracketIndex > -1) {
   1330          numberOfCharsToReplaceCharsBeforeCursor = inputBeforeCursor.substring(
   1331            lastOpeningBracketIndex + 1
   1332          ).length;
   1333        }
   1334 
   1335        // If the autoclose bracket option is enabled, the input might be in a state where
   1336        // there's already the closing quote and the closing bracket, e.g.
   1337        // `document["activeEl|"]`, so we don't need to add
   1338        // Let's retrieve the completionText last character, to see if it's a quote.
   1339        const completionTextLastChar =
   1340          completionText[completionText.length - 1];
   1341        const endingQuote = [`"`, `'`, "`"].includes(completionTextLastChar)
   1342          ? completionTextLastChar
   1343          : "";
   1344        if (
   1345          endingQuote &&
   1346          inputAfterCursor.trimLeft().startsWith(endingQuote)
   1347        ) {
   1348          completionText = completionText.substring(
   1349            0,
   1350            completionText.length - 1
   1351          );
   1352          numberOfCharsToMoveTheCursorForward++;
   1353        }
   1354 
   1355        // If there's not a closing bracket already, we add one.
   1356        if (
   1357          !inputAfterCursor.trimLeft().match(new RegExp(`^${endingQuote}?]`))
   1358        ) {
   1359          completionText = completionText + "]";
   1360        } else {
   1361          // if there's already one, we want to move the cursor after the closing bracket.
   1362          numberOfCharsToMoveTheCursorForward++;
   1363        }
   1364      }
   1365    }
   1366 
   1367    const expression =
   1368      inputBeforeCursor.substring(
   1369        0,
   1370        inputBeforeCursor.length -
   1371          (numberOfCharsToReplaceCharsBeforeCursor || 0)
   1372      ) +
   1373      completionText +
   1374      inputAfterCursor;
   1375 
   1376    return {
   1377      completionText,
   1378      expression,
   1379      numberOfCharsToMoveTheCursorForward,
   1380      numberOfCharsToReplaceCharsBeforeCursor,
   1381    };
   1382  }
   1383 
   1384  getInputValueBeforeCursor() {
   1385    return this.editor
   1386      ? this.editor
   1387          .getDoc()
   1388          .getRange({ line: 0, ch: 0 }, this.editor.getCursor())
   1389      : null;
   1390  }
   1391 
   1392  /**
   1393   * Insert a string into the console at the cursor location,
   1394   * moving the cursor to the end of the string.
   1395   *
   1396   * @param {string} str
   1397   * @param {int} numberOfCharsToReplaceCharsBeforeCursor - defaults to 0
   1398   */
   1399  insertStringAtCursor(str, numberOfCharsToReplaceCharsBeforeCursor = 0) {
   1400    if (!this.editor) {
   1401      return;
   1402    }
   1403 
   1404    const cursor = this.editor.getCursor();
   1405    const from = {
   1406      line: cursor.line,
   1407      ch: cursor.ch - numberOfCharsToReplaceCharsBeforeCursor,
   1408    };
   1409 
   1410    this.editor
   1411      .getDoc()
   1412      .replaceRange(str, from, cursor, JSTERM_CODEMIRROR_ORIGIN);
   1413  }
   1414 
   1415  /**
   1416   * Set the autocompletion text of the input.
   1417   *
   1418   * @param string suffix
   1419   *        The proposed suffix for the input value.
   1420   */
   1421  setAutoCompletionText(suffix) {
   1422    if (!this.editor) {
   1423      return;
   1424    }
   1425 
   1426    this.pendingCompletionText = null;
   1427 
   1428    if (suffix && !this.canDisplayAutoCompletionText()) {
   1429      suffix = "";
   1430    }
   1431 
   1432    this.editor.setAutoCompletionText(suffix);
   1433  }
   1434 
   1435  getAutoCompletionText() {
   1436    const renderedCompletionText =
   1437      this.editor && this.editor.getAutoCompletionText();
   1438    return typeof this.pendingCompletionText === "string"
   1439      ? this.pendingCompletionText
   1440      : renderedCompletionText;
   1441  }
   1442 
   1443  /**
   1444   * Indicate if the input has an autocompletion suggestion, i.e. that there is either
   1445   * something in the autocompletion text or that there's a selected item in the
   1446   * autocomplete popup.
   1447   */
   1448  hasAutocompletionSuggestion() {
   1449    // We can have cases where the popup is opened but we can't display the autocompletion
   1450    // text.
   1451    return (
   1452      this.getAutoCompletionText() ||
   1453      (this.autocompletePopup.isOpen &&
   1454        Number.isInteger(this.autocompletePopup.selectedIndex) &&
   1455        this.autocompletePopup.selectedIndex > -1)
   1456    );
   1457  }
   1458 
   1459  /**
   1460   * Returns a boolean indicating if we can display an autocompletion text in the input,
   1461   * i.e. if there is no characters displayed on the same line of the cursor and after it.
   1462   */
   1463  canDisplayAutoCompletionText() {
   1464    if (!this.editor) {
   1465      return false;
   1466    }
   1467 
   1468    const { ch, line } = this.editor.getCursor();
   1469    const lineContent = this.editor.getLine(line);
   1470    const textAfterCursor = lineContent.substring(ch);
   1471    return textAfterCursor === "";
   1472  }
   1473 
   1474  /**
   1475   * Calculates and returns the width of a single character of the input box.
   1476   * This will be used in opening the popup at the correct offset.
   1477   *
   1478   * @returns {number | null}: Width off the "x" char, or null if the input does not exist.
   1479   */
   1480  _getInputCharWidth() {
   1481    return this.editor ? this.editor.defaultCharWidth() : null;
   1482  }
   1483 
   1484  onContextMenu(e) {
   1485    this.props.serviceContainer.openEditContextMenu(e);
   1486  }
   1487 
   1488  destroy() {
   1489    this.autocompleteUpdate.cancel();
   1490    this.terminalInputChanged.cancel();
   1491    this._openPopupPendingPromise = null;
   1492 
   1493    if (this.autocompletePopup) {
   1494      this.autocompletePopup.destroy();
   1495      this.autocompletePopup = null;
   1496    }
   1497 
   1498    if (this.#abortController) {
   1499      this.#abortController.abort();
   1500      this.#abortController = null;
   1501    }
   1502 
   1503    if (this.editor) {
   1504      this.resizeObserver.disconnect();
   1505      this.editor.destroy();
   1506      this.editor = null;
   1507    }
   1508 
   1509    this.webConsoleUI = null;
   1510  }
   1511 
   1512  renderOpenEditorButton() {
   1513    if (this.props.editorMode) {
   1514      return null;
   1515    }
   1516 
   1517    return dom.button({
   1518      className:
   1519        "devtools-button webconsole-input-openEditorButton" +
   1520        (this.props.showEditorOnboarding ? " devtools-feature-callout" : ""),
   1521      title: l10n.getFormatStr("webconsole.input.openEditorButton.tooltip2", [
   1522        isMacOS ? "Cmd + B" : "Ctrl + B",
   1523      ]),
   1524      onClick: this.props.editorToggle,
   1525    });
   1526  }
   1527 
   1528  renderEvaluationContextSelector() {
   1529    if (this.props.editorMode) {
   1530      return null;
   1531    }
   1532 
   1533    return EvaluationContextSelector(this.props);
   1534  }
   1535 
   1536  renderEditorOnboarding() {
   1537    if (!this.props.showEditorOnboarding) {
   1538      return null;
   1539    }
   1540 
   1541    // We deliberately use getStr, and not getFormatStr, because we want keyboard
   1542    // shortcuts to be wrapped in their own span.
   1543    const label = l10n.getStr("webconsole.input.editor.onboarding.label");
   1544    let [prefix, suffix] = label.split("%1$S");
   1545    suffix = suffix.split("%2$S");
   1546 
   1547    const enterString = l10n.getStr("webconsole.enterKey");
   1548 
   1549    return dom.header(
   1550      { className: "editor-onboarding" },
   1551      dom.img({
   1552        className: "editor-onboarding-fox",
   1553        src: "chrome://devtools/skin/images/fox-smiling.svg",
   1554      }),
   1555      dom.p(
   1556        {},
   1557        prefix,
   1558        dom.span({ className: "editor-onboarding-shortcut" }, enterString),
   1559        suffix[0],
   1560        dom.span({ className: "editor-onboarding-shortcut" }, [
   1561          isMacOS ? `Cmd+${enterString}` : `Ctrl+${enterString}`,
   1562        ]),
   1563        suffix[1]
   1564      ),
   1565      dom.button(
   1566        {
   1567          className: "editor-onboarding-dismiss-button",
   1568          onClick: () => this.props.editorOnboardingDismiss(),
   1569        },
   1570        l10n.getStr("webconsole.input.editor.onboarding.dismiss.label")
   1571      )
   1572    );
   1573  }
   1574 
   1575  render() {
   1576    if (!this.props.inputEnabled) {
   1577      return null;
   1578    }
   1579 
   1580    return dom.div(
   1581      {
   1582        className: "jsterm-input-container devtools-input",
   1583        key: "jsterm-container",
   1584        "aria-live": "off",
   1585        tabIndex: -1,
   1586        onContextMenu: this.onContextMenu,
   1587        ref: node => {
   1588          this.node = node;
   1589        },
   1590      },
   1591      dom.div(
   1592        { className: "webconsole-input-buttons" },
   1593        this.renderEvaluationContextSelector(),
   1594        this.renderOpenEditorButton()
   1595      ),
   1596      this.renderEditorOnboarding()
   1597    );
   1598  }
   1599 }
   1600 
   1601 // Redux connect
   1602 
   1603 function mapStateToProps(state) {
   1604  return {
   1605    history: getHistory(state),
   1606    getValueFromHistory: direction => getHistoryValue(state, direction),
   1607    autocompleteData: getAutocompleteState(state),
   1608    showEditorOnboarding: state.ui.showEditorOnboarding,
   1609    autocompletePopupPosition: state.prefs.eagerEvaluation ? "top" : "bottom",
   1610    editorPrettifiedAt: state.ui.editorPrettifiedAt,
   1611  };
   1612 }
   1613 
   1614 function mapDispatchToProps(dispatch) {
   1615  return {
   1616    updateHistoryPosition: (direction, expression) =>
   1617      dispatch(actions.updateHistoryPosition(direction, expression)),
   1618    autocompleteUpdate: (force, getterPath, expressionVars) =>
   1619      dispatch(actions.autocompleteUpdate(force, getterPath, expressionVars)),
   1620    autocompleteClear: () => dispatch(actions.autocompleteClear()),
   1621    evaluateExpression: expression =>
   1622      dispatch(actions.evaluateExpression(expression)),
   1623    editorToggle: () => dispatch(actions.editorToggle()),
   1624    editorOnboardingDismiss: () => dispatch(actions.editorOnboardingDismiss()),
   1625    terminalInputChanged: value =>
   1626      dispatch(actions.terminalInputChanged(value)),
   1627  };
   1628 }
   1629 
   1630 module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);