fune/devtools/client/webconsole/components/JSTerm.js
2018-08-27 07:56:03 +00:00

1533 lines
46 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 {Utils: WebConsoleUtils} = require("devtools/client/webconsole/utils");
const Services = require("Services");
loader.lazyServiceGetter(this, "clipboardHelper",
"@mozilla.org/widget/clipboardhelper;1",
"nsIClipboardHelper");
loader.lazyRequireGetter(this, "Debugger", "Debugger");
loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter");
loader.lazyRequireGetter(this, "AutocompletePopup", "devtools/client/shared/autocomplete-popup");
loader.lazyRequireGetter(this, "PropTypes", "devtools/client/shared/vendor/react-prop-types");
loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
loader.lazyRequireGetter(this, "KeyCodes", "devtools/client/shared/keycodes", true);
loader.lazyRequireGetter(this, "Editor", "devtools/client/sourceeditor/editor");
loader.lazyRequireGetter(this, "Telemetry", "devtools/client/shared/telemetry");
loader.lazyRequireGetter(this, "processScreenshot", "devtools/shared/webconsole/screenshot-helper");
const l10n = require("devtools/client/webconsole/webconsole-l10n");
const HELP_URL = "https://developer.mozilla.org/docs/Tools/Web_Console/Helpers";
function gSequenceId() {
return gSequenceId.n++;
}
gSequenceId.n = 0;
// React & Redux
const { Component } = require("devtools/client/shared/vendor/react");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
const { connect } = require("devtools/client/shared/vendor/react-redux");
// History Modules
const {
getHistory,
getHistoryValue
} = require("devtools/client/webconsole/selectors/history");
const historyActions = require("devtools/client/webconsole/actions/history");
// Constants used for defining the direction of JSTerm input history navigation.
const {
HISTORY_BACK,
HISTORY_FORWARD
} = require("devtools/client/webconsole/constants");
/**
* Create a JSTerminal (a JavaScript command line). This is attached to an
* existing HeadsUpDisplay (a Web Console instance). This code is responsible
* with handling command line input and code evaluation.
*
* @constructor
* @param object webConsoleFrame
* The WebConsoleFrame object that owns this JSTerm instance.
*/
class JSTerm extends Component {
static get propTypes() {
return {
// Append new executed expression into history list (action).
appendToHistory: PropTypes.func.isRequired,
// Remove all entries from the history list (action).
clearHistory: PropTypes.func.isRequired,
// Returns previous or next value from the history
// (depending on direction argument).
getValueFromHistory: PropTypes.func.isRequired,
// History of executed expression (state).
history: PropTypes.object.isRequired,
// Console object.
hud: PropTypes.object.isRequired,
// Needed for opening context menu
serviceContainer: PropTypes.object.isRequired,
// Handler for clipboard 'paste' event (also used for 'drop' event, callback).
onPaste: PropTypes.func,
codeMirrorEnabled: PropTypes.bool,
// Update position in the history after executing an expression (action).
updateHistoryPosition: PropTypes.func.isRequired,
};
}
constructor(props) {
super(props);
const {
hud,
} = props;
this.hud = hud;
this.hudId = this.hud.hudId;
this._keyPress = this._keyPress.bind(this);
this._inputEventHandler = this._inputEventHandler.bind(this);
this._blurEventHandler = this._blurEventHandler.bind(this);
this.onContextMenu = this.onContextMenu.bind(this);
this.SELECTED_FRAME = -1;
/**
* Array that caches the user input suggestions received from the server.
* @private
* @type array
*/
this._autocompleteCache = null;
/**
* The input that caused the last request to the server, whose response is
* cached in the _autocompleteCache array.
* @private
* @type string
*/
this._autocompleteQuery = null;
/**
* The frameActorId used in the last autocomplete query. Whenever this changes
* the autocomplete cache must be invalidated.
* @private
* @type string
*/
this._lastFrameActorId = null;
/**
* Last input value.
* @type string
*/
this.lastInputValue = "";
this.currentAutoCompletionRequestId = null;
this.autocompletePopup = null;
this.inputNode = null;
this.completeNode = null;
this._telemetry = new Telemetry();
EventEmitter.decorate(this);
hud.jsterm = this;
}
componentDidMount() {
const autocompleteOptions = {
onSelect: this.onAutocompleteSelect.bind(this),
onClick: this.acceptProposedCompletion.bind(this),
listId: "webConsole_autocompletePopupListBox",
position: "top",
autoSelect: true
};
const doc = this.hud.document;
const toolbox = gDevTools.getToolbox(this.hud.owner.target);
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.props.codeMirrorEnabled) {
if (this.node) {
const onArrowUp = () => {
let inputUpdated;
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectPreviousItem();
return null;
}
if (this.canCaretGoPrevious()) {
inputUpdated = this.historyPeruse(HISTORY_BACK);
}
if (!inputUpdated) {
return "CodeMirror.Pass";
}
return null;
};
const onArrowDown = () => {
let inputUpdated;
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectNextItem();
return null;
}
if (this.canCaretGoNext()) {
inputUpdated = this.historyPeruse(HISTORY_FORWARD);
}
if (!inputUpdated) {
return "CodeMirror.Pass";
}
return null;
};
this.editor = new Editor({
autofocus: true,
enableCodeFolding: false,
gutters: [],
lineWrapping: true,
mode: Editor.modes.js,
styleActiveLine: false,
tabIndex: "0",
viewportMargin: Infinity,
extraKeys: {
"Enter": () => {
// No need to handle shift + Enter as it's natively handled by CodeMirror.
const hasSuggestion = this.hasAutocompletionSuggestion();
if (!hasSuggestion && !Debugger.isCompilableUnit(this.getInputValue())) {
// incomplete statement
return "CodeMirror.Pass";
}
if (hasSuggestion) {
return this.acceptProposedCompletion();
}
this.execute();
return null;
},
"Tab": () => {
if (this.hasEmptyInput()) {
this.editor.codeMirror.getInputField().blur();
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;
},
"Up": onArrowUp,
"Cmd-Up": onArrowUp,
"Down": onArrowDown,
"Cmd-Down": onArrowDown,
"Left": () => {
if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) {
this.clearCompletion();
}
return "CodeMirror.Pass";
},
"Right": () => {
// 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";
},
"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)
) {
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.canCaretGoPrevious()
&& this.historyPeruse(HISTORY_BACK)
) {
return null;
}
this.clearCompletion();
return "CodeMirror.Pass";
},
"PageUp": () => {
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectPreviousPageItem();
} else {
this.hud.outputScroller.scrollTop = Math.max(
0,
this.hud.outputScroller.scrollTop - this.hud.outputScroller.clientHeight
);
}
return null;
},
"PageDown": () => {
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectNextPageItem();
} else {
this.hud.outputScroller.scrollTop = Math.min(
this.hud.outputScroller.scrollHeight,
this.hud.outputScroller.scrollTop + this.hud.outputScroller.clientHeight
);
}
return null;
},
"Home": () => {
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectedIndex = 0;
return null;
}
if (!this.getInputValue()) {
this.hud.outputScroller.scrollTop = 0;
return null;
}
return "CodeMirror.Pass";
},
"End": () => {
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectedIndex =
this.autocompletePopup.itemCount - 1;
return null;
}
if (!this.getInputValue()) {
this.hud.outputScroller.scrollTop = this.hud.outputScroller.scrollHeight;
return null;
}
return "CodeMirror.Pass";
},
"Esc": false,
"Cmd-F": false,
"Ctrl-F": false,
}
});
this.editor.on("changes", 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));
this.node.addEventListener("keydown", event => {
if (event.keyCode === KeyCodes.DOM_VK_ESCAPE && this.autocompletePopup.isOpen) {
this.clearCompletion();
event.preventDefault();
event.stopPropagation();
}
});
}
} else if (this.inputNode) {
this.inputNode.addEventListener("keypress", this._keyPress);
this.inputNode.addEventListener("input", this._inputEventHandler);
this.inputNode.addEventListener("keyup", this._inputEventHandler);
this.focus();
}
this.inputBorderSize = this.inputNode
? this.inputNode.getBoundingClientRect().height - this.inputNode.clientHeight
: 0;
// Update the character and chevron width needed for the popup offset calculations.
this._inputCharWidth = this._getInputCharWidth();
this._chevronWidth = this.editor ? null : this._getChevronWidth();
this.hud.window.addEventListener("blur", this._blurEventHandler);
this.lastInputValue && this.setInputValue(this.lastInputValue);
}
shouldComponentUpdate(nextProps, nextState) {
// XXX: For now, everything is handled in an imperative way and we
// only want React to do the initial rendering of the component.
// This should be modified when the actual refactoring will take place.
return false;
}
/**
* Getter for the element that holds the messages we display.
* @type Element
*/
get outputNode() {
return this.hud.outputNode;
}
/**
* Getter for the debugger WebConsoleClient.
* @type object
*/
get webConsoleClient() {
return this.hud.webConsoleClient;
}
focus() {
if (this.editor) {
this.editor.focus();
} else if (this.inputNode && !this.inputNode.getAttribute("focused")) {
this.inputNode.focus();
}
}
/**
* The JavaScript evaluation response handler.
*
* @private
* @param object response
* The message received from the server.
*/
async _executeResultCallback(response) {
if (!this.hud) {
return null;
}
if (response.error) {
console.error("Evaluation error " + response.error + ": " + response.message);
return null;
}
let errorMessage = response.exceptionMessage;
// Wrap thrown strings in Error objects, so `throw "foo"` outputs "Error: foo"
if (typeof response.exception === "string") {
errorMessage = new Error(errorMessage).toString();
}
const result = response.result;
const helperResult = response.helperResult;
const helperHasRawOutput = !!(helperResult || {}).rawOutput;
if (helperResult && helperResult.type) {
switch (helperResult.type) {
case "clearOutput":
this.hud.clearOutput();
break;
case "clearHistory":
this.props.clearHistory();
break;
case "inspectObject":
this.inspectObjectActor(helperResult.object);
break;
case "error":
try {
errorMessage = l10n.getStr(helperResult.message);
} catch (ex) {
errorMessage = helperResult.message;
}
break;
case "help":
this.hud.owner.openLink(HELP_URL);
break;
case "copyValueToClipboard":
clipboardHelper.copyString(helperResult.value);
break;
case "screenshotOutput":
const { args, value } = helperResult;
const results = await processScreenshot(this.hud.window, args, value);
this.screenshotNotify(results);
// early return as screenshot notify has dispatched all necessary messages
return null;
}
}
// Hide undefined results coming from JSTerm helper functions.
if (!errorMessage && result && typeof result == "object" &&
result.type == "undefined" &&
helperResult && !helperHasRawOutput) {
return null;
}
if (this.hud.consoleOutput) {
return this.hud.consoleOutput.dispatchMessageAdd(response, true);
}
return null;
}
inspectObjectActor(objectActor) {
this.hud.consoleOutput.dispatchMessageAdd({
helperResult: {
type: "inspectObject",
object: objectActor
}
}, true);
return this.hud.consoleOutput;
}
screenshotNotify(results) {
const wrappedResults = results.map(message => ({ message, type: "logMessage" }));
this.hud.consoleOutput.dispatchMessagesAdd(wrappedResults);
}
/**
* Execute a string. Execution happens asynchronously in the content process.
*
* @param {String} executeString
* The string you want to execute. If this is not provided, the current
* user input is used - taken from |this.getInputValue()|.
* @returns {Promise}
* Resolves with the message once the result is displayed.
*/
async execute(executeString) {
// attempt to execute the content of the inputNode
executeString = executeString || this.getInputValue();
if (!executeString) {
return null;
}
// Append executed expression into the history list.
this.props.appendToHistory(executeString);
WebConsoleUtils.usageCount++;
this.setInputValue("");
this.clearCompletion();
let selectedNodeActor = null;
const inspectorSelection = this.hud.owner.getInspectorSelection();
if (inspectorSelection && inspectorSelection.nodeFront) {
selectedNodeActor = inspectorSelection.nodeFront.actorID;
}
const { ConsoleCommand } = require("devtools/client/webconsole/types");
const cmdMessage = new ConsoleCommand({
messageText: executeString,
});
this.hud.proxy.dispatchMessageAdd(cmdMessage);
const options = {
frame: this.SELECTED_FRAME,
selectedNodeActor,
};
const mappedString = await this.hud.owner.getMappedExpression(executeString);
// Even if requestEvaluation rejects (because of webConsoleClient.evaluateJSAsync),
// we still need to pass the error response to executeResultCallback.
const onEvaluated = this.requestEvaluation(mappedString, options)
.then(res => res, res => res);
const response = await onEvaluated;
return this._executeResultCallback(response);
}
/**
* Request a JavaScript string evaluation from the server.
*
* @param string str
* String to execute.
* @param object [options]
* Options for evaluation:
* - bindObjectActor: tells the ObjectActor ID for which you want to do
* the evaluation. The Debugger.Object of the OA will be bound to
* |_self| during evaluation, such that it's usable in the string you
* execute.
* - frame: tells the stackframe depth to evaluate the string in. If
* the jsdebugger is paused, you can pick the stackframe to be used for
* evaluation. Use |this.SELECTED_FRAME| to always pick th;
* user-selected stackframe.
* If you do not provide a |frame| the string will be evaluated in the
* global content window.
* - selectedNodeActor: tells the NodeActor ID of the current selection
* in the Inspector, if such a selection exists. This is used by
* helper functions that can evaluate on the current selection.
* @return object
* A promise object that is resolved when the server response is
* received.
*/
requestEvaluation(str, options = {}) {
// Send telemetry event. If we are in the browser toolbox we send -1 as the
// toolbox session id.
this.props.serviceContainer.recordTelemetryEvent("execute_js", {
"lines": str.split(/\n/).length
});
let frameActor = null;
if ("frame" in options) {
frameActor = this.getFrameActor(options.frame);
}
const evalOptions = {
bindObjectActor: options.bindObjectActor,
frameActor,
selectedNodeActor: options.selectedNodeActor,
selectedObjectActor: options.selectedObjectActor,
};
return this.webConsoleClient.evaluateJSAsync(str, null, evalOptions);
}
/**
* Copy the object/variable by invoking the server
* which invokes the `copy(variable)` command and makes it
* available in the clipboard
* @param evalString - string which has the evaluation string to be copied
* @param options - object - Options for evaluation
* @return object
* A promise object that is resolved when the server response is
* received.
*/
copyObject(evalString, evalOptions) {
return this.webConsoleClient.evaluateJSAsync(`copy(${evalString})`,
null, evalOptions);
}
/**
* Retrieve the FrameActor ID given a frame depth.
*
* @param number frame
* Frame depth.
* @return string|null
* The FrameActor ID for the given frame depth.
*/
getFrameActor(frame) {
const state = this.hud.owner.getDebuggerFrames();
if (!state) {
return null;
}
let grip;
if (frame == this.SELECTED_FRAME) {
grip = state.frames[state.selected];
} else {
grip = state.frames[frame];
}
return grip ? grip.actor : null;
}
/**
* Updates the size of the input field (command line) to fit its contents.
*
* @returns void
*/
resizeInput() {
if (this.props.codeMirrorEnabled) {
return;
}
if (!this.inputNode) {
return;
}
const inputNode = this.inputNode;
// Reset the height so that scrollHeight will reflect the natural height of
// the contents of the input field.
inputNode.style.height = "auto";
// Now resize the input field to fit its contents.
const scrollHeight = inputNode.scrollHeight;
if (scrollHeight > 0) {
inputNode.style.height = (scrollHeight + this.inputBorderSize) + "px";
}
}
/**
* 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 = "") {
if (this.props.codeMirrorEnabled) {
if (this.editor) {
// In order to get the autocomplete popup to work properly, we need to set the
// editor text and the cursor in the same operation. If we don't, the text change
// is done before the cursor is moved, and the autocompletion call to the server
// sends an erroneous query.
this.editor.codeMirror.operation(() => {
this.editor.setText(newValue);
// 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();
});
}
} else {
if (!this.inputNode) {
return;
}
this.inputNode.value = newValue;
this.completeNode.value = "";
}
this.lastInputValue = newValue;
this.resizeInput();
this.emit("set-input-value");
}
/**
* Gets the value from the input field
* @returns string
*/
getInputValue() {
if (this.props.codeMirrorEnabled) {
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() {
const value = this.getInputValue();
if (this.lastInputValue !== value) {
this.resizeInput();
this.updateAutocompletion();
this.lastInputValue = value;
}
}
/**
* The window "blur" event handler.
* @private
*/
_blurEventHandler() {
if (this.autocompletePopup) {
this.clearCompletion();
}
}
/* eslint-disable complexity */
/**
* The inputNode "keypress" event handler.
*
* @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;
if (this.hasMultilineInput()) {
// find index of closest newline >= cursor
for (let i = inputNode.selectionEnd; i < lineEndPos; i++) {
if (inputValue.charAt(i) == "\r" ||
inputValue.charAt(i) == "\n") {
lineEndPos = i;
break;
}
}
}
inputNode.setSelectionRange(lineEndPos, lineEndPos);
event.preventDefault();
this.clearCompletion();
break;
case 110:
// 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)) {
event.preventDefault();
// Ctrl-N is also used to focus the Network category button on
// MacOSX. The preventDefault() call doesn't prevent the focus
// from moving away from the input.
this.focus();
}
this.clearCompletion();
break;
case 112:
// 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)) {
event.preventDefault();
// Ctrl-P may also be used to focus some category button on MacOSX.
// The preventDefault() call doesn't prevent the focus from moving
// away from the input.
this.focus();
}
this.clearCompletion();
break;
default:
break;
}
return;
} else if (event.keyCode == KeyCodes.DOM_VK_RETURN) {
if (!this.autocompletePopup.isOpen && (
event.shiftKey || !Debugger.isCompilableUnit(this.getInputValue())
)) {
// shift return or incomplete statement
return;
}
}
switch (event.keyCode) {
case KeyCodes.DOM_VK_ESCAPE:
if (this.autocompletePopup.isOpen) {
this.clearCompletion();
event.preventDefault();
event.stopPropagation();
}
break;
case KeyCodes.DOM_VK_RETURN:
if (this.hasAutocompletionSuggestion()) {
this.acceptProposedCompletion();
} else {
this.execute();
}
event.preventDefault();
break;
case KeyCodes.DOM_VK_UP:
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectPreviousItem();
event.preventDefault();
} else if (this.canCaretGoPrevious()) {
inputUpdated = this.historyPeruse(HISTORY_BACK);
}
if (inputUpdated) {
event.preventDefault();
}
break;
case KeyCodes.DOM_VK_DOWN:
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectNextItem();
event.preventDefault();
} else if (this.canCaretGoNext()) {
inputUpdated = this.historyPeruse(HISTORY_FORWARD);
}
if (inputUpdated) {
event.preventDefault();
}
break;
case KeyCodes.DOM_VK_PAGE_UP:
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectPreviousPageItem();
} else {
this.hud.outputScroller.scrollTop =
Math.max(0,
this.hud.outputScroller.scrollTop -
this.hud.outputScroller.clientHeight
);
}
event.preventDefault();
break;
case KeyCodes.DOM_VK_PAGE_DOWN:
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectNextPageItem();
} else {
this.hud.outputScroller.scrollTop =
Math.min(this.hud.outputScroller.scrollHeight,
this.hud.outputScroller.scrollTop +
this.hud.outputScroller.clientHeight
);
}
event.preventDefault();
break;
case KeyCodes.DOM_VK_HOME:
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectedIndex = 0;
event.preventDefault();
} else if (inputValue.length <= 0) {
this.hud.outputScroller.scrollTop = 0;
event.preventDefault();
}
break;
case KeyCodes.DOM_VK_END:
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.selectedIndex = this.autocompletePopup.itemCount - 1;
event.preventDefault();
} else if (inputValue.length <= 0) {
this.hud.outputScroller.scrollTop = this.hud.outputScroller.scrollHeight;
event.preventDefault();
}
break;
case KeyCodes.DOM_VK_LEFT:
if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) {
this.clearCompletion();
}
break;
case KeyCodes.DOM_VK_RIGHT:
// We only want to complete on Right arrow if the completion text is
// displayed.
if (this.getAutoCompletionText()) {
this.acceptProposedCompletion();
event.preventDefault();
}
this.clearCompletion();
break;
case KeyCodes.DOM_VK_TAB:
if (this.hasAutocompletionSuggestion()) {
this.acceptProposedCompletion();
event.preventDefault();
} else if (!this.hasEmptyInput()) {
if (!event.shiftKey) {
this.insertStringAtCursor("\t");
}
event.preventDefault();
}
break;
default:
break;
}
}
/* eslint-enable complexity */
/**
* 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.getInputValue();
updateHistoryPosition(direction, expression);
if (newInputValue != null) {
this.setInputValue(newInputValue);
return true;
}
return false;
}
/**
* Test for empty input.
*
* @return boolean
*/
hasEmptyInput() {
return this.getInputValue() === "";
}
/**
* Test for multiline input.
*
* @return boolean
* True if CR or LF found in node value; else false.
*/
hasMultilineInput() {
return /[\r\n]/.test(this.getInputValue());
}
/**
* 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() {
const inputValue = this.getInputValue();
if (this.editor) {
const {line, ch} = this.editor.getCursor();
return (line === 0 && ch === 0) || (line === 0 && ch === inputValue.length);
}
const node = this.inputNode;
if (node.selectionStart != node.selectionEnd) {
return false;
}
const multiline = /[\r\n]/.test(inputValue);
return node.selectionStart == 0 ? true :
node.selectionStart == inputValue.length && !multiline;
}
/**
* 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() {
const inputValue = this.getInputValue();
const multiline = /[\r\n]/.test(inputValue);
if (this.editor) {
const {line, ch} = this.editor.getCursor();
return (!multiline && ch === 0) ||
this.editor.getDoc()
.getRange({line: 0, ch: 0}, {line, ch})
.length === inputValue.length;
}
const node = this.inputNode;
if (node.selectionStart != node.selectionEnd) {
return false;
}
return node.selectionStart == node.value.length ? true :
node.selectionStart == 0 && !multiline;
}
async updateAutocompletion() {
const inputValue = this.getInputValue();
const {editor, inputNode} = this;
const frameActor = this.getFrameActor(this.SELECTED_FRAME);
// Only complete if the selection is empty and the input value is not.
if (
!inputValue ||
(inputNode && inputNode.selectionStart != inputNode.selectionEnd) ||
(editor && editor.getSelection()) ||
(this.lastInputValue === inputValue && frameActor === this._lastFrameActorId)
) {
this.clearCompletion();
this.emit("autocomplete-updated");
return;
}
const cursor = this.getSelectionStart();
const input = inputValue.substring(0, cursor);
// 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-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 = this._autocompleteCache.sort().filter(l => l.startsWith(filterBy));
this._receiveAutocompleteProperties(null, {
matches: newList,
matchProp: filterBy
});
return;
}
const requestId = gSequenceId();
this._lastFrameActorId = frameActor;
this.currentAutoCompletionRequestId = requestId;
const message = await this.webConsoleClient.autocomplete(input, cursor, frameActor);
this._receiveAutocompleteProperties(requestId, message);
}
/**
* 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 object message
* The JSON message which holds the completion results received from
* the content process.
*/
_receiveAutocompleteProperties(requestId, message) {
if (this.currentAutoCompletionRequestId !== requestId) {
return;
}
this.currentAutoCompletionRequestId = null;
// Cache whatever came from the server if the last char is
// alphanumeric or '.'
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();
this.emit("autocomplete-updated");
return;
}
const popup = this.autocompletePopup;
const items = matches.map(match => ({ preLabel: lastPart, label: match }));
popup.setItems(items);
const minimumAutoCompleteLength = 2;
if (items.length >= minimumAutoCompleteLength) {
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 = 5;
} 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);
}
} else if (items.length < minimumAutoCompleteLength && popup.isOpen) {
popup.hidePopup();
}
if (items.length > 0) {
const suffix = items[0].label.substring(lastPart.length);
this.setAutoCompletionText(suffix);
}
this.emit("autocomplete-updated");
}
onAutocompleteSelect() {
const {selectedItem} = this.autocompletePopup;
if (selectedItem) {
const suffix = selectedItem.label.substring(selectedItem.preLabel.length);
this.setAutoCompletionText(suffix);
} else {
this.setAutoCompletionText("");
}
}
/**
* Clear the current completion information and close the autocomplete popup,
* if needed.
*/
clearCompletion() {
this.setAutoCompletionText("");
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.
if (this.inputNode) {
this.inputNode.blur();
}
this.autocompletePopup.once("popup-closed", () => {
this.focus();
});
this.autocompletePopup.hidePopup();
}
}
}
/**
* Accept the proposed input completion.
*
* @return boolean
* True if there was a selected completion item and the input value
* was updated, false otherwise.
*/
acceptProposedCompletion() {
let completionText = this.getAutoCompletionText();
// In some cases the completion text might not be displayed (e.g. there is some text
// just after the cursor so we can't display it). In those case, if the popup is
// open and has a selectedItem, we use it for completing the input.
if (
!completionText
&& this.autocompletePopup.isOpen
&& this.autocompletePopup.selectedItem
) {
const {selectedItem} = this.autocompletePopup;
completionText = selectedItem.label.substring(selectedItem.preLabel.length);
}
this.clearCompletion();
if (completionText) {
this.insertStringAtCursor(completionText);
}
}
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;
}
/**
* 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 value = this.getInputValue();
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
});
}
}
/**
* Set the autocompletion text of the input.
*
* @param string suffix
* The proposed suffix for the inputNode value.
*/
setAutoCompletionText(suffix) {
if (suffix && !this.canDisplayAutoCompletionText()) {
suffix = "";
}
if (this.completeNode) {
const lines = this.getInputValueBeforeCursor().split("\n");
const lastLine = lines[lines.length - 1];
const prefix = ("\n".repeat(lines.length - 1)) + lastLine.replace(/[\S]/g, " ");
this.completeNode.value = suffix ? prefix + suffix : "";
}
if (this.editor) {
this.editor.setAutoCompletionText(suffix);
}
}
getAutoCompletionText() {
if (this.completeNode) {
// Remove the spaces we set to align with the input value.
return this.completeNode.value.replace(/^\s+/gm, "");
}
if (this.editor) {
return this.editor.getAutoCompletionText();
}
return null;
}
/**
* 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) {
const { ch, line } = this.editor.getCursor();
const lineContent = this.editor.getLine(line);
const textAfterCursor = lineContent.substring(ch);
return textAfterCursor === "";
}
if (this.inputNode) {
const value = this.getInputValue();
const textAfterCursor = value.substring(this.inputNode.selectionStart);
return textAfterCursor.split("\n")[0] === "";
}
return false;
}
/**
* 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() {
if (!this.inputNode && !this.node) {
return null;
}
if (this.editor) {
return this.editor.defaultCharWidth();
}
const doc = this.hud.document;
const tempLabel = doc.createElement("span");
const style = tempLabel.style;
style.position = "fixed";
style.padding = "0";
style.margin = "0";
style.width = "auto";
style.color = "transparent";
WebConsoleUtils.copyTextStyles(this.inputNode, tempLabel);
tempLabel.textContent = "x";
doc.documentElement.appendChild(tempLabel);
const width = tempLabel.offsetWidth;
tempLabel.remove();
return width;
}
/**
* Calculates and returns the width of the chevron icon.
* This will be used in opening the popup at the correct offset.
*
* @returns {Number|null}: Width of the icon, or null if the input does not exist.
*/
_getChevronWidth() {
if (!this.inputNode) {
return null;
}
// Calculate the width of the chevron placed at the beginning of the input
// box. Remove 4 more pixels to accommodate the padding of the popup.
const doc = this.hud.document;
return doc.defaultView
.getComputedStyle(this.inputNode)
.paddingLeft.replace(/[^0-9.]/g, "") - 4;
}
onContextMenu(e) {
// The toolbox does it's own edit menu handling with
// toolbox-textbox-context-popup and friends. For now, fall
// back to use that if running inside the toolbox, but use our
// own menu when running in the Browser Console (see Bug 1476097).
if (this.props.hud.isBrowserConsole) {
this.props.serviceContainer.openEditContextMenu(e);
}
}
destroy() {
this.clearCompletion();
this.webConsoleClient.clearNetworkRequests();
if (this.hud.outputNode) {
// We do this because it's much faster than letting React handle the ConsoleOutput
// unmounting.
this.hud.outputNode.innerHTML = "";
}
if (this.autocompletePopup) {
this.autocompletePopup.destroy();
this.autocompletePopup = null;
}
if (this.inputNode) {
this.inputNode.removeEventListener("keypress", this._keyPress);
this.inputNode.removeEventListener("input", this._inputEventHandler);
this.inputNode.removeEventListener("keyup", this._inputEventHandler);
this.hud.window.removeEventListener("blur", this._blurEventHandler);
}
if (this.editor) {
this.editor.destroy();
this.editor = null;
}
this.hud = null;
}
render() {
if (this.props.hud.isBrowserConsole &&
!Services.prefs.getBoolPref("devtools.chrome.enabled")) {
return null;
}
if (this.props.codeMirrorEnabled) {
return dom.div({
className: "jsterm-input-container devtools-monospace",
key: "jsterm-container",
style: {direction: "ltr"},
"aria-live": "off",
onContextMenu: this.onContextMenu,
ref: node => {
this.node = node;
},
});
}
const {
onPaste
} = this.props;
return (
dom.div({
className: "jsterm-input-container",
key: "jsterm-container",
style: {direction: "ltr"},
"aria-live": "off",
},
dom.textarea({
className: "jsterm-complete-node devtools-monospace",
key: "complete",
tabIndex: "-1",
ref: node => {
this.completeNode = node;
},
}),
dom.textarea({
className: "jsterm-input-node devtools-monospace",
key: "input",
tabIndex: "0",
rows: "1",
"aria-autocomplete": "list",
ref: node => {
this.inputNode = node;
},
onPaste: onPaste,
onDrop: onPaste,
onContextMenu: this.onContextMenu,
})
)
);
}
}
// Redux connect
function mapStateToProps(state) {
return {
history: getHistory(state),
getValueFromHistory: (direction) => getHistoryValue(state, direction),
};
}
function mapDispatchToProps(dispatch) {
return {
appendToHistory: (expr) => dispatch(historyActions.appendToHistory(expr)),
clearHistory: () => dispatch(historyActions.clearHistory()),
updateHistoryPosition: (direction, expression) =>
dispatch(historyActions.updateHistoryPosition(direction, expression)),
};
}
module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);