summaryrefslogtreecommitdiffstats
path: root/devtools/client/webconsole/components/Input
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/webconsole/components/Input')
-rw-r--r--devtools/client/webconsole/components/Input/ConfirmDialog.js196
-rw-r--r--devtools/client/webconsole/components/Input/EagerEvaluation.css122
-rw-r--r--devtools/client/webconsole/components/Input/EagerEvaluation.js142
-rw-r--r--devtools/client/webconsole/components/Input/EditorToolbar.js169
-rw-r--r--devtools/client/webconsole/components/Input/EvaluationContextSelector.css33
-rw-r--r--devtools/client/webconsole/components/Input/EvaluationContextSelector.js225
-rw-r--r--devtools/client/webconsole/components/Input/JSTerm.js1594
-rw-r--r--devtools/client/webconsole/components/Input/ReverseSearchInput.css124
-rw-r--r--devtools/client/webconsole/components/Input/ReverseSearchInput.js284
-rw-r--r--devtools/client/webconsole/components/Input/moz.build13
10 files changed, 2902 insertions, 0 deletions
diff --git a/devtools/client/webconsole/components/Input/ConfirmDialog.js b/devtools/client/webconsole/components/Input/ConfirmDialog.js
new file mode 100644
index 0000000000..f46ed78ade
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/ConfirmDialog.js
@@ -0,0 +1,196 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "PropTypes",
+ "devtools/client/shared/vendor/react-prop-types"
+);
+loader.lazyRequireGetter(
+ this,
+ "HTMLTooltip",
+ "devtools/client/shared/widgets/tooltip/HTMLTooltip",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "createPortal",
+ "devtools/client/shared/vendor/react-dom",
+ true
+);
+
+// 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");
+
+const {
+ getAutocompleteState,
+} = require("devtools/client/webconsole/selectors/autocomplete");
+const autocompleteActions = require("devtools/client/webconsole/actions/autocomplete");
+const { l10n } = require("devtools/client/webconsole/utils/messages");
+
+const utmParams = new URLSearchParams({
+ utm_source: "mozilla",
+ utm_medium: "devtools-webconsole",
+ utm_campaign: "default",
+});
+const LEARN_MORE_URL = `https://developer.mozilla.org/docs/Tools/Web_Console/Invoke_getters_from_autocomplete?${utmParams}`;
+
+class ConfirmDialog extends Component {
+ static get propTypes() {
+ return {
+ // Console object.
+ webConsoleUI: PropTypes.object.isRequired,
+ // Update autocomplete popup state.
+ autocompleteUpdate: PropTypes.func.isRequired,
+ autocompleteClear: PropTypes.func.isRequired,
+ // Data to be displayed in the confirm dialog.
+ getterPath: PropTypes.array,
+ serviceContainer: PropTypes.object.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ const { webConsoleUI } = props;
+ webConsoleUI.confirmDialog = this;
+
+ this.cancel = this.cancel.bind(this);
+ this.confirm = this.confirm.bind(this);
+ this.onLearnMoreClick = this.onLearnMoreClick.bind(this);
+ }
+
+ componentDidMount() {
+ const doc = this.props.webConsoleUI.document;
+ const { toolbox } = this.props.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.tooltip = new HTMLTooltip(tooltipDoc, {
+ className: "invoke-confirm",
+ });
+ }
+
+ componentDidUpdate() {
+ const { getterPath, serviceContainer } = this.props;
+
+ if (getterPath) {
+ this.tooltip.show(serviceContainer.getJsTermTooltipAnchor(), { y: 5 });
+ } else {
+ this.tooltip.hide();
+ this.props.webConsoleUI.jsterm.focus();
+ }
+ }
+
+ componentDidThrow(e) {
+ console.error("Error in ConfirmDialog", e);
+ this.setState(state => ({ ...state, hasError: true }));
+ }
+
+ onLearnMoreClick(e) {
+ this.props.serviceContainer.openLink(LEARN_MORE_URL, e);
+ }
+
+ cancel() {
+ this.tooltip.hide();
+ this.props.autocompleteClear();
+ }
+
+ confirm() {
+ this.tooltip.hide();
+ this.props.autocompleteUpdate(this.props.getterPath);
+ }
+
+ render() {
+ if (
+ (this.state && this.state.hasError) ||
+ !this.props ||
+ !this.props.getterPath
+ ) {
+ return null;
+ }
+
+ const { getterPath } = this.props;
+ const getterName = getterPath.join(".");
+
+ // We deliberately use getStr, and not getFormatStr, because we want getterName to
+ // be wrapped in its own span.
+ const description = l10n.getStr("webconsole.confirmDialog.getter.label");
+ const [descriptionPrefix, descriptionSuffix] = description.split("%S");
+
+ const closeButtonTooltip = l10n.getFormatStr(
+ "webconsole.confirmDialog.getter.closeButton.tooltip",
+ ["Esc"]
+ );
+ const invokeButtonLabel = l10n.getFormatStr(
+ "webconsole.confirmDialog.getter.invokeButtonLabelWithShortcut",
+ ["Tab"]
+ );
+
+ const learnMoreElement = dom.a(
+ {
+ className: "learn-more-link",
+ key: "learn-more-link",
+ title: LEARN_MORE_URL.split("?")[0],
+ onClick: this.onLearnMoreClick,
+ },
+ l10n.getStr("webConsoleMoreInfoLabel")
+ );
+
+ return createPortal(
+ [
+ dom.div(
+ {
+ className: "confirm-label",
+ key: "confirm-label",
+ },
+ dom.p(
+ {},
+ dom.span({}, descriptionPrefix),
+ dom.span({ className: "emphasized" }, getterName),
+ dom.span({}, descriptionSuffix)
+ ),
+ dom.button({
+ className: "devtools-button close-confirm-dialog-button",
+ key: "close-button",
+ title: closeButtonTooltip,
+ onClick: this.cancel,
+ })
+ ),
+ dom.button(
+ {
+ className: "confirm-button",
+ key: "confirm-button",
+ onClick: this.confirm,
+ },
+ invokeButtonLabel
+ ),
+ learnMoreElement,
+ ],
+ this.tooltip.panel
+ );
+ }
+}
+
+// Redux connect
+function mapStateToProps(state) {
+ const autocompleteData = getAutocompleteState(state);
+ return {
+ getterPath: autocompleteData.getterPath,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ autocompleteUpdate: getterPath =>
+ dispatch(autocompleteActions.autocompleteUpdate(true, getterPath)),
+ autocompleteClear: () => dispatch(autocompleteActions.autocompleteClear()),
+ };
+}
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(ConfirmDialog);
diff --git a/devtools/client/webconsole/components/Input/EagerEvaluation.css b/devtools/client/webconsole/components/Input/EagerEvaluation.css
new file mode 100644
index 0000000000..ac47159892
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EagerEvaluation.css
@@ -0,0 +1,122 @@
+/* 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/. */
+
+.eager-evaluation-result {
+ flex: none;
+ font-family: var(--monospace-font-family);
+ font-size: var(--theme-code-font-size);
+ line-height: var(--console-output-line-height);
+ color: var(--theme-text-color-alt);
+}
+
+.theme-light .eager-evaluation-result {
+ --log-icon-color: var(--grey-35);
+ /* Override Reps variables to turn eager eval output gray */
+ --object-color: var(--grey-50);
+ --number-color: var(--grey-50);
+ --string-color: var(--grey-50);
+ --node-color: var(--grey-50);
+ --reference-color: var(--grey-50);
+ --location-color: var(--grey-43);
+ --source-link-color: var(--grey-43);
+ --null-color: var(--grey-43);
+}
+
+.theme-dark .eager-evaluation-result {
+ --log-icon-color: var(--grey-55);
+ /* Override Reps variables to turn eager eval output gray */
+ --object-color: var(--grey-43);
+ --number-color: var(--grey-43);
+ --string-color: var(--grey-43);
+ --node-color: var(--grey-43);
+ --reference-color: var(--grey-43);
+ --location-color: var(--grey-50);
+ --source-link-color: var(--grey-50);
+ --null-color: var(--grey-50);
+}
+
+.eager-evaluation-result__row {
+ direction: ltr;
+ display: flex;
+ align-items: center;
+ overflow-y: hidden;
+ height: var(--console-row-height);
+ padding: 0 2px;
+}
+
+.eager-evaluation-result__icon {
+ flex: none;
+ width: 14px;
+ height: 14px;
+ margin: 0 8px;
+ background: url(chrome://devtools/skin/images/webconsole/return.svg) no-repeat
+ center;
+ background-size: 12px;
+ -moz-context-properties: fill;
+ fill: var(--log-icon-color);
+}
+
+.eager-evaluation-result__text {
+ flex: 1 1 auto;
+ height: 14px;
+ overflow: hidden;
+ /* Use pre rather than nowrap because we want to preserve consecutive spaces,
+ * e.g. if we display "some string" we should not collapse spaces. */
+ white-space: pre;
+}
+
+/* Style the reps result */
+.eager-evaluation-result__text > * {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.eager-evaluation-result__text * {
+ /* Some Reps elements define white-space:pre-wrap, which lets the text break
+ * to a new line */
+ white-space: inherit !important;
+}
+
+.eager-evaluation-result__text .objectBox-function .param {
+ color: var(--null-color);
+}
+
+/* Object property label */
+.eager-evaluation-result__text .nodeName {
+ color: var(--object-color);
+}
+
+/*
+ * Inline mode specifics
+ */
+.webconsole-app:not(.jsterm-editor) .eager-evaluation-result {
+ /* It should fill the remaining height in the output+input area */
+ flex-grow: 1;
+ background-color: var(--console-input-background);
+ /* Reserve a bit of whitespace after the content. */
+ min-height: calc(
+ var(--console-row-height) + var(--console-input-extra-padding)
+ );
+}
+
+/*
+ * Editor mode specifics
+ */
+.webconsole-app.jsterm-editor .eager-evaluation-result {
+ border-top: 1px solid var(--theme-splitter-color);
+ border-inline-end: 1px solid var(--theme-splitter-color);
+ /* Make text smaller when displayed in the sidebar */
+ font-size: 10px;
+ line-height: 14px;
+ background-color: var(--theme-sidebar-background);
+}
+
+.webconsole-app.jsterm-editor .eager-evaluation-result:empty {
+ display: none;
+}
+
+.webconsole-app.jsterm-editor .eager-evaluation-result__row {
+ height: var(--theme-toolbar-height);
+}
diff --git a/devtools/client/webconsole/components/Input/EagerEvaluation.js b/devtools/client/webconsole/components/Input/EagerEvaluation.js
new file mode 100644
index 0000000000..e70d0ad290
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EagerEvaluation.js
@@ -0,0 +1,142 @@
+/* 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 { 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");
+
+const {
+ getTerminalEagerResult,
+} = require("devtools/client/webconsole/selectors/history");
+
+const actions = require("devtools/client/webconsole/actions/index");
+
+loader.lazyGetter(this, "REPS", function() {
+ return require("devtools/client/shared/components/reps/index").REPS;
+});
+loader.lazyGetter(this, "MODE", function() {
+ return require("devtools/client/shared/components/reps/index").MODE;
+});
+loader.lazyRequireGetter(
+ this,
+ "PropTypes",
+ "devtools/client/shared/vendor/react-prop-types"
+);
+
+/**
+ * Show the results of evaluating the current terminal text, if possible.
+ */
+class EagerEvaluation extends Component {
+ static get propTypes() {
+ return {
+ terminalEagerResult: PropTypes.any,
+ serviceContainer: PropTypes.object.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ };
+ }
+
+ static getDerivedStateFromError(error) {
+ return { hasError: true };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ highlightDomElement,
+ unHighlightDomElement,
+ terminalEagerResult,
+ } = this.props;
+
+ if (canHighlightObject(prevProps.terminalEagerResult)) {
+ unHighlightDomElement(prevProps.terminalEagerResult.getGrip());
+ }
+
+ if (canHighlightObject(terminalEagerResult)) {
+ highlightDomElement(terminalEagerResult.getGrip());
+ }
+
+ if (this.state?.hasError) {
+ // If the render function threw at some point, clear the error after 1s so the
+ // component has a chance to render again.
+ // This way, we don't block instant evaluation for the whole session, in case the
+ // input changed in the meantime. If the input didn't change, we'll hit
+ // getDerivatedStateFromError again (and this won't render anything), so it's safe.
+ setTimeout(() => {
+ this.setState({ hasError: false });
+ }, 1000);
+ }
+ }
+
+ componentWillUnmount() {
+ const { unHighlightDomElement, terminalEagerResult } = this.props;
+
+ if (canHighlightObject(terminalEagerResult)) {
+ unHighlightDomElement(terminalEagerResult.getGrip());
+ }
+ }
+
+ renderRepsResult() {
+ const { terminalEagerResult } = this.props;
+
+ const result = terminalEagerResult.getGrip
+ ? terminalEagerResult.getGrip()
+ : terminalEagerResult;
+ const { isError } = result || {};
+
+ return REPS.Rep({
+ key: "rep",
+ object: result,
+ mode: isError ? MODE.SHORT : MODE.LONG,
+ });
+ }
+
+ render() {
+ const hasResult =
+ this.props.terminalEagerResult !== null && !this.state?.hasError;
+
+ return dom.div(
+ { className: "eager-evaluation-result", key: "eager-evaluation-result" },
+ hasResult
+ ? dom.span(
+ { className: "eager-evaluation-result__row" },
+ dom.span({
+ className: "eager-evaluation-result__icon",
+ key: "icon",
+ }),
+ dom.span(
+ { className: "eager-evaluation-result__text", key: "text" },
+ this.renderRepsResult()
+ )
+ )
+ : null
+ );
+ }
+}
+
+function canHighlightObject(obj) {
+ const grip = obj?.getGrip && obj.getGrip();
+ return (
+ grip &&
+ (REPS.ElementNode.supportsObject(grip) ||
+ REPS.TextNode.supportsObject(grip)) &&
+ grip.preview.isConnected
+ );
+}
+
+function mapStateToProps(state) {
+ return {
+ terminalEagerResult: getTerminalEagerResult(state),
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ highlightDomElement: grip => dispatch(actions.highlightDomElement(grip)),
+ unHighlightDomElement: grip =>
+ dispatch(actions.unHighlightDomElement(grip)),
+ };
+}
+module.exports = connect(mapStateToProps, mapDispatchToProps)(EagerEvaluation);
diff --git a/devtools/client/webconsole/components/Input/EditorToolbar.js b/devtools/client/webconsole/components/Input/EditorToolbar.js
new file mode 100644
index 0000000000..75fc7dffcd
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EditorToolbar.js
@@ -0,0 +1,169 @@
+/* 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";
+
+// React & Redux
+const {
+ Component,
+ createFactory,
+} = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+const EvaluationContextSelector = createFactory(
+ require("devtools/client/webconsole/components/Input/EvaluationContextSelector")
+);
+
+const actions = require("devtools/client/webconsole/actions/index");
+const { l10n } = require("devtools/client/webconsole/utils/messages");
+const Services = require("Services");
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+// Constants used for defining the direction of JSTerm input history navigation.
+const {
+ HISTORY_BACK,
+ HISTORY_FORWARD,
+} = require("devtools/client/webconsole/constants");
+
+class EditorToolbar extends Component {
+ static get propTypes() {
+ return {
+ editorMode: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
+ reverseSearchInputVisible: PropTypes.bool.isRequired,
+ serviceContainer: PropTypes.object.isRequired,
+ webConsoleUI: PropTypes.object.isRequired,
+ showEvaluationContextSelector: PropTypes.bool,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onReverseSearchButtonClick = this.onReverseSearchButtonClick.bind(
+ this
+ );
+ }
+
+ onReverseSearchButtonClick(event) {
+ const { dispatch, serviceContainer } = this.props;
+
+ event.stopPropagation();
+ dispatch(
+ actions.reverseSearchInputToggle({
+ initialValue: serviceContainer.getInputSelection(),
+ access: "editor-toolbar-icon",
+ })
+ );
+ }
+
+ renderEvaluationContextSelector() {
+ if (
+ !this.props.webConsoleUI.wrapper.toolbox ||
+ !this.props.showEvaluationContextSelector
+ ) {
+ return null;
+ }
+
+ return EvaluationContextSelector({
+ webConsoleUI: this.props.webConsoleUI,
+ });
+ }
+
+ render() {
+ const {
+ editorMode,
+ dispatch,
+ reverseSearchInputVisible,
+ webConsoleUI,
+ } = this.props;
+
+ if (!editorMode) {
+ return null;
+ }
+
+ const enterStr = l10n.getStr("webconsole.enterKey");
+
+ return dom.div(
+ {
+ className:
+ "devtools-toolbar devtools-input-toolbar webconsole-editor-toolbar",
+ },
+ dom.button(
+ {
+ className: "devtools-button webconsole-editor-toolbar-executeButton",
+ title: l10n.getFormatStr(
+ "webconsole.editor.toolbar.executeButton.tooltip",
+ [isMacOS ? `Cmd + ${enterStr}` : `Ctrl + ${enterStr}`]
+ ),
+ onClick: () => dispatch(actions.evaluateExpression()),
+ },
+ l10n.getStr("webconsole.editor.toolbar.executeButton.label")
+ ),
+ this.renderEvaluationContextSelector(),
+ dom.button({
+ className:
+ "devtools-button webconsole-editor-toolbar-prettyPrintButton",
+ title: l10n.getStr(
+ "webconsole.editor.toolbar.prettyPrintButton.tooltip"
+ ),
+ onClick: () => dispatch(actions.prettyPrintEditor()),
+ }),
+ dom.div({
+ className:
+ "devtools-separator webconsole-editor-toolbar-prettyPrintSeparator",
+ }),
+ dom.button({
+ className:
+ "devtools-button webconsole-editor-toolbar-history-prevExpressionButton",
+ title: l10n.getStr(
+ "webconsole.editor.toolbar.history.prevExpressionButton.tooltip"
+ ),
+ onClick: () => {
+ webConsoleUI.jsterm.historyPeruse(HISTORY_BACK);
+ },
+ }),
+ dom.button({
+ className:
+ "devtools-button webconsole-editor-toolbar-history-nextExpressionButton",
+ title: l10n.getStr(
+ "webconsole.editor.toolbar.history.nextExpressionButton.tooltip"
+ ),
+ onClick: () => {
+ webConsoleUI.jsterm.historyPeruse(HISTORY_FORWARD);
+ },
+ }),
+ dom.button({
+ className: `devtools-button webconsole-editor-toolbar-reverseSearchButton ${
+ reverseSearchInputVisible ? "checked" : ""
+ }`,
+ title: reverseSearchInputVisible
+ ? l10n.getFormatStr(
+ "webconsole.editor.toolbar.reverseSearchButton.closeReverseSearch.tooltip",
+ ["Esc" + (isMacOS ? " | Ctrl + C" : "")]
+ )
+ : l10n.getFormatStr(
+ "webconsole.editor.toolbar.reverseSearchButton.openReverseSearch.tooltip",
+ [isMacOS ? "Ctrl + R" : "F9"]
+ ),
+ onClick: this.onReverseSearchButtonClick,
+ }),
+ dom.div({
+ className:
+ "devtools-separator webconsole-editor-toolbar-historyNavSeparator",
+ }),
+ dom.button({
+ className: "devtools-button webconsole-editor-toolbar-closeButton",
+ title: l10n.getFormatStr(
+ "webconsole.editor.toolbar.closeButton.tooltip2",
+ [isMacOS ? "Cmd + B" : "Ctrl + B"]
+ ),
+ onClick: () => dispatch(actions.editorToggle()),
+ })
+ );
+ }
+}
+
+module.exports = EditorToolbar;
diff --git a/devtools/client/webconsole/components/Input/EvaluationContextSelector.css b/devtools/client/webconsole/components/Input/EvaluationContextSelector.css
new file mode 100644
index 0000000000..2c58a5e456
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EvaluationContextSelector.css
@@ -0,0 +1,33 @@
+/* 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/. */
+
+.webconsole-evaluation-selector-button {
+ padding: 1px 16px 1px 8px !important;
+ margin-top: 2px;
+ background-position-x: right 4px !important;
+ max-width: 150px;
+}
+
+/* This overrides the .devtools-dropdown-button:dir(rtl) rule from toolbars.css */
+html[dir="rtl"] .webconsole-evaluation-selector-button {
+ background-position-x: right 4px !important;
+}
+
+.jsterm-editor .webconsole-editor-toolbar .webconsole-evaluation-selector-button {
+ height: 20px;
+ margin-inline-start: 5px;
+ margin-top: 1px;
+}
+
+.webconsole-evaluation-selector-button-non-top.devtools-dropdown-button {
+ background-color: var(--blue-60);
+ color: white;
+ fill: currentColor;
+}
+
+.webconsole-evaluation-selector-button-non-top.devtools-dropdown-button:hover,
+.webconsole-evaluation-selector-button-non-top.devtools-dropdown-button[aria-expanded="true"] {
+ background-color: var(--blue-70) !important;
+ color: white !important;
+}
diff --git a/devtools/client/webconsole/components/Input/EvaluationContextSelector.js b/devtools/client/webconsole/components/Input/EvaluationContextSelector.js
new file mode 100644
index 0000000000..83d850ce03
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EvaluationContextSelector.js
@@ -0,0 +1,225 @@
+/* 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";
+
+// React & Redux
+const {
+ Component,
+ createFactory,
+} = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
+
+const frameworkActions = require("devtools/client/framework/actions/index");
+const webconsoleActions = require("devtools/client/webconsole/actions/index");
+
+const { l10n } = require("devtools/client/webconsole/utils/messages");
+const targetSelectors = require("devtools/client/framework/reducers/targets");
+
+loader.lazyGetter(this, "TARGET_TYPES", function() {
+ return require("devtools/shared/resources/target-list").TargetList.TYPES;
+});
+
+// Additional Components
+const MenuButton = createFactory(
+ require("devtools/client/shared/components/menu/MenuButton")
+);
+
+loader.lazyGetter(this, "MenuItem", function() {
+ return createFactory(
+ require("devtools/client/shared/components/menu/MenuItem")
+ );
+});
+
+loader.lazyGetter(this, "MenuList", function() {
+ return createFactory(
+ require("devtools/client/shared/components/menu/MenuList")
+ );
+});
+
+class EvaluationContextSelector extends Component {
+ static get propTypes() {
+ return {
+ selectTarget: PropTypes.func.isRequired,
+ updateInstantEvaluationResultForCurrentExpression:
+ PropTypes.func.isRequired,
+ selectedTarget: PropTypes.object,
+ targets: PropTypes.array,
+ webConsoleUI: PropTypes.object.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps) {
+ if (this.props.selectedTarget !== nextProps.selectedTarget) {
+ return true;
+ }
+ if (this.props.targets.length !== nextProps.targets.length) {
+ return true;
+ }
+ for (let i = 0; i < nextProps.targets.length; i++) {
+ const target = this.props.targets[i];
+ const nextTarget = nextProps.targets[i];
+ if (target.url != nextTarget.url || target.name != nextTarget.name) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.selectedTarget !== prevProps.selectedTarget) {
+ this.props.updateInstantEvaluationResultForCurrentExpression();
+ }
+ }
+
+ getIcon(target) {
+ if (target.targetType === TARGET_TYPES.FRAME) {
+ return "chrome://devtools/content/debugger/images/globe-small.svg";
+ }
+
+ if (
+ target.targetType === TARGET_TYPES.WORKER ||
+ target.targetType === TARGET_TYPES.SHARED_WORKER ||
+ target.targetType === TARGET_TYPES.SERVICE_WORKER
+ ) {
+ return "chrome://devtools/content/debugger/images/worker.svg";
+ }
+
+ if (target.targetType === TARGET_TYPES.PROCESS) {
+ return "chrome://devtools/content/debugger/images/window.svg";
+ }
+
+ return null;
+ }
+
+ renderMenuItem(target) {
+ const { selectTarget, selectedTarget } = this.props;
+
+ const label = target.isTopLevel
+ ? l10n.getStr("webconsole.input.selector.top")
+ : target.name;
+
+ return MenuItem({
+ key: `webconsole-evaluation-selector-item-${target.actorID}`,
+ className: "menu-item webconsole-evaluation-selector-item",
+ type: "checkbox",
+ checked: selectedTarget ? selectedTarget == target : target.isTopLevel,
+ label,
+ tooltip: target.url,
+ icon: this.getIcon(target),
+ onClick: () => selectTarget(target.actorID),
+ });
+ }
+
+ renderMenuItems() {
+ const { targets } = this.props;
+
+ // Let's sort the targets (using "numeric" so Content processes are ordered by PID).
+ const collator = new Intl.Collator("en", { numeric: true });
+ targets.sort((a, b) => collator.compare(a.name, b.name));
+
+ let mainTarget;
+ const frames = [];
+ const contentProcesses = [];
+ const dedicatedWorkers = [];
+ const sharedWorkers = [];
+ const serviceWorkers = [];
+
+ const dict = {
+ [TARGET_TYPES.FRAME]: frames,
+ [TARGET_TYPES.PROCESS]: contentProcesses,
+ [TARGET_TYPES.WORKER]: dedicatedWorkers,
+ [TARGET_TYPES.SHARED_WORKER]: sharedWorkers,
+ [TARGET_TYPES.SERVICE_WORKER]: serviceWorkers,
+ };
+
+ for (const target of targets) {
+ const menuItem = this.renderMenuItem(target);
+
+ if (target.isTopLevel) {
+ mainTarget = menuItem;
+ } else {
+ dict[target.targetType].push(menuItem);
+ }
+ }
+
+ const items = [mainTarget];
+
+ for (const [targetType, menuItems] of Object.entries(dict)) {
+ if (menuItems.length > 0) {
+ items.push(
+ dom.hr({ role: "menuseparator", key: `${targetType}-separator` }),
+ ...menuItems
+ );
+ }
+ }
+
+ return MenuList(
+ { id: "webconsole-console-evaluation-context-selector-menu-list" },
+ items
+ );
+ }
+
+ getLabel() {
+ const { selectedTarget } = this.props;
+
+ if (!selectedTarget || selectedTarget.isTopLevel) {
+ return l10n.getStr("webconsole.input.selector.top");
+ }
+
+ return selectedTarget.name;
+ }
+
+ render() {
+ const { webConsoleUI, targets, selectedTarget } = this.props;
+ const doc = webConsoleUI.document;
+ const { toolbox } = webConsoleUI.wrapper;
+
+ if (targets.length <= 1) {
+ return null;
+ }
+
+ return MenuButton(
+ {
+ menuId: "webconsole-input-evaluationsButton",
+ toolboxDoc: toolbox ? toolbox.doc : doc,
+ label: this.getLabel(),
+ className:
+ "webconsole-evaluation-selector-button devtools-button devtools-dropdown-button" +
+ (selectedTarget && !selectedTarget.isTopLevel
+ ? " webconsole-evaluation-selector-button-non-top"
+ : ""),
+ title: l10n.getStr("webconsole.input.selector.tooltip"),
+ },
+ // We pass the children in a function so we don't require the MenuItem and MenuList
+ // components until we need to display them (i.e. when the button is clicked).
+ () => this.renderMenuItems()
+ );
+ }
+}
+
+const toolboxConnected = connect(
+ state => ({
+ targets: targetSelectors.getToolboxTargets(state),
+ selectedTarget: targetSelectors.getSelectedTarget(state),
+ }),
+ dispatch => ({
+ selectTarget: actorID => dispatch(frameworkActions.selectTarget(actorID)),
+ }),
+ undefined,
+ { storeKey: "toolbox-store" }
+)(EvaluationContextSelector);
+
+module.exports = connect(
+ state => state,
+ dispatch => ({
+ updateInstantEvaluationResultForCurrentExpression: () =>
+ dispatch(
+ webconsoleActions.updateInstantEvaluationResultForCurrentExpression()
+ ),
+ })
+)(toolboxConnected);
diff --git a/devtools/client/webconsole/components/Input/JSTerm.js b/devtools/client/webconsole/components/Input/JSTerm.js
new file mode 100644
index 0000000000..c506162f96
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/JSTerm.js
@@ -0,0 +1,1594 @@
+/* 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 Services = require("Services");
+const { debounce } = require("devtools/shared/debounce");
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+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,
+ "KeyCodes",
+ "devtools/client/shared/keycodes",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "Editor",
+ "devtools/client/shared/sourceeditor/editor"
+);
+loader.lazyRequireGetter(
+ this,
+ "getFocusableElements",
+ "devtools/client/shared/focus",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "l10n",
+ "devtools/client/webconsole/utils/messages",
+ true
+);
+loader.lazyRequireGetter(this, "saveAs", "devtools/shared/DevToolsUtils", true);
+loader.lazyRequireGetter(
+ this,
+ "beautify",
+ "devtools/shared/jsbeautify/beautify"
+);
+
+// React & Redux
+const {
+ Component,
+ createFactory,
+} = 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 {
+ getAutocompleteState,
+} = require("devtools/client/webconsole/selectors/autocomplete");
+const actions = require("devtools/client/webconsole/actions/index");
+
+const EvaluationContextSelector = createFactory(
+ require("devtools/client/webconsole/components/Input/EvaluationContextSelector")
+);
+
+// Constants used for defining the direction of JSTerm input history navigation.
+const {
+ HISTORY_BACK,
+ HISTORY_FORWARD,
+} = require("devtools/client/webconsole/constants");
+
+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);
+ }
+ }
+
+ 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 => {
+ const { OS } = Cu.import("resource://gre/modules/osfile.jsm");
+ OS.File.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) {
+ 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) {
+ 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 > 0) {
+ 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.webConsoleUI.wrapper.toolbox ||
+ 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);
diff --git a/devtools/client/webconsole/components/Input/ReverseSearchInput.css b/devtools/client/webconsole/components/Input/ReverseSearchInput.css
new file mode 100644
index 0000000000..1347de3ab8
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/ReverseSearchInput.css
@@ -0,0 +1,124 @@
+/* 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/. */
+
+.reverse-search {
+ display: flex;
+ font-size: inherit;
+ min-height: 26px;
+ color: var(--theme-body-color);
+ padding-block-start: 2px;
+ align-items: baseline;
+ border: 1px solid transparent;
+ border-top-color: var(--theme-splitter-color);
+ transition: border-color 0.2s ease-in-out;
+}
+
+.jsterm-editor .reverse-search {
+ border-inline-end-color: var(--theme-splitter-color);
+}
+
+/* Add a border radius match the borders of the window on Mac OS
+ * and hide the border radius on the right if the sidebar or editor
+ * is open. */
+:root[platform="mac"] .webconsole-app .reverse-search {
+ border-end-start-radius: 5px;
+}
+:root[platform="mac"] .webconsole-app:not(.jsterm-editor, .sidebar-visible) .reverse-search
+{
+ border-end-end-radius: 5px;
+}
+
+.reverse-search:focus-within {
+ border-color: var(--blue-50);
+}
+
+.reverse-search {
+ flex-shrink: 0;
+}
+
+.reverse-search input {
+ border: none;
+ flex-grow: 1;
+ background: transparent;
+ color: currentColor;
+ background-image: url(chrome://devtools/skin/images/search.svg);
+ background-repeat: no-repeat;
+ background-size: 12px;
+ --background-position-inline: 10px;
+ background-position: var(--background-position-inline) 2px;
+ -moz-context-properties: fill;
+ fill: var(--theme-icon-dimmed-color);
+ text-align: match-parent;
+ unicode-bidi: plaintext;
+ min-width: 80px;
+ flex-shrink: 1;
+ flex-basis: 0;
+}
+
+.reverse-search:dir(ltr) input {
+ /* Be explicit about left/right direction to prevent the text/placeholder
+ * from overlapping the background image when the user changes the text
+ * direction manually (e.g. via Ctrl+Shift). */
+ padding-left: var(--console-inline-start-gutter);
+}
+
+.reverse-search:dir(rtl) input {
+ background-position-x: right var(--background-position-inline);
+ padding-right: var(--console-inline-start-gutter);
+}
+
+.reverse-search input:focus {
+ border: none;
+ outline: none;
+}
+
+.reverse-search:not(.no-result) input:focus {
+ fill: var(--theme-icon-checked-color);
+}
+
+.reverse-search-actions {
+ flex-shrink: 0;
+ display: flex;
+ align-items: baseline;
+}
+
+.reverse-search-info {
+ flex-shrink: 0;
+ padding: 0 8px;
+ color: var(--comment-node-color);
+}
+
+.search-result-button-prev,
+.search-result-button-next,
+.reverse-search-close-button {
+ padding: 4px 0;
+ margin: 0;
+ border-radius: 0;
+}
+
+.search-result-button-prev::before {
+ background-image: url("chrome://devtools/skin/images/arrowhead-up.svg");
+ background-size: 16px;
+ fill: var(--comment-node-color);
+}
+
+.search-result-button-next::before {
+ background-image: url("chrome://devtools/skin/images/arrowhead-down.svg");
+ background-size: 16px;
+ fill: var(--comment-node-color);
+}
+
+.reverse-search-close-button::before {
+ fill: var(--comment-node-color);
+ background-image: url("chrome://devtools/skin/images/close.svg");
+}
+
+.reverse-search.no-result input {
+ fill: var(--error-color);
+}
+
+.reverse-search.no-result,
+.reverse-search.no-result input {
+ color: var(--error-color);
+}
diff --git a/devtools/client/webconsole/components/Input/ReverseSearchInput.js b/devtools/client/webconsole/components/Input/ReverseSearchInput.js
new file mode 100644
index 0000000000..b320f4d4b0
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/ReverseSearchInput.js
@@ -0,0 +1,284 @@
+/* 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";
+
+// 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");
+
+const {
+ getReverseSearchTotalResults,
+ getReverseSearchResultPosition,
+ getReverseSearchResult,
+} = require("devtools/client/webconsole/selectors/history");
+
+loader.lazyRequireGetter(
+ this,
+ "PropTypes",
+ "devtools/client/shared/vendor/react-prop-types"
+);
+loader.lazyRequireGetter(
+ this,
+ "actions",
+ "devtools/client/webconsole/actions/index"
+);
+loader.lazyRequireGetter(
+ this,
+ "l10n",
+ "devtools/client/webconsole/utils/messages",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PluralForm",
+ "devtools/shared/plural-form",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "KeyCodes",
+ "devtools/client/shared/keycodes",
+ true
+);
+
+const Services = require("Services");
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+class ReverseSearchInput extends Component {
+ static get propTypes() {
+ return {
+ dispatch: PropTypes.func.isRequired,
+ setInputValue: PropTypes.func.isRequired,
+ focusInput: PropTypes.func.isRequired,
+ reverseSearchResult: PropTypes.string,
+ reverseSearchTotalResults: PropTypes.number,
+ reverseSearchResultPosition: PropTypes.number,
+ visible: PropTypes.bool,
+ initialValue: PropTypes.string,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onInputKeyDown = this.onInputKeyDown.bind(this);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { setInputValue, focusInput } = this.props;
+ if (
+ prevProps.reverseSearchResult !== this.props.reverseSearchResult &&
+ this.props.visible &&
+ this.props.reverseSearchTotalResults > 0
+ ) {
+ setInputValue(this.props.reverseSearchResult);
+ }
+
+ if (prevProps.visible === true && this.props.visible === false) {
+ focusInput();
+ }
+
+ if (
+ prevProps.visible === false &&
+ this.props.visible === true &&
+ this.props.initialValue
+ ) {
+ this.inputNode.value = this.props.initialValue;
+ }
+ }
+
+ onEnterKeyboardShortcut(event) {
+ const { dispatch } = this.props;
+ event.stopPropagation();
+ dispatch(actions.reverseSearchInputToggle());
+ dispatch(actions.evaluateExpression(undefined, "reverse-search"));
+ }
+
+ onEscapeKeyboardShortcut(event) {
+ const { dispatch } = this.props;
+ event.stopPropagation();
+ dispatch(actions.reverseSearchInputToggle());
+ }
+
+ onBackwardNavigationKeyBoardShortcut(event, canNavigate) {
+ const { dispatch } = this.props;
+ event.stopPropagation();
+ event.preventDefault();
+ if (canNavigate) {
+ dispatch(actions.showReverseSearchBack({ access: "keyboard" }));
+ }
+ }
+
+ onForwardNavigationKeyBoardShortcut(event, canNavigate) {
+ const { dispatch } = this.props;
+ event.stopPropagation();
+ event.preventDefault();
+ if (canNavigate) {
+ dispatch(actions.showReverseSearchNext({ access: "keyboard" }));
+ }
+ }
+
+ onInputKeyDown(event) {
+ const { keyCode, key, ctrlKey, shiftKey } = event;
+ const { reverseSearchTotalResults } = this.props;
+
+ // On Enter, we trigger an execute.
+ if (keyCode === KeyCodes.DOM_VK_RETURN) {
+ return this.onEnterKeyboardShortcut(event);
+ }
+
+ const lowerCaseKey = key.toLowerCase();
+
+ // On Escape (and Ctrl + c on OSX), we close the reverse search input.
+ if (
+ keyCode === KeyCodes.DOM_VK_ESCAPE ||
+ (isMacOS && ctrlKey && lowerCaseKey === "c")
+ ) {
+ return this.onEscapeKeyboardShortcut(event);
+ }
+
+ const canNavigate =
+ Number.isInteger(reverseSearchTotalResults) &&
+ reverseSearchTotalResults > 1;
+
+ if (
+ (!isMacOS && key === "F9" && !shiftKey) ||
+ (isMacOS && ctrlKey && lowerCaseKey === "r")
+ ) {
+ return this.onBackwardNavigationKeyBoardShortcut(event, canNavigate);
+ }
+
+ if (
+ (!isMacOS && key === "F9" && shiftKey) ||
+ (isMacOS && ctrlKey && lowerCaseKey === "s")
+ ) {
+ return this.onForwardNavigationKeyBoardShortcut(event, canNavigate);
+ }
+
+ return null;
+ }
+
+ renderSearchInformation() {
+ const {
+ reverseSearchTotalResults,
+ reverseSearchResultPosition,
+ } = this.props;
+
+ if (!Number.isInteger(reverseSearchTotalResults)) {
+ return null;
+ }
+
+ let text;
+ if (reverseSearchTotalResults === 0) {
+ text = l10n.getStr("webconsole.reverseSearch.noResult");
+ } else {
+ const resultsString = l10n.getStr("webconsole.reverseSearch.results");
+ text = PluralForm.get(reverseSearchTotalResults, resultsString)
+ .replace("#1", reverseSearchResultPosition)
+ .replace("#2", reverseSearchTotalResults);
+ }
+
+ return dom.div({ className: "reverse-search-info" }, text);
+ }
+
+ renderNavigationButtons() {
+ const { dispatch, reverseSearchTotalResults } = this.props;
+
+ if (
+ !Number.isInteger(reverseSearchTotalResults) ||
+ reverseSearchTotalResults <= 1
+ ) {
+ return null;
+ }
+
+ return [
+ dom.button({
+ key: "search-result-button-prev",
+ className: "devtools-button search-result-button-prev",
+ title: l10n.getFormatStr(
+ "webconsole.reverseSearch.result.previousButton.tooltip",
+ [isMacOS ? "Ctrl + R" : "F9"]
+ ),
+ onClick: () => {
+ dispatch(actions.showReverseSearchBack({ access: "click" }));
+ this.inputNode.focus();
+ },
+ }),
+ dom.button({
+ key: "search-result-button-next",
+ className: "devtools-button search-result-button-next",
+ title: l10n.getFormatStr(
+ "webconsole.reverseSearch.result.nextButton.tooltip",
+ [isMacOS ? "Ctrl + S" : "Shift + F9"]
+ ),
+ onClick: () => {
+ dispatch(actions.showReverseSearchNext({ access: "click" }));
+ this.inputNode.focus();
+ },
+ }),
+ ];
+ }
+
+ render() {
+ const { dispatch, visible, reverseSearchTotalResults } = this.props;
+
+ if (!visible) {
+ return null;
+ }
+
+ const classNames = ["reverse-search"];
+
+ if (reverseSearchTotalResults === 0) {
+ classNames.push("no-result");
+ }
+
+ return dom.div(
+ { className: classNames.join(" ") },
+ dom.input({
+ ref: node => {
+ this.inputNode = node;
+ },
+ autoFocus: true,
+ placeholder: l10n.getStr("webconsole.reverseSearch.input.placeHolder"),
+ className: "reverse-search-input devtools-monospace",
+ onKeyDown: this.onInputKeyDown,
+ onInput: ({ target }) =>
+ dispatch(actions.reverseSearchInputChange(target.value)),
+ }),
+ dom.div(
+ {
+ className: "reverse-search-actions",
+ },
+ this.renderSearchInformation(),
+ this.renderNavigationButtons(),
+ dom.button({
+ className: "devtools-button reverse-search-close-button",
+ title: l10n.getFormatStr(
+ "webconsole.reverseSearch.closeButton.tooltip",
+ ["Esc" + (isMacOS ? " | Ctrl + C" : "")]
+ ),
+ onClick: () => {
+ dispatch(actions.reverseSearchInputToggle());
+ },
+ })
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ visible: state.ui.reverseSearchInputVisible,
+ reverseSearchTotalResults: getReverseSearchTotalResults(state),
+ reverseSearchResultPosition: getReverseSearchResultPosition(state),
+ reverseSearchResult: getReverseSearchResult(state),
+});
+
+const mapDispatchToProps = dispatch => ({ dispatch });
+
+module.exports = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(ReverseSearchInput);
diff --git a/devtools/client/webconsole/components/Input/moz.build b/devtools/client/webconsole/components/Input/moz.build
new file mode 100644
index 0000000000..ae435b3495
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "ConfirmDialog.js",
+ "EagerEvaluation.js",
+ "EditorToolbar.js",
+ "EvaluationContextSelector.js",
+ "JSTerm.js",
+ "ReverseSearchInput.js",
+)