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