Bug 1463674 - Enable autocompletion popup in codeMirror JsTerm; r=bgrins. draft
authorNicolas Chevobbe <nchevobbe@mozilla.com>
Thu, 05 Jul 2018 16:22:45 +0200
changeset 818821 bf88130eb8e92b2bf29dac5024f0dc49f727e9c7
parent 818820 d35caaf19b14d9a5cfbddaf58d20bc6c7aeb4aaa
child 818822 5b916fe9ad953ce80c058be1ea2eb8894c625c8e
push id116354
push userbmo:nchevobbe@mozilla.com
push dateMon, 16 Jul 2018 16:01:04 +0000
reviewersbgrins
bugs1463674
milestone63.0a1
Bug 1463674 - Enable autocompletion popup in codeMirror JsTerm; r=bgrins. This patch translates old key handlers to codeMirror ones. MozReview-Commit-ID: FGJehgGaBGI
devtools/client/webconsole/components/JSTerm.js
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -177,43 +177,78 @@ class JSTerm extends Component {
           enableCodeFolding: false,
           gutters: [],
           lineWrapping: true,
           mode: Editor.modes.js,
           styleActiveLine: false,
           tabIndex: "0",
           viewportMargin: Infinity,
           extraKeys: {
-            "Enter": (e, cm) => {
-              if (!this.autocompletePopup.isOpen && (
-                e.shiftKey || !Debugger.isCompilableUnit(this.getInputValue())
-              )) {
-                // shift return or incomplete statement
+            "Enter": () => {
+              // No need to handle shift + Enter as it's natively handled by CodeMirror.
+              if (
+                !this.autocompletePopup.isOpen &&
+                !Debugger.isCompilableUnit(this.getInputValue())
+              ) {
+                // incomplete statement
                 return "CodeMirror.Pass";
               }
 
+              if (this._autocompletePopupNavigated &&
+                this.autocompletePopup.isOpen &&
+                this.autocompletePopup.selectedIndex > -1
+              ) {
+                return this.acceptProposedCompletion();
+              }
+
               this.execute();
               return null;
             },
+
+            "Tab": () => {
+              // Generate a completion and accept the first proposed value.
+              if (
+                this.complete(this.COMPLETE_HINT_ONLY) &&
+                this.lastCompletion &&
+                this.acceptProposedCompletion()
+              ) {
+                return false;
+              }
+
+              if (this.hasEmptyInput()) {
+                this.editor.codeMirror.getInputField().blur();
+                return false;
+              }
+
+              if (!this.editor.somethingSelected()) {
+                this.insertStringAtCursor("\t");
+                return false;
+              }
+
+              // Input is not empty and some text is selected, let the editor handle this.
+              return true;
+            },
+
             "Up": () => {
               let inputUpdated;
               if (this.autocompletePopup.isOpen) {
                 inputUpdated = this.complete(this.COMPLETE_BACKWARD);
                 if (inputUpdated) {
                   this._autocompletePopupNavigated = true;
                 }
               } else if (this.canCaretGoPrevious()) {
                 inputUpdated = this.historyPeruse(HISTORY_BACK);
               }
 
               if (!inputUpdated) {
                 return "CodeMirror.Pass";
               }
               return null;
             },
+
             "Down": () => {
               let inputUpdated;
               if (this.autocompletePopup.isOpen) {
                 inputUpdated = this.complete(this.COMPLETE_FORWARD);
                 if (inputUpdated) {
                   this._autocompletePopupNavigated = true;
                 }
               } else if (this.canCaretGoNext()) {
@@ -221,16 +256,46 @@ class JSTerm extends Component {
               }
 
               if (!inputUpdated) {
                 return "CodeMirror.Pass";
               }
               return null;
             },
 
+            "Left": () => {
+              if (this.autocompletePopup.isOpen || this.lastCompletion.value) {
+                this.clearCompletion();
+              }
+              return "CodeMirror.Pass";
+            },
+
+            "Right": () => {
+              const haveSuggestion =
+                this.autocompletePopup.isOpen || this.lastCompletion.value;
+              const useCompletion =
+                this.canCaretGoNext() || this._autocompletePopupNavigated;
+
+              if (
+                haveSuggestion &&
+                useCompletion &&
+                this.complete(this.COMPLETE_HINT_ONLY) &&
+                this.lastCompletion.value &&
+                this.acceptProposedCompletion()
+              ) {
+                return null;
+              }
+
+              if (this.autocompletePopup.isOpen) {
+                this.clearCompletion();
+              }
+
+              return "CodeMirror.Pass";
+            },
+
             "Ctrl-N": () => {
               // Control-N differs from down arrow: it ignores autocomplete state.
               // Note that we preserve the default 'down' navigation within
               // multiline text.
               if (
                 Services.appinfo.OS === "Darwin"
                 && this.canCaretGoNext()
                 && this.historyPeruse(HISTORY_FORWARD)
@@ -238,33 +303,84 @@ class JSTerm extends Component {
                 return null;
               }
 
               this.clearCompletion();
               return "CodeMirror.Pass";
             },
 
             "Ctrl-P": () => {
-              // Control-N differs from down arrow: it ignores autocomplete state.
-              // Note that we preserve the default 'down' navigation within
+              // Control-P differs from up arrow: it ignores autocomplete state.
+              // Note that we preserve the default 'up' navigation within
               // multiline text.
               if (
                 Services.appinfo.OS === "Darwin"
                 && this.canCaretGoPrevious()
                 && this.historyPeruse(HISTORY_BACK)
               ) {
                 return null;
               }
 
               this.clearCompletion();
               return "CodeMirror.Pass";
+            },
+
+            "Esc": () => {
+              if (this.autocompletePopup.isOpen) {
+                this.clearCompletion();
+                return null;
+              }
+
+              return "CodeMirror.Pass";
+            },
+
+            "PageUp": () => {
+              if (this.autocompletePopup.isOpen) {
+                if (this.complete(this.COMPLETE_PAGEUP)) {
+                  this._autocompletePopupNavigated = true;
+                }
+                return null;
+              }
+
+              return "CodeMirror.Pass";
+            },
+
+            "PageDown": () => {
+              if (this.autocompletePopup.isOpen) {
+                if (this.complete(this.COMPLETE_PAGEDOWN)) {
+                  this._autocompletePopupNavigated = true;
+                }
+                return null;
+              }
+
+              return "CodeMirror.Pass";
+            },
+
+            "Home": () => {
+              if (this.autocompletePopup.isOpen) {
+                this.autocompletePopup.selectedIndex = 0;
+                return null;
+              }
+
+              return "CodeMirror.Pass";
+            },
+
+            "End": () => {
+              if (this.autocompletePopup.isOpen) {
+                this.autocompletePopup.selectedIndex =
+                  this.autocompletePopup.itemCount - 1;
+                return null;
+              }
+
+              return "CodeMirror.Pass";
             }
           }
         });
 
+        this.editor.on("change", this._inputEventHandler);
         this.editor.appendToLocalElement(this.node);
         const cm = this.editor.codeMirror;
         cm.on("paste", (_, event) => this.props.onPaste(event));
         cm.on("drop", (_, event) => this.props.onPaste(event));
       }
     } else if (this.inputNode) {
       this.inputNode.addEventListener("keypress", this._keyPress);
       this.inputNode.addEventListener("input", this._inputEventHandler);
@@ -571,22 +687,23 @@ class JSTerm extends Component {
    * Sets the value of the input field (command line), and resizes the field to
    * fit its contents. This method is preferred over setting "inputNode.value"
    * directly, because it correctly resizes the field.
    *
    * @param string newValue
    *        The new value to set.
    * @returns void
    */
-  setInputValue(newValue) {
+  setInputValue(newValue = "") {
     if (this.props.codeMirrorEnabled) {
       if (this.editor) {
         this.editor.setText(newValue);
         // Set the cursor at the end of the input.
         this.editor.setCursor({line: this.editor.getDoc().lineCount(), ch: 0});
+        this.editor.setAutoCompletionText();
       }
     } else {
       if (!this.inputNode) {
         return;
       }
 
       this.inputNode.value = newValue;
       this.completeNode.value = "";
@@ -598,31 +715,40 @@ class JSTerm extends Component {
   }
 
   /**
    * Gets the value from the input field
    * @returns string
    */
   getInputValue() {
     if (this.props.codeMirrorEnabled) {
-      return this.editor.getText() || "";
+      return this.editor ? this.editor.getText() || "" : "";
     }
 
     return this.inputNode ? this.inputNode.value || "" : "";
   }
 
+  getSelectionStart() {
+    if (this.props.codeMirrorEnabled) {
+      return this.getInputValueBeforeCursor().length;
+    }
+
+    return this.inputNode ? this.inputNode.selectionStart : null;
+  }
+
   /**
    * The inputNode "input" and "keyup" event handler.
    * @private
    */
   _inputEventHandler() {
-    if (this.lastInputValue != this.getInputValue()) {
+    const value = this.getInputValue();
+    if (this.lastInputValue !== value) {
       this.resizeInput();
       this.complete(this.COMPLETE_HINT_ONLY);
-      this.lastInputValue = this.getInputValue();
+      this.lastInputValue = value;
     }
   }
 
   /**
    * The window "blur" event handler.
    * @private
    */
   _blurEventHandler() {
@@ -637,17 +763,16 @@ class JSTerm extends Component {
    *
    * @private
    * @param Event event
    */
   _keyPress(event) {
     const inputNode = this.inputNode;
     const inputValue = this.getInputValue();
     let inputUpdated = false;
-
     if (event.ctrlKey) {
       switch (event.charCode) {
         case 101:
           // control-e
           if (Services.appinfo.OS == "WINNT") {
             break;
           }
           let lineEndPos = inputValue.length;
@@ -994,39 +1119,40 @@ class JSTerm extends Component {
    *          completed text.
    * @param function callback
    *        Optional function invoked when the autocomplete properties are
    *        updated.
    * @returns boolean true if there existed a completion for the current input,
    *          or false otherwise.
    */
   complete(type, callback) {
-    const inputNode = this.inputNode;
     const inputValue = this.getInputValue();
     const frameActor = this.getFrameActor(this.SELECTED_FRAME);
-
     // If the inputNode has no value, then don't try to complete on it.
     if (!inputValue) {
       this.clearCompletion();
       callback && callback(this);
       this.emit("autocomplete-updated");
       return false;
     }
 
+    const {editor, inputNode} = this;
     // Only complete if the selection is empty.
-    if (inputNode.selectionStart != inputNode.selectionEnd) {
+    if (
+      (inputNode && inputNode.selectionStart != inputNode.selectionEnd) ||
+      (editor && editor.getSelection())
+    ) {
       this.clearCompletion();
       this.callback && callback(this);
       this.emit("autocomplete-updated");
       return false;
     }
 
     // Update the completion results.
-    if (this.lastCompletion.value != inputValue ||
-        frameActor != this._lastFrameActorId) {
+    if (this.lastCompletion.value != inputValue || frameActor != this._lastFrameActorId) {
       this._updateCompletionResult(type, callback);
       return false;
     }
 
     const popup = this.autocompletePopup;
     let accepted = false;
 
     if (type != this.COMPLETE_HINT_ONLY && popup.itemCount == 1) {
@@ -1053,135 +1179,155 @@ class JSTerm extends Component {
    *
    * @private
    * @param int type
    *        Completion type. See this.complete() for details.
    * @param function [callback]
    *        Optional, function to invoke when completion results are received.
    */
   _updateCompletionResult(type, callback) {
+    const value = this.getInputValue();
     const frameActor = this.getFrameActor(this.SELECTED_FRAME);
-    if (this.lastCompletion.value == this.getInputValue() &&
-        frameActor == this._lastFrameActorId) {
+    if (this.lastCompletion.value == value && frameActor == this._lastFrameActorId) {
       return;
     }
 
     const requestId = gSequenceId();
-    const cursor = this.inputNode.selectionStart;
-    const input = this.getInputValue().substring(0, cursor);
+    const cursor = this.getSelectionStart();
+    const input = value.substring(0, cursor);
     const cache = this._autocompleteCache;
 
     // If the current input starts with the previous input, then we already
     // have a list of suggestions and we just need to filter the cached
-    // suggestions. When the current input ends with a non-alphanumeri;
+    // suggestions. When the current input ends with a non-alphanumeric
     // character we ask the server again for suggestions.
 
     // Check if last character is non-alphanumeric
     if (!/[a-zA-Z0-9]$/.test(input) || frameActor != this._lastFrameActorId) {
       this._autocompleteQuery = null;
       this._autocompleteCache = null;
     }
-
     if (this._autocompleteQuery && input.startsWith(this._autocompleteQuery)) {
       let filterBy = input;
       // Find the last non-alphanumeric other than _ or $ if it exists.
       const lastNonAlpha = input.match(/[^a-zA-Z0-9_$][a-zA-Z0-9_$]*$/);
       // If input contains non-alphanumerics, use the part after the last one
       // to filter the cache
       if (lastNonAlpha) {
         filterBy = input.substring(input.lastIndexOf(lastNonAlpha) + 1);
       }
 
-      const newList = cache.sort().filter(function(l) {
-        return l.startsWith(filterBy);
-      });
+      const newList = cache.sort().filter(l => l.startsWith(filterBy));
 
       this.lastCompletion = {
         requestId: null,
         completionType: type,
         value: null,
       };
 
       const response = { matches: newList, matchProp: filterBy };
       this._receiveAutocompleteProperties(null, callback, response);
       return;
     }
-
     this._lastFrameActorId = frameActor;
 
     this.lastCompletion = {
       requestId: requestId,
       completionType: type,
       value: null,
     };
 
     const autocompleteCallback =
       this._receiveAutocompleteProperties.bind(this, requestId, callback);
 
     this.webConsoleClient.autocomplete(
       input, cursor, autocompleteCallback, frameActor);
   }
 
+  getInputValueBeforeCursor() {
+    if (this.editor) {
+      return this.editor.getDoc().getRange({line: 0, ch: 0}, this.editor.getCursor());
+    }
+
+    if (this.inputNode) {
+      const cursor = this.inputNode.selectionStart;
+      return this.getInputValue().substring(0, cursor);
+    }
+
+    return null;
+  }
+
   /**
    * Handler for the autocompletion results. This method takes
    * the completion result received from the server and updates the UI
    * accordingly.
    *
    * @param number requestId
    *        Request ID.
    * @param function [callback=null]
    *        Optional, function to invoke when the completion result is received.
    * @param object message
    *        The JSON message which holds the completion results received from
    *        the content process.
    */
   _receiveAutocompleteProperties(requestId, callback, message) {
-    const inputNode = this.inputNode;
     const inputValue = this.getInputValue();
     if (this.lastCompletion.value == inputValue ||
         requestId != this.lastCompletion.requestId) {
       return;
     }
     // Cache whatever came from the server if the last char is
     // alphanumeric or '.'
-    const cursor = inputNode.selectionStart;
-    const inputUntilCursor = inputValue.substring(0, cursor);
+    const inputUntilCursor = this.getInputValueBeforeCursor();
 
     if (requestId != null && /[a-zA-Z0-9.]$/.test(inputUntilCursor)) {
       this._autocompleteCache = message.matches;
       this._autocompleteQuery = inputUntilCursor;
     }
 
     const matches = message.matches;
     const lastPart = message.matchProp;
     if (!matches.length) {
       this.clearCompletion();
       callback && callback(this);
       this.emit("autocomplete-updated");
       return;
     }
 
-    const items = matches.reverse().map(function(match) {
-      return { preLabel: lastPart, label: match };
-    });
-
+    const items = matches.reverse().map(match => ({ preLabel: lastPart, label: match }));
     const popup = this.autocompletePopup;
     popup.setItems(items);
 
     const completionType = this.lastCompletion.completionType;
     this.lastCompletion = {
       value: inputValue,
       matchProp: lastPart,
     };
     if (items.length > 1 && !popup.isOpen) {
-      const str = this.getInputValue().substr(0, this.inputNode.selectionStart);
-      const offset = str.length - (str.lastIndexOf("\n") + 1) - lastPart.length;
-      const x = offset * this._inputCharWidth;
-      popup.openPopup(inputNode, x + this._chevronWidth);
-      this._autocompletePopupNavigated = false;
+      let popupAlignElement;
+      let xOffset;
+      let yOffset;
+
+      if (this.editor) {
+        popupAlignElement = this.node.querySelector(".CodeMirror-cursor");
+        // We need to show the popup at the ".".
+        xOffset = -1 * lastPart.length * this._inputCharWidth;
+        yOffset = 4;
+      } else if (this.inputNode) {
+        const offset = inputUntilCursor.length -
+          (inputUntilCursor.lastIndexOf("\n") + 1) -
+          lastPart.length;
+        xOffset = (offset * this._inputCharWidth) + this._chevronWidth;
+        popupAlignElement = this.inputNode;
+      }
+
+      if (popupAlignElement) {
+        popup.openPopup(popupAlignElement, xOffset, yOffset);
+        this._autocompletePopupNavigated = false;
+      }
     } else if (items.length < 2 && popup.isOpen) {
       popup.hidePopup();
       this._autocompletePopupNavigated = false;
     }
     if (items.length == 1) {
       popup.selectedIndex = 0;
     }
 
@@ -1196,17 +1342,17 @@ class JSTerm extends Component {
     }
 
     callback && callback(this);
     this.emit("autocomplete-updated");
   }
 
   onAutocompleteSelect() {
     // Render the suggestion only if the cursor is at the end of the input.
-    if (this.inputNode.selectionStart != this.getInputValue().length) {
+    if (this.getSelectionStart() != this.getInputValue().length) {
       return;
     }
 
     const currentItem = this.autocompletePopup.selectedItem;
     if (currentItem && this.lastCompletion.value) {
       const suffix =
         currentItem.label.substring(this.lastCompletion.matchProp.length);
       this.updateCompleteNode(suffix);
@@ -1223,19 +1369,21 @@ class JSTerm extends Component {
     this.lastCompletion = { value: null };
     this.updateCompleteNode("");
     if (this.autocompletePopup) {
       this.autocompletePopup.clearItems();
 
       if (this.autocompletePopup.isOpen) {
         // Trigger a blur/focus of the JSTerm input to force screen readers to read the
         // value again.
-        this.inputNode.blur();
+        if (this.inputNode) {
+          this.inputNode.blur();
+        }
         this.autocompletePopup.once("popup-closed", () => {
-          this.inputNode.focus();
+          this.focus();
         });
         this.autocompletePopup.hidePopup();
         this._autocompletePopupNavigated = false;
       }
     }
   }
 
   /**
@@ -1248,53 +1396,69 @@ class JSTerm extends Component {
   acceptProposedCompletion() {
     let updated = false;
 
     const currentItem = this.autocompletePopup.selectedItem;
     if (currentItem && this.lastCompletion.value) {
       this.insertStringAtCursor(
         currentItem.label.substring(this.lastCompletion.matchProp.length)
       );
+
       updated = true;
     }
 
     this.clearCompletion();
 
     return updated;
   }
 
   /**
    * Insert a string into the console at the cursor location,
    * moving the cursor to the end of the string.
    *
    * @param string str
    */
   insertStringAtCursor(str) {
-    const cursor = this.inputNode.selectionStart;
     const value = this.getInputValue();
-    this.setInputValue(value.substr(0, cursor) +
-      str + value.substr(cursor));
-    const newCursor = cursor + str.length;
-    this.inputNode.selectionStart = this.inputNode.selectionEnd = newCursor;
+    const prefix = this.getInputValueBeforeCursor();
+    const suffix = value.replace(prefix, "");
+
+    // We need to retrieve the cursor before setting the new value.
+    const editorCursor = this.editor && this.editor.getCursor();
+
+    this.setInputValue(prefix + str + suffix);
+
+    if (this.inputNode) {
+      const newCursor = prefix.length + str.length;
+      this.inputNode.selectionStart = this.inputNode.selectionEnd = newCursor;
+    } else if (this.editor) {
+      // Set the cursor on the same line it was already at, after the autocompleted text
+      this.editor.setCursor({
+        line: editorCursor.line,
+        ch: editorCursor.ch + str.length
+      });
+    }
   }
 
   /**
    * Update the node that displays the currently selected autocomplete proposal.
    *
    * @param string suffix
    *        The proposed suffix for the inputNode value.
    */
   updateCompleteNode(suffix) {
-    if (!this.completeNode) {
-      return;
+    if (this.completeNode) {
+      // completion prefix = input, with non-control chars replaced by spaces
+      const prefix = suffix ? this.getInputValue().replace(/[\S]/g, " ") : "";
+      this.completeNode.value = prefix + suffix;
     }
 
-    // completion prefix = input, with non-control chars replaced by spaces
-    const prefix = suffix ? this.getInputValue().replace(/[\S]/g, " ") : "";
-    this.completeNode.value = prefix + suffix;
+    if (this.editor) {
+      this.editor.setAutoCompletionText(suffix);
+    }
   }
 
   /**
    * Calculates and returns the width of a single character of the input box.
    * This will be used in opening the popup at the correct offset.
    *
    * @returns {Number|null}: Width off the "x" char, or null if the input does not exist.
    */