diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/client/webconsole/components/Input/JSTerm.js | 1605 |
1 files changed, 1605 insertions, 0 deletions
diff --git a/devtools/client/webconsole/components/Input/JSTerm.js b/devtools/client/webconsole/components/Input/JSTerm.js new file mode 100644 index 0000000000..f00ddd66b0 --- /dev/null +++ b/devtools/client/webconsole/components/Input/JSTerm.js @@ -0,0 +1,1605 @@ +/* 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 { debounce } = require("resource://devtools/shared/debounce.js"); +const isMacOS = Services.appinfo.OS === "Darwin"; + +loader.lazyRequireGetter(this, "Debugger", "Debugger"); +loader.lazyRequireGetter( + this, + "EventEmitter", + "resource://devtools/shared/event-emitter.js" +); +loader.lazyRequireGetter( + this, + "AutocompletePopup", + "resource://devtools/client/shared/autocomplete-popup.js" +); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +loader.lazyRequireGetter( + this, + "KeyCodes", + "resource://devtools/client/shared/keycodes.js", + true +); +loader.lazyRequireGetter( + this, + "Editor", + "resource://devtools/client/shared/sourceeditor/editor.js" +); +loader.lazyRequireGetter( + this, + "getFocusableElements", + "resource://devtools/client/shared/focus.js", + true +); +loader.lazyRequireGetter( + this, + "l10n", + "resource://devtools/client/webconsole/utils/messages.js", + true +); +loader.lazyRequireGetter( + this, + "saveAs", + "resource://devtools/shared/DevToolsUtils.js", + true +); +loader.lazyRequireGetter( + this, + "beautify", + "resource://devtools/shared/jsbeautify/beautify.js" +); + +// React & Redux +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +// History Modules +const { + getHistory, + getHistoryValue, +} = require("resource://devtools/client/webconsole/selectors/history.js"); +const { + getAutocompleteState, +} = require("resource://devtools/client/webconsole/selectors/autocomplete.js"); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); + +const EvaluationContextSelector = createFactory( + require("resource://devtools/client/webconsole/components/Input/EvaluationContextSelector.js") +); + +// Constants used for defining the direction of JSTerm input history navigation. +const { + HISTORY_BACK, + HISTORY_FORWARD, +} = require("resource://devtools/client/webconsole/constants.js"); + +const JSTERM_CODEMIRROR_ORIGIN = "jsterm"; + +/** + * 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. + */ +class JSTerm extends Component { + static get propTypes() { + return { + // 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. + webConsoleUI: PropTypes.object.isRequired, + // Needed for opening context menu + serviceContainer: PropTypes.object.isRequired, + // Handler for clipboard 'paste' event (also used for 'drop' event, callback). + onPaste: PropTypes.func, + // Evaluate provided expression. + evaluateExpression: PropTypes.func.isRequired, + // Update position in the history after executing an expression (action). + updateHistoryPosition: PropTypes.func.isRequired, + // Update autocomplete popup state. + autocompleteUpdate: PropTypes.func.isRequired, + autocompleteClear: PropTypes.func.isRequired, + // Data to be displayed in the autocomplete popup. + autocompleteData: PropTypes.object.isRequired, + // Toggle the editor mode. + editorToggle: PropTypes.func.isRequired, + // Dismiss the editor onboarding UI. + editorOnboardingDismiss: PropTypes.func.isRequired, + // Set the last JS input value. + terminalInputChanged: PropTypes.func.isRequired, + // Is the input in editor mode. + editorMode: PropTypes.bool, + editorWidth: PropTypes.number, + editorPrettifiedAt: PropTypes.number, + showEditorOnboarding: PropTypes.bool, + autocomplete: PropTypes.bool, + showEvaluationContextSelector: PropTypes.bool, + autocompletePopupPosition: PropTypes.string, + inputEnabled: PropTypes.bool, + }; + } + + constructor(props) { + super(props); + + const { webConsoleUI } = props; + + this.webConsoleUI = webConsoleUI; + this.hudId = this.webConsoleUI.hudId; + + this._onEditorChanges = this._onEditorChanges.bind(this); + this._onEditorBeforeChange = this._onEditorBeforeChange.bind(this); + this._onEditorKeyHandled = this._onEditorKeyHandled.bind(this); + this.onContextMenu = this.onContextMenu.bind(this); + this.imperativeUpdate = this.imperativeUpdate.bind(this); + + // We debounce the autocompleteUpdate so we don't send too many requests to the server + // as the user is typing. + // The delay should be small enough to be unnoticed by the user. + this.autocompleteUpdate = debounce(this.props.autocompleteUpdate, 75, this); + + // Updates to the terminal input which can trigger eager evaluations are + // similarly debounced. + this.terminalInputChanged = debounce( + this.props.terminalInputChanged, + 75, + this + ); + + // Because the autocomplete has a slight delay (75ms), there can be time where the + // codeMirror completion text is out-of-date, which might lead to issue when the user + // accept the autocompletion while the update of the completion text is still pending. + // In order to account for that, we put any future value of the completion text in + // this property. + this.pendingCompletionText = null; + + /** + * Last input value. + * @type string + */ + this.lastInputValue = ""; + + this.autocompletePopup = null; + + EventEmitter.decorate(this); + webConsoleUI.jsterm = this; + } + + componentDidMount() { + if (this.props.editorMode) { + this.setEditorWidth(this.props.editorWidth); + } + + const autocompleteOptions = { + onSelect: this.onAutocompleteSelect.bind(this), + onClick: this.acceptProposedCompletion.bind(this), + listId: "webConsole_autocompletePopupListBox", + position: this.props.autocompletePopupPosition, + autoSelect: true, + useXulWrapper: true, + }; + + const doc = this.webConsoleUI.document; + const { toolbox } = this.webConsoleUI.wrapper; + const tooltipDoc = toolbox ? toolbox.doc : doc; + // The popup will be attached to the toolbox document or HUD document in the case + // such as the browser console which doesn't have a toolbox. + this.autocompletePopup = new AutocompletePopup( + tooltipDoc, + autocompleteOptions + ); + + if (this.node) { + const onArrowUp = () => { + let inputUpdated; + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.selectPreviousItem(); + return null; + } + + if (this.props.editorMode === false && this.canCaretGoPrevious()) { + inputUpdated = this.historyPeruse(HISTORY_BACK); + } + + return inputUpdated ? null : "CodeMirror.Pass"; + }; + + const onArrowDown = () => { + let inputUpdated; + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.selectNextItem(); + return null; + } + + if (this.props.editorMode === false && this.canCaretGoNext()) { + inputUpdated = this.historyPeruse(HISTORY_FORWARD); + } + + return inputUpdated ? null : "CodeMirror.Pass"; + }; + + const onArrowLeft = () => { + if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) { + this.clearCompletion(); + } + return "CodeMirror.Pass"; + }; + + const onArrowRight = () => { + // We only want to complete on Right arrow if the completion text is + // displayed. + if (this.getAutoCompletionText()) { + this.acceptProposedCompletion(); + return null; + } + + this.clearCompletion(); + return "CodeMirror.Pass"; + }; + + const onCtrlCmdEnter = () => { + if (this.hasAutocompletionSuggestion()) { + return this.acceptProposedCompletion(); + } + + this._execute(); + return null; + }; + + this.editor = new Editor({ + autofocus: true, + enableCodeFolding: this.props.editorMode, + lineNumbers: this.props.editorMode, + lineWrapping: true, + mode: { + name: "javascript", + globalVars: true, + }, + styleActiveLine: false, + tabIndex: "0", + viewportMargin: Infinity, + disableSearchAddon: true, + extraKeys: { + Enter: () => { + // No need to handle shift + Enter as it's natively handled by CodeMirror. + + const hasSuggestion = this.hasAutocompletionSuggestion(); + if ( + !hasSuggestion && + !Debugger.isCompilableUnit(this._getValue()) + ) { + // incomplete statement + return "CodeMirror.Pass"; + } + + if (hasSuggestion) { + return this.acceptProposedCompletion(); + } + + if (!this.props.editorMode) { + this._execute(); + return null; + } + return "CodeMirror.Pass"; + }, + + "Cmd-Enter": onCtrlCmdEnter, + "Ctrl-Enter": onCtrlCmdEnter, + + [Editor.accel("S")]: () => { + const value = this._getValue(); + if (!value) { + return null; + } + + const date = new Date(); + const suggestedName = + `console-input-${date.getFullYear()}-` + + `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` + + `${date.getMinutes()}-${date.getSeconds()}.js`; + const data = new TextEncoder().encode(value); + return saveAs(window, data, suggestedName, [ + { + pattern: "*.js", + label: l10n.getStr("webconsole.input.openJavaScriptFileFilter"), + }, + ]); + }, + + [Editor.accel("O")]: async () => this._openFile(), + + Tab: () => { + if (this.hasEmptyInput()) { + this.editor.codeMirror.getInputField().blur(); + return false; + } + + if ( + this.props.autocompleteData && + this.props.autocompleteData.getterPath + ) { + this.props.autocompleteUpdate( + true, + this.props.autocompleteData.getterPath + ); + return false; + } + + const isSomethingSelected = this.editor.somethingSelected(); + const hasSuggestion = this.hasAutocompletionSuggestion(); + + if (hasSuggestion && !isSomethingSelected) { + this.acceptProposedCompletion(); + return false; + } + + if (!isSomethingSelected) { + this.insertStringAtCursor("\t"); + return false; + } + + // Something is selected, let the editor handle the indent. + return true; + }, + + "Shift-Tab": () => { + if (this.hasEmptyInput()) { + this.focusPreviousElement(); + return false; + } + + const hasSuggestion = this.hasAutocompletionSuggestion(); + + if (hasSuggestion) { + return false; + } + + return "CodeMirror.Pass"; + }, + + Up: onArrowUp, + "Cmd-Up": onArrowUp, + + Down: onArrowDown, + "Cmd-Down": onArrowDown, + + Left: onArrowLeft, + "Ctrl-Left": onArrowLeft, + "Cmd-Left": onArrowLeft, + "Alt-Left": onArrowLeft, + // On OSX, Ctrl-A navigates to the beginning of the line. + "Ctrl-A": isMacOS ? onArrowLeft : undefined, + + Right: onArrowRight, + "Ctrl-Right": onArrowRight, + "Cmd-Right": onArrowRight, + "Alt-Right": onArrowRight, + + "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.props.editorMode === false && + this.canCaretGoNext() && + this.historyPeruse(HISTORY_FORWARD) + ) { + return null; + } + + this.clearCompletion(); + return "CodeMirror.Pass"; + }, + + "Ctrl-P": () => { + // 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.props.editorMode === false && + this.canCaretGoPrevious() && + this.historyPeruse(HISTORY_BACK) + ) { + return null; + } + + this.clearCompletion(); + return "CodeMirror.Pass"; + }, + + PageUp: () => { + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.selectPreviousPageItem(); + } else { + const { outputScroller } = this.webConsoleUI; + const { scrollTop, clientHeight } = outputScroller; + outputScroller.scrollTop = Math.max(0, scrollTop - clientHeight); + } + + return null; + }, + + PageDown: () => { + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.selectNextPageItem(); + } else { + const { outputScroller } = this.webConsoleUI; + const { scrollTop, scrollHeight, clientHeight } = outputScroller; + outputScroller.scrollTop = Math.min( + scrollHeight, + scrollTop + clientHeight + ); + } + + return null; + }, + + Home: () => { + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.selectItemAtIndex(0); + return null; + } + + if (!this._getValue()) { + this.webConsoleUI.outputScroller.scrollTop = 0; + return null; + } + + if (this.getAutoCompletionText()) { + this.clearCompletion(); + } + + return "CodeMirror.Pass"; + }, + + End: () => { + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.selectItemAtIndex( + this.autocompletePopup.itemCount - 1 + ); + return null; + } + + if (!this._getValue()) { + const { outputScroller } = this.webConsoleUI; + outputScroller.scrollTop = outputScroller.scrollHeight; + return null; + } + + if (this.getAutoCompletionText()) { + this.clearCompletion(); + } + + return "CodeMirror.Pass"; + }, + + "Ctrl-Space": () => { + if (!this.autocompletePopup.isOpen) { + this.props.autocompleteUpdate( + true, + null, + this._getExpressionVariables() + ); + return null; + } + + return "CodeMirror.Pass"; + }, + + Esc: false, + // Don't handle Ctrl/Cmd + F so it can be listened by a parent node + [Editor.accel("F")]: false, + }, + }); + + this.editor.on("changes", this._onEditorChanges); + this.editor.on("beforeChange", this._onEditorBeforeChange); + this.editor.on("blur", this._onEditorBlur); + this.editor.on("keyHandled", this._onEditorKeyHandled); + + 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)); + + this.node.addEventListener("keydown", event => { + if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) { + if (this.autocompletePopup.isOpen) { + this.clearCompletion(); + event.preventDefault(); + event.stopPropagation(); + } + + if ( + this.props.autocompleteData && + this.props.autocompleteData.getterPath + ) { + this.props.autocompleteClear(); + event.preventDefault(); + event.stopPropagation(); + } + } + }); + + this.resizeObserver = new ResizeObserver(() => { + // If we don't have the node reference, or if the node isn't connected + // anymore, we disconnect the resize observer (componentWillUnmount is never + // called on this component, so we have to do it here). + if (!this.node || !this.node.isConnected) { + this.resizeObserver.disconnect(); + return; + } + // Calling `refresh` will update the cursor position, and all the selection blocks. + this.editor.codeMirror.refresh(); + }); + this.resizeObserver.observe(this.node); + + // Update the character width needed for the popup offset calculations. + this._inputCharWidth = this._getInputCharWidth(); + this.lastInputValue && this._setValue(this.lastInputValue); + } + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + this.imperativeUpdate(nextProps); + } + + shouldComponentUpdate(nextProps) { + return ( + this.props.showEditorOnboarding !== nextProps.showEditorOnboarding || + this.props.editorMode !== nextProps.editorMode + ); + } + + /** + * Do all the imperative work needed after a Redux store update. + * + * @param {Object} nextProps: props passed from shouldComponentUpdate. + */ + imperativeUpdate(nextProps) { + if (!nextProps) { + return; + } + + if ( + nextProps.autocompleteData !== this.props.autocompleteData && + nextProps.autocompleteData.pendingRequestId === null + ) { + this.updateAutocompletionPopup(nextProps.autocompleteData); + } + + if (nextProps.editorMode !== this.props.editorMode) { + if (this.editor) { + this.editor.setOption("lineNumbers", nextProps.editorMode); + this.editor.setOption("enableCodeFolding", nextProps.editorMode); + } + + if (nextProps.editorMode && nextProps.editorWidth) { + this.setEditorWidth(nextProps.editorWidth); + } else { + this.setEditorWidth(null); + } + + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.hidePopup(); + } + } + + if ( + nextProps.autocompletePopupPosition !== + this.props.autocompletePopupPosition && + this.autocompletePopup + ) { + this.autocompletePopup.position = nextProps.autocompletePopupPosition; + } + + if ( + nextProps.editorPrettifiedAt && + nextProps.editorPrettifiedAt !== this.props.editorPrettifiedAt + ) { + this._setValue( + beautify.js(this._getValue(), { + // Read directly from prefs because this.editor.config.indentUnit and + // this.editor.getOption('indentUnit') are not really synced with + // prefs. + indent_size: Services.prefs.getIntPref("devtools.editor.tabsize"), + indent_with_tabs: !Services.prefs.getBoolPref( + "devtools.editor.expandtab" + ), + }) + ); + } + } + + /** + * + * @param {Number|null} editorWidth: The width to set the node to. If null, removes any + * `width` property on node style. + */ + setEditorWidth(editorWidth) { + if (!this.node) { + return; + } + + if (editorWidth) { + this.node.style.width = `${editorWidth}px`; + } else { + this.node.style.removeProperty("width"); + } + } + + focus() { + if (this.editor) { + this.editor.focus(); + } + } + + focusPreviousElement() { + const inputField = this.editor.codeMirror.getInputField(); + + const findPreviousFocusableElement = el => { + if (!el || !el.querySelectorAll) { + return null; + } + + // We only want to get visible focusable element, and for that we can assert that + // the offsetParent isn't null. We can do that because we don't have fixed position + // element in the console. + const items = getFocusableElements(el).filter( + ({ offsetParent }) => offsetParent !== null + ); + const inputIndex = items.indexOf(inputField); + + if (items.length === 0 || (inputIndex > -1 && items.length === 1)) { + return findPreviousFocusableElement(el.parentNode); + } + + const index = inputIndex > 0 ? inputIndex - 1 : items.length - 1; + return items[index]; + }; + + const focusableEl = findPreviousFocusableElement(this.node.parentNode); + if (focusableEl) { + focusableEl.focus(); + } + } + + /** + * Execute a string. Execution happens asynchronously in the content process. + */ + _execute() { + const value = this._getValue(); + // In editor mode, we only evaluate the text selection if there's one. The feature isn't + // enabled in inline mode as it can be confusing since input is cleared when evaluating. + const executeString = this.props.editorMode + ? this.getSelectedText() || value + : value; + + if (!executeString) { + return; + } + + if (!this.props.editorMode) { + // Calling this.props.terminalInputChanged instead of this.terminalInputChanged + // because we want to instantly hide the instant evaluation result, and don't want + // the delay we have in this.terminalInputChanged. + this.props.terminalInputChanged(""); + this._setValue(""); + } + this.clearCompletion(); + this.props.evaluateExpression(executeString); + } + + /** + * Sets the value of the input field. + * + * @param string newValue + * The new value to set. + * @returns void + */ + _setValue(newValue = "") { + this.lastInputValue = newValue; + this.terminalInputChanged(newValue); + + if (this.editor) { + // In order to get the autocomplete popup to work properly, we need to set the + // editor text and the cursor in the same operation. If we don't, the text change + // is done before the cursor is moved, and the autocompletion call to the server + // sends an erroneous query. + this.editor.codeMirror.operation(() => { + this.editor.setText(newValue); + + // Set the cursor at the end of the input. + const lines = newValue.split("\n"); + this.editor.setCursor({ + line: lines.length - 1, + ch: lines[lines.length - 1].length, + }); + this.editor.setAutoCompletionText(); + }); + } + + this.emitForTests("set-input-value"); + } + + /** + * Gets the value from the input field + * @returns string + */ + _getValue() { + return this.editor ? this.editor.getText() || "" : ""; + } + + /** + * Open the file picker for the user to select a javascript file and open it. + * + */ + async _openFile() { + const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init( + this.webConsoleUI.document.defaultView, + l10n.getStr("webconsole.input.openJavaScriptFile"), + Ci.nsIFilePicker.modeOpen + ); + + // Append file filters + fp.appendFilter( + l10n.getStr("webconsole.input.openJavaScriptFileFilter"), + "*.js" + ); + + function readFile(file) { + return new Promise(resolve => { + IOUtils.read(file.path).then(data => { + const decoder = new TextDecoder(); + resolve(decoder.decode(data)); + }); + }); + } + + const content = await new Promise(resolve => { + fp.open(rv => { + if (rv == Ci.nsIFilePicker.returnOK) { + const file = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + file.initWithPath(fp.file.path); + readFile(file).then(resolve); + } + }); + }); + + this._setValue(content); + } + + getSelectionStart() { + return this.getInputValueBeforeCursor().length; + } + + getSelectedText() { + return this.editor.getSelection(); + } + + /** + * Even handler for the "beforeChange" event fired by codeMirror. This event is fired + * when codeMirror is about to make a change to its DOM representation. + */ + _onEditorBeforeChange(cm, change) { + // If the user did not type a character that matches the completion text, then we + // clear it before the change is done to prevent a visual glitch. + // See Bugs 1491776 & 1558248. + const { from, to, origin, text } = change; + const isAddedText = + from.line === to.line && from.ch === to.ch && origin === "+input"; + + // if there was no changes (hitting delete on an empty input, or suppr when at the end + // of the input), we bail out. + if ( + !isAddedText && + origin === "+delete" && + from.line === to.line && + from.ch === to.ch + ) { + return; + } + + const addedText = text.join(""); + const completionText = this.getAutoCompletionText(); + + const addedCharacterMatchCompletion = + isAddedText && completionText.startsWith(addedText); + + const addedCharacterMatchPopupItem = + isAddedText && + this.autocompletePopup.items.some(({ preLabel, label }) => + label.startsWith(preLabel + addedText) + ); + const nextSelectedAutocompleteItemIndex = + addedCharacterMatchPopupItem && + this.autocompletePopup.items.findIndex(({ preLabel, label }) => + label.startsWith(preLabel + addedText) + ); + + if (addedCharacterMatchPopupItem) { + this.autocompletePopup.selectItemAtIndex( + nextSelectedAutocompleteItemIndex, + { preventSelectCallback: true } + ); + } + + if (!completionText || change.canceled || !addedCharacterMatchCompletion) { + this.setAutoCompletionText(""); + } + + if (!addedCharacterMatchCompletion && !addedCharacterMatchPopupItem) { + this.autocompletePopup.hidePopup(); + } else if ( + !change.canceled && + (completionText || + addedCharacterMatchCompletion || + addedCharacterMatchPopupItem) + ) { + // The completion text will be updated when the debounced autocomplete update action + // is done, so in the meantime we set the pending value to pendingCompletionText. + // See Bug 1595068 for more information. + this.pendingCompletionText = completionText.substring(text.length); + // And we update the preLabel of the matching autocomplete items that may be used + // in the acceptProposedAutocompletion function. + this.autocompletePopup.items.forEach(item => { + if (item.label.startsWith(item.preLabel + addedText)) { + item.preLabel += addedText; + } + }); + } + } + + /** + * Even handler for the "blur" event fired by codeMirror. + */ + _onEditorBlur(cm) { + if (cm.somethingSelected()) { + // If there's a selection when the input is blurred, then we remove it by setting + // the cursor at the position that matches the start of the first selection. + const [{ head }] = cm.listSelections(); + cm.setCursor(head, { scroll: false }); + } + } + + /** + * Fired after a key is handled through a key map. + * + * @param {CodeMirror} cm: codeMirror instance + * @param {String} key: The key that was handled + * @param {Event} e: The keypress event + */ + _onEditorKeyHandled(cm, key, e) { + // The autocloseBracket addon handle closing brackets keys when they're typed, but + // there's already an existing closing bracket. + // ex: + // 1. input is `foo(x|)` (where | represents the cursor) + // 2. user types `)` + // 3. input is now `foo(x)|` (i.e. the typed character wasn't inserted) + // In such case, _onEditorBeforeChange isn't triggered, so we need to hide the popup + // here. We can do that because this function won't be called when codeMirror _do_ + // insert the closing char. + const closingKeys = [`']'`, `')'`, "'}'"]; + if (this.autocompletePopup.isOpen && closingKeys.includes(key)) { + this.clearCompletion(); + } + } + + /** + * Retrieve variable declared in the expression from the CodeMirror state, in order + * to display them in the autocomplete popup. + */ + _getExpressionVariables() { + const cm = this.editor.codeMirror; + const { state } = cm.getTokenAt(cm.getCursor()); + const variables = []; + + if (state.context) { + for (let c = state.context; c; c = c.prev) { + for (let v = c.vars; v; v = v.next) { + if (v.name) { + variables.push(v.name); + } + } + } + } + + const keys = ["localVars", "globalVars"]; + for (const key of keys) { + if (state[key]) { + for (let v = state[key]; v; v = v.next) { + if (v.name) { + variables.push(v.name); + } + } + } + } + + return variables; + } + + /** + * The editor "changes" event handler. + */ + _onEditorChanges(cm, changes) { + const value = this._getValue(); + + if (this.lastInputValue !== value) { + // We don't autocomplete if the changes were made by JsTerm (e.g. autocomplete was + // accepted). + const isJsTermChangeOnly = changes.every( + ({ origin }) => origin === JSTERM_CODEMIRROR_ORIGIN + ); + + if ( + !isJsTermChangeOnly && + (this.props.autocomplete || this.hasAutocompletionSuggestion()) + ) { + this.autocompleteUpdate(false, null, this._getExpressionVariables()); + } + this.lastInputValue = value; + this.terminalInputChanged(value); + } + } + + /** + * Go up/down the history stack of input values. + * + * @param number direction + * History navigation direction: HISTORY_BACK or HISTORY_FORWARD. + * + * @returns boolean + * True if the input value changed, false otherwise. + */ + historyPeruse(direction) { + const { history, updateHistoryPosition, getValueFromHistory } = this.props; + + if (!history.entries.length) { + return false; + } + + const newInputValue = getValueFromHistory(direction); + const expression = this._getValue(); + updateHistoryPosition(direction, expression); + + if (newInputValue != null) { + this._setValue(newInputValue); + return true; + } + + return false; + } + + /** + * Test for empty input. + * + * @return boolean + */ + hasEmptyInput() { + return this._getValue() === ""; + } + + /** + * Check if the caret is at a location that allows selecting the previous item + * in history when the user presses the Up arrow key. + * + * @return boolean + * True if the caret is at a location that allows selecting the + * previous item in history when the user presses the Up arrow key, + * otherwise false. + */ + canCaretGoPrevious() { + if (!this.editor) { + return false; + } + + const inputValue = this._getValue(); + const { line, ch } = this.editor.getCursor(); + return (line === 0 && ch === 0) || (line === 0 && ch === inputValue.length); + } + + /** + * Check if the caret is at a location that allows selecting the next item in + * history when the user presses the Down arrow key. + * + * @return boolean + * True if the caret is at a location that allows selecting the next + * item in history when the user presses the Down arrow key, otherwise + * false. + */ + canCaretGoNext() { + if (!this.editor) { + return false; + } + + const inputValue = this._getValue(); + const multiline = /[\r\n]/.test(inputValue); + + const { line, ch } = this.editor.getCursor(); + return ( + (!multiline && ch === 0) || + this.editor.getDoc().getRange({ line: 0, ch: 0 }, { line, ch }).length === + inputValue.length + ); + } + + /** + * Takes the data returned by the server and update the autocomplete popup state (i.e. + * its visibility and items). + * + * @param {Object} data + * The autocompletion data as returned by the webconsole actor's autocomplete + * service. Should be of the following shape: + * { + * matches: {Array} array of the properties matching the input, + * matchProp: {String} The string used to filter the properties, + * isElementAccess: {Boolean} True when the input is an element access, + * i.e. `document["addEve`. + * } + * @fires autocomplete-updated + */ + async updateAutocompletionPopup(data) { + if (!this.editor) { + return; + } + + const { matches, matchProp, isElementAccess } = data; + if (!matches.length) { + this.clearCompletion(); + return; + } + + const inputUntilCursor = this.getInputValueBeforeCursor(); + + const items = matches.map(label => { + let preLabel = label.substring(0, matchProp.length); + // If the user is performing an element access, and if they did not typed a quote, + // then we need to adjust the preLabel to match the quote from the label + what + // the user entered. + if (isElementAccess && /^['"`]/.test(matchProp) === false) { + preLabel = label.substring(0, matchProp.length + 1); + } + return { preLabel, label, isElementAccess }; + }); + + if (items.length) { + const { preLabel, label } = items[0]; + let suffix = label.substring(preLabel.length); + if (isElementAccess) { + if (!matchProp) { + suffix = label; + } + const inputAfterCursor = this._getValue().substring( + inputUntilCursor.length + ); + // If there's not a bracket after the cursor, add it to the completionText. + if (!inputAfterCursor.trimLeft().startsWith("]")) { + suffix = suffix + "]"; + } + } + this.setAutoCompletionText(suffix); + } + + const popup = this.autocompletePopup; + // We don't want to trigger the onSelect callback since we already set the completion + // text a few lines above. + popup.setItems(items, 0, { + preventSelectCallback: true, + }); + + const minimumAutoCompleteLength = 2; + + // We want to show the autocomplete popup if: + // - there are at least 2 matching results + // - OR, if there's 1 result, but whose label does not start like the input (this can + // happen with insensitive search: `num` will match `Number`). + // - OR, if there's 1 result, but we can't show the completionText (because there's + // some text after the cursor), unless the text in the popup is the same as the input. + if ( + items.length >= minimumAutoCompleteLength || + (items.length === 1 && items[0].preLabel !== matchProp) || + (items.length === 1 && + !this.canDisplayAutoCompletionText() && + items[0].label !== matchProp) + ) { + // We need to show the popup at the "." or "[". + const xOffset = -1 * matchProp.length * this._inputCharWidth; + const yOffset = 5; + const popupAlignElement = + this.props.serviceContainer.getJsTermTooltipAnchor(); + this._openPopupPendingPromise = popup.openPopup( + popupAlignElement, + xOffset, + yOffset, + 0, + { + preventSelectCallback: true, + } + ); + await this._openPopupPendingPromise; + this._openPopupPendingPromise = null; + } else if ( + items.length < minimumAutoCompleteLength && + (popup.isOpen || this._openPopupPendingPromise) + ) { + if (this._openPopupPendingPromise) { + await this._openPopupPendingPromise; + } + popup.hidePopup(); + } + + // Eager evaluation results incorporate the current autocomplete item. We need to + // trigger it here as well as in onAutocompleteSelect as we set the items with + // preventSelectCallback (which means we won't trigger onAutocompleteSelect when the + // popup is open). + this.terminalInputChanged( + this.getInputValueWithCompletionText().expression + ); + + this.emit("autocomplete-updated"); + } + + onAutocompleteSelect() { + const { selectedItem } = this.autocompletePopup; + if (selectedItem) { + const { preLabel, label, isElementAccess } = selectedItem; + let suffix = label.substring(preLabel.length); + + // If the user is performing an element access, we need to check if we should add + // starting and ending quotes, as well as a closing bracket. + if (isElementAccess) { + const inputBeforeCursor = this.getInputValueBeforeCursor(); + if (inputBeforeCursor.trim().endsWith("[")) { + suffix = label; + } + + const inputAfterCursor = this._getValue().substring( + inputBeforeCursor.length + ); + // If there's no closing bracket after the cursor, add it to the completionText. + if (!inputAfterCursor.trimLeft().startsWith("]")) { + suffix = suffix + "]"; + } + } + this.setAutoCompletionText(suffix); + } else { + this.setAutoCompletionText(""); + } + // Eager evaluation results incorporate the current autocomplete item. + this.terminalInputChanged( + this.getInputValueWithCompletionText().expression + ); + } + + /** + * Clear the current completion information, cancel any pending autocompletion update + * and close the autocomplete popup, if needed. + * @fires autocomplete-updated + */ + clearCompletion() { + this.autocompleteUpdate.cancel(); + // Update Eager evaluation result as the completion text was removed. + this.terminalInputChanged(this._getValue()); + + this.setAutoCompletionText(""); + let onPopupClosed = Promise.resolve(); + if (this.autocompletePopup) { + this.autocompletePopup.clearItems(); + + if (this.autocompletePopup.isOpen || this._openPopupPendingPromise) { + onPopupClosed = this.autocompletePopup.once("popup-closed"); + + if (this._openPopupPendingPromise) { + this._openPopupPendingPromise.then(() => + this.autocompletePopup.hidePopup() + ); + } else { + this.autocompletePopup.hidePopup(); + } + onPopupClosed.then(() => this.focus()); + } + } + onPopupClosed.then(() => this.emit("autocomplete-updated")); + } + + /** + * Accept the proposed input completion. + */ + acceptProposedCompletion() { + const { + completionText, + numberOfCharsToMoveTheCursorForward, + numberOfCharsToReplaceCharsBeforeCursor, + } = this.getInputValueWithCompletionText(); + + this.autocompleteUpdate.cancel(); + this.props.autocompleteClear(); + + // If the code triggering the opening of the popup was already triggered but not yet + // settled, then we need to wait until it's resolved in order to close the popup (See + // Bug 1655406). + if (this._openPopupPendingPromise) { + this._openPopupPendingPromise.then(() => + this.autocompletePopup.hidePopup() + ); + } + + if (completionText) { + this.insertStringAtCursor( + completionText, + numberOfCharsToReplaceCharsBeforeCursor + ); + + if (numberOfCharsToMoveTheCursorForward) { + const { line, ch } = this.editor.getCursor(); + this.editor.setCursor({ + line, + ch: ch + numberOfCharsToMoveTheCursorForward, + }); + } + } + } + + /** + * Returns an object containing the expression we would get if the user accepted the + * current completion text. This is more than the current input + the completion text, + * as there are special cases for element access and case-insensitive matches. + * + * @return {Object}: An object of the following shape: + * - {String} expression: The complete expression + * - {String} completionText: the completion text only, which should be used + * with the next property + * - {Integer} numberOfCharsToReplaceCharsBeforeCursor: The number of chars that + * should be removed from the current input before the cursor to + * cleanly apply the completionText. This is handy when we only want + * to insert the completionText. + * - {Integer} numberOfCharsToMoveTheCursorForward: The number of chars that the + * cursor should be moved after the completion is done. This can + * be useful for element access where there's already a closing + * quote and/or bracket. + */ + getInputValueWithCompletionText() { + const inputBeforeCursor = this.getInputValueBeforeCursor(); + const inputAfterCursor = this._getValue().substring( + inputBeforeCursor.length + ); + let completionText = this.getAutoCompletionText(); + let numberOfCharsToReplaceCharsBeforeCursor; + let numberOfCharsToMoveTheCursorForward = 0; + + // If the autocompletion popup is open, we always get the selected element from there, + // since the autocompletion text might not be enough (e.g. `dOcUmEn` should + // autocomplete to `document`, but the autocompletion text only shows `t`). + if (this.autocompletePopup.isOpen && this.autocompletePopup.selectedItem) { + const { selectedItem } = this.autocompletePopup; + const { label, preLabel, isElementAccess } = selectedItem; + + completionText = label; + numberOfCharsToReplaceCharsBeforeCursor = preLabel.length; + + // If the user is performing an element access, we need to check if we should add + // starting and ending quotes, as well as a closing bracket. + if (isElementAccess) { + const lastOpeningBracketIndex = inputBeforeCursor.lastIndexOf("["); + if (lastOpeningBracketIndex > -1) { + numberOfCharsToReplaceCharsBeforeCursor = inputBeforeCursor.substring( + lastOpeningBracketIndex + 1 + ).length; + } + + // If the autoclose bracket option is enabled, the input might be in a state where + // there's already the closing quote and the closing bracket, e.g. + // `document["activeEl|"]`, so we don't need to add + // Let's retrieve the completionText last character, to see if it's a quote. + const completionTextLastChar = + completionText[completionText.length - 1]; + const endingQuote = [`"`, `'`, "`"].includes(completionTextLastChar) + ? completionTextLastChar + : ""; + if ( + endingQuote && + inputAfterCursor.trimLeft().startsWith(endingQuote) + ) { + completionText = completionText.substring( + 0, + completionText.length - 1 + ); + numberOfCharsToMoveTheCursorForward++; + } + + // If there's not a closing bracket already, we add one. + if ( + !inputAfterCursor.trimLeft().match(new RegExp(`^${endingQuote}?]`)) + ) { + completionText = completionText + "]"; + } else { + // if there's already one, we want to move the cursor after the closing bracket. + numberOfCharsToMoveTheCursorForward++; + } + } + } + + const expression = + inputBeforeCursor.substring( + 0, + inputBeforeCursor.length - + (numberOfCharsToReplaceCharsBeforeCursor || 0) + ) + + completionText + + inputAfterCursor; + + return { + completionText, + expression, + numberOfCharsToMoveTheCursorForward, + numberOfCharsToReplaceCharsBeforeCursor, + }; + } + + getInputValueBeforeCursor() { + return this.editor + ? this.editor + .getDoc() + .getRange({ line: 0, ch: 0 }, this.editor.getCursor()) + : null; + } + + /** + * Insert a string into the console at the cursor location, + * moving the cursor to the end of the string. + * + * @param {string} str + * @param {int} numberOfCharsToReplaceCharsBeforeCursor - defaults to 0 + */ + insertStringAtCursor(str, numberOfCharsToReplaceCharsBeforeCursor = 0) { + if (!this.editor) { + return; + } + + const cursor = this.editor.getCursor(); + const from = { + line: cursor.line, + ch: cursor.ch - numberOfCharsToReplaceCharsBeforeCursor, + }; + + this.editor + .getDoc() + .replaceRange(str, from, cursor, JSTERM_CODEMIRROR_ORIGIN); + } + + /** + * Set the autocompletion text of the input. + * + * @param string suffix + * The proposed suffix for the input value. + */ + setAutoCompletionText(suffix) { + if (!this.editor) { + return; + } + + this.pendingCompletionText = null; + + if (suffix && !this.canDisplayAutoCompletionText()) { + suffix = ""; + } + + this.editor.setAutoCompletionText(suffix); + } + + getAutoCompletionText() { + const renderedCompletionText = + this.editor && this.editor.getAutoCompletionText(); + return typeof this.pendingCompletionText === "string" + ? this.pendingCompletionText + : renderedCompletionText; + } + + /** + * Indicate if the input has an autocompletion suggestion, i.e. that there is either + * something in the autocompletion text or that there's a selected item in the + * autocomplete popup. + */ + hasAutocompletionSuggestion() { + // We can have cases where the popup is opened but we can't display the autocompletion + // text. + return ( + this.getAutoCompletionText() || + (this.autocompletePopup.isOpen && + Number.isInteger(this.autocompletePopup.selectedIndex) && + this.autocompletePopup.selectedIndex > -1) + ); + } + + /** + * Returns a boolean indicating if we can display an autocompletion text in the input, + * i.e. if there is no characters displayed on the same line of the cursor and after it. + */ + canDisplayAutoCompletionText() { + if (!this.editor) { + return false; + } + + const { ch, line } = this.editor.getCursor(); + const lineContent = this.editor.getLine(line); + const textAfterCursor = lineContent.substring(ch); + return textAfterCursor === ""; + } + + /** + * 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. + */ + _getInputCharWidth() { + return this.editor ? this.editor.defaultCharWidth() : null; + } + + onContextMenu(e) { + this.props.serviceContainer.openEditContextMenu(e); + } + + destroy() { + this.autocompleteUpdate.cancel(); + this.terminalInputChanged.cancel(); + this._openPopupPendingPromise = null; + + if (this.autocompletePopup) { + this.autocompletePopup.destroy(); + this.autocompletePopup = null; + } + + if (this.editor) { + this.resizeObserver.disconnect(); + this.editor.destroy(); + this.editor = null; + } + + this.webConsoleUI = null; + } + + renderOpenEditorButton() { + if (this.props.editorMode) { + return null; + } + + return dom.button({ + className: + "devtools-button webconsole-input-openEditorButton" + + (this.props.showEditorOnboarding ? " devtools-feature-callout" : ""), + title: l10n.getFormatStr("webconsole.input.openEditorButton.tooltip2", [ + isMacOS ? "Cmd + B" : "Ctrl + B", + ]), + onClick: this.props.editorToggle, + }); + } + + renderEvaluationContextSelector() { + if (this.props.editorMode || !this.props.showEvaluationContextSelector) { + return null; + } + + return EvaluationContextSelector(this.props); + } + + renderEditorOnboarding() { + if (!this.props.showEditorOnboarding) { + return null; + } + + // We deliberately use getStr, and not getFormatStr, because we want keyboard + // shortcuts to be wrapped in their own span. + const label = l10n.getStr("webconsole.input.editor.onboarding.label"); + let [prefix, suffix] = label.split("%1$S"); + suffix = suffix.split("%2$S"); + + const enterString = l10n.getStr("webconsole.enterKey"); + + return dom.header( + { className: "editor-onboarding" }, + dom.img({ + className: "editor-onboarding-fox", + src: "chrome://devtools/skin/images/fox-smiling.svg", + }), + dom.p( + {}, + prefix, + dom.span({ className: "editor-onboarding-shortcut" }, enterString), + suffix[0], + dom.span({ className: "editor-onboarding-shortcut" }, [ + isMacOS ? `Cmd+${enterString}` : `Ctrl+${enterString}`, + ]), + suffix[1] + ), + dom.button( + { + className: "editor-onboarding-dismiss-button", + onClick: () => this.props.editorOnboardingDismiss(), + }, + l10n.getStr("webconsole.input.editor.onboarding.dismiss.label") + ) + ); + } + + render() { + if (!this.props.inputEnabled) { + return null; + } + + return dom.div( + { + className: "jsterm-input-container devtools-input", + key: "jsterm-container", + "aria-live": "off", + tabIndex: -1, + onContextMenu: this.onContextMenu, + ref: node => { + this.node = node; + }, + }, + dom.div( + { className: "webconsole-input-buttons" }, + this.renderEvaluationContextSelector(), + this.renderOpenEditorButton() + ), + this.renderEditorOnboarding() + ); + } +} + +// Redux connect + +function mapStateToProps(state) { + return { + history: getHistory(state), + getValueFromHistory: direction => getHistoryValue(state, direction), + autocompleteData: getAutocompleteState(state), + showEditorOnboarding: state.ui.showEditorOnboarding, + showEvaluationContextSelector: state.ui.showEvaluationContextSelector, + autocompletePopupPosition: state.prefs.eagerEvaluation ? "top" : "bottom", + editorPrettifiedAt: state.ui.editorPrettifiedAt, + }; +} + +function mapDispatchToProps(dispatch) { + return { + updateHistoryPosition: (direction, expression) => + dispatch(actions.updateHistoryPosition(direction, expression)), + autocompleteUpdate: (force, getterPath, expressionVars) => + dispatch(actions.autocompleteUpdate(force, getterPath, expressionVars)), + autocompleteClear: () => dispatch(actions.autocompleteClear()), + evaluateExpression: expression => + dispatch(actions.evaluateExpression(expression)), + editorToggle: () => dispatch(actions.editorToggle()), + editorOnboardingDismiss: () => dispatch(actions.editorOnboardingDismiss()), + terminalInputChanged: value => + dispatch(actions.terminalInputChanged(value)), + }; +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm); |