diff options
Diffstat (limited to 'devtools/client/webconsole/components')
44 files changed, 8409 insertions, 0 deletions
diff --git a/devtools/client/webconsole/components/App.css b/devtools/client/webconsole/components/App.css new file mode 100644 index 0000000000..15dfa06f5e --- /dev/null +++ b/devtools/client/webconsole/components/App.css @@ -0,0 +1,523 @@ +/* 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/. */ + +html, +body { + height: 100vh; + margin: 0; + padding: 0; + overflow: hidden; +} + +#app-wrapper { + height: 100vh; + max-height: 100vh; +} + +.webconsole-output { + direction: ltr; + overflow: auto; + overflow-anchor: none; + user-select: text; + position: relative; + container-name: console-output; + container-type: inline-size; +} + +.webconsole-app { + --object-inspector-hover-background: transparent; + --attachment-margin-block-end: 3px; + --primary-toolbar-height: 29px; + display: grid; + /* + * Here's the design we want in in-line mode + * +----------------------------------------------+ + * | [ChromeDebugToolbar] | + * +----------------------------------------------+ + * | Filter bar primary ↔ | + * +----------------------------↔ | + * | [Filter bar secondary] ↔ | + * +----------------------------↔ | + * | ↔ | + * + +----------------------+ ↔ | + * | | | ↔ | + * | | Output | ↔ [sidebar] | + * | | | ↔ | + * | +----------------------+ ↔ | + * | | [NotificationBox] | ↔ | + * | +----------------------+ ↔ | + * | | | ↔ | + * | | JSTerm | ↔ | + * | | | ↔ | + * | +----------------------+ ↔ | + * | | [EagerEvaluation] | ↔ | + * | +----------------------+ ↔ | + * | | [EvalNotification] | ↔ | + * | +----------------------+ ↔ | + * +----------------------------↔ | + * | [Reverse search input] ↔ | + * +----------------------------------------------+ + * + * - ↔ are width resizers + * - Elements inside brackets may not be visible, so we set + * rows/columns to "auto" to make them collapse when the element + * they contain is hidden. + */ + grid-template-areas: "chrome-debug-toolbar chrome-debug-toolbar" + "filter-toolbar sidebar" + "filter-toolbar-secondary sidebar" + "output-input sidebar" + "eval-notification sidebar" + "reverse-search sidebar"; + grid-template-rows: auto var(--primary-toolbar-height) auto 1fr auto auto; + grid-template-columns: minmax(200px, 1fr) minmax(0, auto); + max-height: 100vh !important; + height: 100vh !important; + width: 100vw; + overflow: hidden; + color: var(--console-output-color); + -moz-user-focus: normal; +} + +.chrome-debug-toolbar { + grid-column: chrome-debug-toolbar; + grid-row: chrome-debug-toolbar; +} + +.webconsole-filteringbar-wrapper { + grid-column: filter-toolbar; + grid-row: filter-toolbar / filter-toolbar-secondary; + grid-template-rows: subgrid; +} + +.webconsole-filterbar-primary { + grid-row: filter-toolbar; +} + +/* Only put the filter buttons toolbar on its own row in narrow filterbar layout */ +.narrow .devtools-toolbar.webconsole-filterbar-secondary { + grid-row: filter-toolbar-secondary; +} + +.flexible-output-input { + display: flex; + flex-direction: column; + grid-area: output-input; + /* Don't take more height than the grid allows to */ + max-height: 100%; + overflow: hidden; +} + +.flexible-output-input .webconsole-output { + flex-shrink: 100000; + overflow-x: hidden; +} + +.flexible-output-input > .webconsole-output:not(:empty) { + min-height: var(--console-row-height); +} + +/* webconsole.css | chrome://devtools/skin/webconsole.css */ +.webconsole-filteringbar-wrapper .devtools-toolbar { + padding-inline-end: 0; +} + +.devtools-button.webconsole-console-settings-menu-button { + /* adjust outline offset so it's not clipped */ + --theme-outline-offset: -2px; + height: 100%; + margin: 0; +} + +.webconsole-console-settings-menu-button::before { + background-image: url("chrome://devtools/skin/images/settings.svg"); +} + +.webconsole-app .jsterm-input-container { + overflow-y: auto; + overflow-x: hidden; + /* We display the open editor button at the end of the input */ + display: grid; + grid-template-columns: 1fr auto; + /* This allows us to not define a column for the CodeMirror container */ + grid-auto-flow: column; + /* This element has tabindex="-1" and can briefly show a focus outline when + * clicked, before we move the focus to CodeMirror. */ + outline: none; +} + +.webconsole-app:not(.jsterm-editor) .jsterm-input-container { + direction: ltr; + /* Define the border width and padding as variables so that we can keep + * border-top-width, padding and min-height in sync. */ + --jsterm-border-width: 0; + --jsterm-padding-top: 0; + --jsterm-padding-bottom: 0; + min-height: calc( + var(--console-row-height) + + var(--jsterm-border-width) + + var(--jsterm-padding-top) + + var(--jsterm-padding-bottom) + ); + padding-top: var(--jsterm-padding-top); + padding-bottom: var(--jsterm-padding-bottom); + border-top-color: var(--theme-splitter-color); + border-top-width: var(--jsterm-border-width); + border-top-style: solid; +} + +.webconsole-app .webconsole-output:not(:empty) ~ .jsterm-input-container { + --jsterm-border-width: 1px; +} + +.webconsole-app:not(.jsterm-editor, .eager-evaluation) .jsterm-input-container { + /* The input should be full-height when eager evaluation is disabled. */ + flex-grow: 1; + --jsterm-padding-top: var(--console-input-extra-padding); + --jsterm-padding-bottom: var(--console-input-extra-padding); +} + +.webconsole-app:not(.jsterm-editor).eager-evaluation .jsterm-input-container { + --jsterm-padding-top: var(--console-input-extra-padding); +} + +.webconsole-input-openEditorButton { + /* adjust outline offset so it's not clipped */ + --theme-outline-offset: -2px; + height: var(--console-row-height); + margin: 0; + padding-block: 0; +} + +.webconsole-input-buttons { + grid-column: -1 / -2; + display: flex; + align-items: flex-start; +} + +:root:dir(rtl) .webconsole-input-openEditorButton { + transform: scaleX(-1); +} + +.webconsole-input-openEditorButton::before { + background-image: url("chrome://devtools/skin/images/webconsole/editor.svg"); +} + +.webconsole-app .reverse-search { + grid-area: reverse-search; + /* Those 2 next lines make it so the element isn't impacting the grid column size, but + will still take the whole available space. */ + width: 0; + min-width: 100%; + /* Let the reverse search buttons wrap to the next line */ + flex-wrap: wrap; + justify-content: end; +} + +.sidebar { + display: grid; + grid-area: sidebar; + grid-template-rows: subgrid; + border-inline-start: 1px solid var(--theme-splitter-color); + background-color: var(--theme-sidebar-background); + width: 200px; + min-width: 150px; + max-width: 100%; +} + +.sidebar-resizer { + grid-row: 1 / -1; + grid-column: -1 / -2; +} + +.webconsole-sidebar-toolbar { + grid-row: 1 / 2; + min-height: 100%; + display: flex; + justify-content: end; + margin: 0; + padding: 0; +} + +.sidebar-contents { + grid-row: 2 / -1; + overflow: auto; + direction: ltr; +} + +.webconsole-sidebar-toolbar .sidebar-close-button { + margin: 0; +} + +.sidebar-close-button::before { + background-image: url("chrome://devtools/skin/images/close.svg"); +} + +.sidebar-contents .object-inspector { + min-width: 100%; +} + +/** EDITOR MODE */ +.webconsole-app.jsterm-editor { + display: grid; + /* + * Here's the design we want in editor mode + * +-----------------------------------------------------------------------+ + * | [ChromeDebugToolbar] | + * +-----------------------------------------------------------------------+ + * | [Notification Box (self XSS warning)] | + * +--------------------------+--------------------------+-----------------+ + * | Editor Toolbar ↔ Filter bar primary ↔ | + * +--------------------------↔--------------------------↔ | + * | ↔ [Filter bar secondary] ↔ | + * | ↔--------------------------↔ | + * | ↔ ↔ | + * | Editor ↔ output ↔ [sidebar] | + * | ↔ ↔ | + * | ↔ ↔ | + * | ↔ ↔ | + * | ↔ ↔ | + * +--------------------------↔ ↔ | + * | [Eager evaluation] ↔ ↔ | + * +--------------------------↔ ↔ | + * | [Eval Notification] ↔ ↔ | + * +--------------------------↔ ↔ | + * | [Reverse search input] ↔ ↔ | + * +-----------------------------------------------------+-----------------+ + * + * - ↔ are width resizers + * - Elements inside brackets may not be visible, so we set + * rows/columns to "auto" to make them collapse when the element + * they contain is hidden. + */ + grid-template-areas: "chrome-debug-toolbar chrome-debug-toolbar chrome-debug-toolbar" + "notification notification notification" + "editor-toolbar filter-toolbar sidebar" + "editor filter-toolbar-secondary sidebar" + "editor output sidebar" + "eager-evaluation output sidebar" + "eval-notification output sidebar" + "reverse-search output sidebar"; + grid-template-rows: + auto + auto + var(--primary-toolbar-height) + auto + 1fr + auto + auto + auto; + grid-template-columns: minmax(150px, auto) minmax(200px, 1fr) minmax(0, auto); +} + +.jsterm-editor .flexible-output-input { + /* This allow us to place the div children (jsterm, output, notification) on the grid */ + display: contents; +} + +.jsterm-editor .webconsole-editor-toolbar { + grid-area: editor-toolbar; + border-inline-end: 1px solid var(--theme-splitter-color); + display: grid; + align-items: center; + /* + * The following elements are going to be present in the toolbar: + * - The run button + * - The evaluation selector button + * - The pretty print button + * - A separator + * - The history nav + * - A separator + * - The close button + * + * +-------------------------------------------+ + * | ▶︎ Run Top↕ {} | ˄ ˅ 🔍 | ✕ | + * +-------------------------------------------+ + * + */ + grid-template-columns: auto auto 1fr auto auto auto auto auto auto auto; + height: unset; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-executeButton { + padding-inline: 4px 8px; + height: 20px; + margin-inline-start: 5px; + display: flex; + align-items: center; +} + + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-executeButton::before { + content: url("chrome://devtools/skin/images/webconsole/run.svg"); + height: 16px; + width: 16px; + -moz-context-properties: fill; + fill: currentColor; + margin-inline-end: 2px; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-prettyPrintButton { + grid-column: -7 / -8; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-prettyPrintSeparator { + grid-column: -6 / -7; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-history-prevExpressionButton { + grid-column: -5 / -6; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-history-nextExpressionButton { + grid-column: -4 / -5; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-reverseSearchButton { + grid-column: -3 / -4; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-historyNavSeparator { + grid-column: -2 / -3; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-closeButton { + grid-column: -1 / -2; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-prettyPrintButton::before { + mask-image: url("chrome://devtools/content/debugger/images/prettyPrint.svg"); + background-size: 16px; + background-color: var(--theme-icon-color); +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-history-prevExpressionButton::before { + background-image: url("chrome://devtools/skin/images/arrowhead-up.svg"); + background-size: 16px; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-history-nextExpressionButton::before { + background-image: url("chrome://devtools/skin/images/arrowhead-down.svg"); + background-size: 16px; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-reverseSearchButton::before { + background-image: url("chrome://devtools/skin/images/webconsole/reverse-search.svg"); + background-size: 14px; +} + +.jsterm-editor .webconsole-editor-toolbar .webconsole-editor-toolbar-closeButton::before { + background-image: url("chrome://devtools/skin/images/close.svg"); +} + +.jsterm-editor .webconsole-input-openEditorButton { + display: none; +} + +.jsterm-editor .webconsole-output { + grid-area: output; +} + +.jsterm-editor .jsterm-input-container { + grid-area: editor; + width: 30vw; + /* Don't allow the input to be narrower than the grid-column it's in */ + min-width: 100%; + border-top: none; + border-inline-end: 1px solid var(--theme-splitter-color); + padding: 0; + /* Needed as we might have the onboarding UI displayed */ + display: flex; + flex-direction: column; + background-color: var(--theme-sidebar-background); +} + +.jsterm-editor #webconsole-notificationbox { + grid-area: notification; +} + +.jsterm-editor .jsterm-input-container > .CodeMirror { + flex: 1; + padding-inline-start: 0; + font-size: var(--theme-code-font-size); + line-height: var(--theme-code-line-height); + background-image: none; +} + +.jsterm-editor .eager-evaluation-result { + grid-area: eager-evaluation; + /* The next 2 lines make it so the element isn't impacting the grid column size, but + will still take the whole available space. */ + min-width: 100%; + width: 0; +} + +.evaluation-notification { + grid-area: eval-notification; + /* The next 2 lines make it so the element isn't impacting the grid column size, but + will still take the whole available space. */ + min-width: 100%; + width: 0; + border: 1px solid; + display: flex; + padding: 0.5em; +} + +.jsterm-editor .editor-resizer { + grid-column: editor; + /* We want the splitter to cover the whole column (minus self-xss message) */ + grid-row: editor / reverse-search; +} + +.editor-onboarding { + display: none; +} + +.jsterm-editor .editor-onboarding { + display: grid; + /** + * Here's the design we want: + * ┌──────┬────────────────────────┐ + * │ Icon │ Onboarding text │ + * ├──────┼────────────────────────┤ + * │ │ Got it!│ + * └──────┴────────────────────────┘ + **/ + grid-template-columns: 22px 1fr; + border-bottom: 1px solid var(--theme-splitter-color); + padding: 8px 16px; + background-color: var(--theme-body-alternate-emphasized-background); + grid-gap: 0 14px; + font-family: system-ui, -apple-system, sans-serif; + font-size: 12px; + line-height: 1.5; +} + +.editor-onboarding-fox { + width: 22px; + height: 22px; + align-self: center; +} + +.jsterm-editor .editor-onboarding p { + padding: 0; + margin: 0; +} + +.jsterm-editor .editor-onboarding .editor-onboarding-shortcut { + font-weight: bold; +} + +.editor-onboarding-dismiss-button { + grid-row: 2 / 3; + grid-column: 2 / 3; + justify-self: end; + padding: 2px; + background: transparent; + border: none; + color: var(--theme-link-color); + font-family: inherit; + cursor: pointer; + font-size: inherit; +} diff --git a/devtools/client/webconsole/components/App.js b/devtools/client/webconsole/components/App.js new file mode 100644 index 0000000000..cabef26b1e --- /dev/null +++ b/devtools/client/webconsole/components/App.js @@ -0,0 +1,513 @@ +/* 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, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + connect, +} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); + +const actions = require("resource://devtools/client/webconsole/actions/index.js"); +const { + FILTERBAR_DISPLAY_MODES, +} = require("resource://devtools/client/webconsole/constants.js"); + +// We directly require Components that we know are going to be used right away +const ConsoleOutput = createFactory( + require("resource://devtools/client/webconsole/components/Output/ConsoleOutput.js") +); +const FilterBar = createFactory( + require("resource://devtools/client/webconsole/components/FilterBar/FilterBar.js") +); +const ReverseSearchInput = createFactory( + require("resource://devtools/client/webconsole/components/Input/ReverseSearchInput.js") +); +const JSTerm = createFactory( + require("resource://devtools/client/webconsole/components/Input/JSTerm.js") +); +const ConfirmDialog = createFactory( + require("resource://devtools/client/webconsole/components/Input/ConfirmDialog.js") +); +const EagerEvaluation = createFactory( + require("resource://devtools/client/webconsole/components/Input/EagerEvaluation.js") +); +const EvaluationNotification = createFactory( + require("resource://devtools/client/webconsole/components/Input/EvaluationNotification.js") +); + +// And lazy load the ones that may not be used. +loader.lazyGetter(this, "SideBar", () => + createFactory( + require("resource://devtools/client/webconsole/components/SideBar.js") + ) +); + +loader.lazyGetter(this, "EditorToolbar", () => + createFactory( + require("resource://devtools/client/webconsole/components/Input/EditorToolbar.js") + ) +); + +loader.lazyGetter(this, "NotificationBox", () => + createFactory( + require("resource://devtools/client/shared/components/NotificationBox.js") + .NotificationBox + ) +); +loader.lazyRequireGetter( + this, + ["getNotificationWithValue", "PriorityLevels"], + "resource://devtools/client/shared/components/NotificationBox.js", + true +); + +loader.lazyGetter(this, "GridElementWidthResizer", () => + createFactory( + require("resource://devtools/client/shared/components/splitter/GridElementWidthResizer.js") + ) +); + +loader.lazyGetter(this, "ChromeDebugToolbar", () => + createFactory( + require("resource://devtools/client/framework/components/ChromeDebugToolbar.js") + ) +); + +const l10n = require("resource://devtools/client/webconsole/utils/l10n.js"); +const { + Utils: WebConsoleUtils, +} = require("resource://devtools/client/webconsole/utils.js"); + +const SELF_XSS_OK = l10n.getStr("selfxss.okstring"); +const SELF_XSS_MSG = l10n.getFormatStr("selfxss.msg", [SELF_XSS_OK]); + +const { + getAllNotifications, +} = require("resource://devtools/client/webconsole/selectors/notifications.js"); +const { div } = dom; +const isMacOS = Services.appinfo.OS === "Darwin"; + +/** + * Console root Application component. + */ +class App extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + webConsoleUI: PropTypes.object.isRequired, + notifications: PropTypes.object, + onFirstMeaningfulPaint: PropTypes.func.isRequired, + serviceContainer: PropTypes.object.isRequired, + closeSplitConsole: PropTypes.func.isRequired, + autocomplete: PropTypes.bool, + currentReverseSearchEntry: PropTypes.string, + reverseSearchInputVisible: PropTypes.bool, + reverseSearchInitialValue: PropTypes.string, + editorMode: PropTypes.bool, + editorWidth: PropTypes.number, + inputEnabled: PropTypes.bool, + sidebarVisible: PropTypes.bool.isRequired, + eagerEvaluationEnabled: PropTypes.bool.isRequired, + filterBarDisplayMode: PropTypes.oneOf([ + ...Object.values(FILTERBAR_DISPLAY_MODES), + ]).isRequired, + showEvaluationContextSelector: PropTypes.bool, + }; + } + + constructor(props) { + super(props); + + this.onClick = this.onClick.bind(this); + this.onPaste = this.onPaste.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onBlur = this.onBlur.bind(this); + } + + componentDidMount() { + window.addEventListener("blur", this.onBlur); + } + + onBlur() { + this.props.dispatch(actions.autocompleteClear()); + } + + onKeyDown(event) { + const { dispatch, webConsoleUI } = this.props; + + if ( + (!isMacOS && event.key === "F9") || + (isMacOS && event.key === "r" && event.ctrlKey === true) + ) { + const initialValue = + webConsoleUI.jsterm && webConsoleUI.jsterm.getSelectedText(); + + dispatch( + actions.reverseSearchInputToggle({ initialValue, access: "keyboard" }) + ); + event.stopPropagation(); + // Prevent Reader Mode to be enabled (See Bug 1682340) + event.preventDefault(); + } + + if ( + event.key.toLowerCase() === "b" && + ((isMacOS && event.metaKey) || (!isMacOS && event.ctrlKey)) + ) { + event.stopPropagation(); + event.preventDefault(); + dispatch(actions.editorToggle()); + } + } + + onClick(event) { + const target = event.originalTarget || event.target; + const { reverseSearchInputVisible, dispatch, webConsoleUI } = this.props; + + if ( + reverseSearchInputVisible === true && + !target.closest(".reverse-search") + ) { + event.preventDefault(); + event.stopPropagation(); + dispatch(actions.reverseSearchInputToggle()); + return; + } + + // Do not focus on middle/right-click or 2+ clicks. + if (event.detail !== 1 || event.button !== 0) { + return; + } + + // Do not focus if a link was clicked + if (target.closest("a")) { + return; + } + + // Do not focus if an input field was clicked + if (target.closest("input")) { + return; + } + + // Do not focus if the click happened in the reverse search toolbar. + if (target.closest(".reverse-search")) { + return; + } + + // Do not focus if something other than the output region was clicked + // (including e.g. the clear messages button in toolbar) + if (!target.closest(".webconsole-app")) { + return; + } + + // Do not focus if something is selected + const selection = webConsoleUI.document.defaultView.getSelection(); + if (selection && !selection.isCollapsed) { + return; + } + + if (webConsoleUI?.jsterm) { + webConsoleUI.jsterm.focus(); + } + } + + onPaste(event) { + const { dispatch, webConsoleUI, notifications } = this.props; + + const { usageCount, CONSOLE_ENTRY_THRESHOLD } = WebConsoleUtils; + + // Bail out if self-xss notification is suppressed. + if ( + webConsoleUI.isBrowserConsole || + usageCount >= CONSOLE_ENTRY_THRESHOLD + ) { + return; + } + + // Stop event propagation, so the clipboard content is *not* inserted. + event.preventDefault(); + event.stopPropagation(); + + // Bail out if self-xss notification is already there. + if (getNotificationWithValue(notifications, "selfxss-notification")) { + return; + } + + const input = event.target; + + // Cleanup function if notification is closed by the user. + const removeCallback = eventType => { + if (eventType == "removed") { + input.removeEventListener("keyup", pasteKeyUpHandler); + dispatch(actions.removeNotification("selfxss-notification")); + } + }; + + // Create self-xss notification + dispatch( + actions.appendNotification( + SELF_XSS_MSG, + "selfxss-notification", + null, + PriorityLevels.PRIORITY_WARNING_HIGH, + null, + removeCallback + ) + ); + + // Remove notification automatically when the user types "allow pasting". + const pasteKeyUpHandler = e => { + const { value } = e.target; + if (value.includes(SELF_XSS_OK)) { + dispatch(actions.removeNotification("selfxss-notification")); + input.removeEventListener("keyup", pasteKeyUpHandler); + WebConsoleUtils.usageCount = WebConsoleUtils.CONSOLE_ENTRY_THRESHOLD; + } + }; + + input.addEventListener("keyup", pasteKeyUpHandler); + } + + renderChromeDebugToolbar() { + const { webConsoleUI } = this.props; + if (!webConsoleUI.isBrowserConsole) { + return null; + } + return ChromeDebugToolbar({ + // This should always be true at this point + isBrowserConsole: webConsoleUI.isBrowserConsole, + }); + } + + renderFilterBar() { + const { closeSplitConsole, filterBarDisplayMode, webConsoleUI } = + this.props; + + return FilterBar({ + key: "filterbar", + closeSplitConsole, + displayMode: filterBarDisplayMode, + webConsoleUI, + }); + } + + renderEditorToolbar() { + const { + editorMode, + dispatch, + reverseSearchInputVisible, + serviceContainer, + webConsoleUI, + showEvaluationContextSelector, + inputEnabled, + } = this.props; + + if (!inputEnabled) { + return null; + } + + return editorMode + ? EditorToolbar({ + key: "editor-toolbar", + editorMode, + dispatch, + reverseSearchInputVisible, + serviceContainer, + showEvaluationContextSelector, + webConsoleUI, + }) + : null; + } + + renderConsoleOutput() { + const { onFirstMeaningfulPaint, serviceContainer, editorMode } = this.props; + + return ConsoleOutput({ + key: "console-output", + serviceContainer, + onFirstMeaningfulPaint, + editorMode, + }); + } + + renderJsTerm() { + const { + webConsoleUI, + serviceContainer, + autocomplete, + editorMode, + editorWidth, + inputEnabled, + } = this.props; + + return JSTerm({ + key: "jsterm", + webConsoleUI, + serviceContainer, + onPaste: this.onPaste, + autocomplete, + editorMode, + editorWidth, + inputEnabled, + }); + } + + renderEagerEvaluation() { + const { eagerEvaluationEnabled, serviceContainer, inputEnabled } = + this.props; + + if (!eagerEvaluationEnabled || !inputEnabled) { + return null; + } + + return EagerEvaluation({ serviceContainer }); + } + + renderReverseSearch() { + const { serviceContainer, reverseSearchInitialValue } = this.props; + + return ReverseSearchInput({ + key: "reverse-search-input", + setInputValue: serviceContainer.setInputValue, + focusInput: serviceContainer.focusInput, + initialValue: reverseSearchInitialValue, + }); + } + + renderSideBar() { + const { serviceContainer, sidebarVisible } = this.props; + return sidebarVisible + ? SideBar({ + key: "sidebar", + serviceContainer, + visible: sidebarVisible, + }) + : null; + } + + renderNotificationBox() { + const { notifications, editorMode } = this.props; + + return notifications && notifications.size > 0 + ? NotificationBox({ + id: "webconsole-notificationbox", + key: "notification-box", + displayBorderTop: !editorMode, + displayBorderBottom: editorMode, + wrapping: true, + notifications, + }) + : null; + } + + renderConfirmDialog() { + const { webConsoleUI, serviceContainer } = this.props; + + return ConfirmDialog({ + webConsoleUI, + serviceContainer, + key: "confirm-dialog", + }); + } + + renderRootElement(children) { + const { editorMode, sidebarVisible, inputEnabled, eagerEvaluationEnabled } = + this.props; + + const classNames = ["webconsole-app"]; + if (sidebarVisible) { + classNames.push("sidebar-visible"); + } + if (editorMode && inputEnabled) { + classNames.push("jsterm-editor"); + } + + if (eagerEvaluationEnabled && inputEnabled) { + classNames.push("eager-evaluation"); + } + + return div( + { + className: classNames.join(" "), + onKeyDown: this.onKeyDown, + onClick: this.onClick, + ref: node => { + this.node = node; + }, + }, + children + ); + } + + render() { + const { webConsoleUI, editorMode, dispatch, inputEnabled } = this.props; + + const chromeDebugToolbar = this.renderChromeDebugToolbar(); + const filterBar = this.renderFilterBar(); + const editorToolbar = this.renderEditorToolbar(); + const consoleOutput = this.renderConsoleOutput(); + const notificationBox = this.renderNotificationBox(); + const jsterm = this.renderJsTerm(); + const eager = this.renderEagerEvaluation(); + const evaluationNotification = EvaluationNotification(); + const reverseSearch = this.renderReverseSearch(); + const sidebar = this.renderSideBar(); + const confirmDialog = this.renderConfirmDialog(); + + return this.renderRootElement([ + chromeDebugToolbar, + filterBar, + editorToolbar, + dom.div( + { className: "flexible-output-input", key: "in-out-container" }, + consoleOutput, + notificationBox, + jsterm, + eager, + evaluationNotification + ), + editorMode && inputEnabled + ? GridElementWidthResizer({ + key: "editor-resizer", + enabled: editorMode, + position: "end", + className: "editor-resizer", + getControlledElementNode: () => webConsoleUI.jsterm.node, + onResizeEnd: width => dispatch(actions.setEditorWidth(width)), + }) + : null, + reverseSearch, + sidebar, + confirmDialog, + ]); + } +} + +const mapStateToProps = state => ({ + notifications: getAllNotifications(state), + reverseSearchInputVisible: state.ui.reverseSearchInputVisible, + reverseSearchInitialValue: state.ui.reverseSearchInitialValue, + editorMode: state.ui.editor, + editorWidth: state.ui.editorWidth, + sidebarVisible: state.ui.sidebarVisible, + filterBarDisplayMode: state.ui.filterBarDisplayMode, + eagerEvaluationEnabled: state.prefs.eagerEvaluation, + autocomplete: state.prefs.autocomplete, + showEvaluationContextSelector: state.ui.showEvaluationContextSelector, +}); + +const mapDispatchToProps = dispatch => ({ + dispatch, +}); + +module.exports = connect(mapStateToProps, mapDispatchToProps)(App); diff --git a/devtools/client/webconsole/components/FilterBar/ConsoleSettings.js b/devtools/client/webconsole/components/FilterBar/ConsoleSettings.js new file mode 100644 index 0000000000..dbff800baa --- /dev/null +++ b/devtools/client/webconsole/components/FilterBar/ConsoleSettings.js @@ -0,0 +1,194 @@ +/* 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 { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const actions = require("resource://devtools/client/webconsole/actions/index.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); + +// 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 ConsoleSettings extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + eagerEvaluation: PropTypes.bool.isRequired, + groupWarnings: PropTypes.bool.isRequired, + persistLogs: PropTypes.bool.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + webConsoleUI: PropTypes.object.isRequired, + autocomplete: PropTypes.bool.isRequired, + enableNetworkMonitoring: PropTypes.bool.isRequired, + }; + } + + renderMenuItems() { + const { + dispatch, + eagerEvaluation, + groupWarnings, + persistLogs, + timestampsVisible, + autocomplete, + webConsoleUI, + enableNetworkMonitoring, + } = this.props; + + const items = []; + + if ( + !webConsoleUI.isBrowserConsole && + !webConsoleUI.isBrowserToolboxConsole + ) { + // Persist Logs + items.push( + MenuItem({ + key: "webconsole-console-settings-menu-item-persistent-logs", + checked: persistLogs, + className: + "menu-item webconsole-console-settings-menu-item-persistentLogs", + label: l10n.getStr( + "webconsole.console.settings.menu.item.enablePersistentLogs.label" + ), + tooltip: l10n.getStr( + "webconsole.console.settings.menu.item.enablePersistentLogs.tooltip" + ), + onClick: () => dispatch(actions.persistToggle()), + }) + ); + } + + if (webConsoleUI.isBrowserConsole || webConsoleUI.isBrowserToolboxConsole) { + // Enable network monitoring + items.push( + MenuItem({ + key: "webconsole-console-settings-menu-item-enable-network-monitoring", + checked: enableNetworkMonitoring, + className: + "menu-item webconsole-console-settings-menu-item-enableNetworkMonitoring", + label: l10n.getStr("browserconsole.enableNetworkMonitoring.label"), + tooltip: l10n.getStr( + "browserconsole.enableNetworkMonitoring.tooltip" + ), + onClick: () => dispatch(actions.networkMonitoringToggle()), + }) + ); + } + + // Timestamps + items.push( + MenuItem({ + key: "webconsole-console-settings-menu-item-timestamps", + checked: timestampsVisible, + className: "menu-item webconsole-console-settings-menu-item-timestamps", + label: l10n.getStr( + "webconsole.console.settings.menu.item.timestamps.label" + ), + tooltip: l10n.getStr( + "webconsole.console.settings.menu.item.timestamps.tooltip" + ), + onClick: () => dispatch(actions.timestampsToggle()), + }) + ); + + // Warning Groups + items.push( + MenuItem({ + key: "webconsole-console-settings-menu-item-warning-groups", + checked: groupWarnings, + className: + "menu-item webconsole-console-settings-menu-item-warning-groups", + label: l10n.getStr( + "webconsole.console.settings.menu.item.warningGroups.label" + ), + tooltip: l10n.getStr( + "webconsole.console.settings.menu.item.warningGroups.tooltip" + ), + onClick: () => dispatch(actions.warningGroupsToggle()), + }) + ); + + // autocomplete + items.push( + MenuItem({ + key: "webconsole-console-settings-menu-item-autocomplete", + checked: autocomplete, + className: + "menu-item webconsole-console-settings-menu-item-autocomplete", + label: l10n.getStr( + "webconsole.console.settings.menu.item.autocomplete.label" + ), + tooltip: l10n.getStr( + "webconsole.console.settings.menu.item.autocomplete.tooltip" + ), + onClick: () => dispatch(actions.autocompleteToggle()), + }) + ); + + // Eager Evaluation + items.push( + MenuItem({ + key: "webconsole-console-settings-menu-item-eager-evaluation", + checked: eagerEvaluation, + className: + "menu-item webconsole-console-settings-menu-item-eager-evaluation", + label: l10n.getStr( + "webconsole.console.settings.menu.item.instantEvaluation.label" + ), + tooltip: l10n.getStr( + "webconsole.console.settings.menu.item.instantEvaluation.tooltip" + ), + onClick: () => dispatch(actions.eagerEvaluationToggle()), + }) + ); + + return MenuList({ id: "webconsole-console-settings-menu-list" }, items); + } + + render() { + const { webConsoleUI } = this.props; + const doc = webConsoleUI.document; + const { toolbox } = webConsoleUI.wrapper; + + return MenuButton( + { + menuId: "webconsole-console-settings-menu-button", + toolboxDoc: toolbox ? toolbox.doc : doc, + className: "devtools-button webconsole-console-settings-menu-button", + title: l10n.getStr("webconsole.console.settings.menu.button.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() + ); + } +} + +module.exports = ConsoleSettings; diff --git a/devtools/client/webconsole/components/FilterBar/FilterBar.js b/devtools/client/webconsole/components/FilterBar/FilterBar.js new file mode 100644 index 0000000000..fa9ab15e87 --- /dev/null +++ b/devtools/client/webconsole/components/FilterBar/FilterBar.js @@ -0,0 +1,441 @@ +/* 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 { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +// Actions +const actions = require("resource://devtools/client/webconsole/actions/index.js"); + +// Selectors +const { + getAllFilters, +} = require("resource://devtools/client/webconsole/selectors/filters.js"); +const { + getFilteredMessagesCount, +} = require("resource://devtools/client/webconsole/selectors/messages.js"); +const { + getAllPrefs, +} = require("resource://devtools/client/webconsole/selectors/prefs.js"); +const { + getAllUi, +} = require("resource://devtools/client/webconsole/selectors/ui.js"); + +// Utilities +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); + +// Constants +const { + FILTERS, + FILTERBAR_DISPLAY_MODES, +} = require("resource://devtools/client/webconsole/constants.js"); + +// Additional Components +const FilterButton = require("resource://devtools/client/webconsole/components/FilterBar/FilterButton.js"); +const ConsoleSettings = createFactory( + require("resource://devtools/client/webconsole/components/FilterBar/ConsoleSettings.js") +); +const SearchBox = createFactory( + require("resource://devtools/client/shared/components/SearchBox.js") +); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); + +const disabledCssFilterButtonTitle = l10n.getStr( + "webconsole.cssFilterButton.inactive.tooltip" +); + +class FilterBar extends Component { + static get propTypes() { + return { + closeButtonVisible: PropTypes.bool, + closeSplitConsole: PropTypes.func, + dispatch: PropTypes.func.isRequired, + displayMode: PropTypes.oneOf([...Object.values(FILTERBAR_DISPLAY_MODES)]) + .isRequired, + enableNetworkMonitoring: PropTypes.bool.isRequired, + filter: PropTypes.object.isRequired, + filteredMessagesCount: PropTypes.object.isRequired, + groupWarnings: PropTypes.bool.isRequired, + persistLogs: PropTypes.bool.isRequired, + eagerEvaluation: PropTypes.bool.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + webConsoleUI: PropTypes.object.isRequired, + autocomplete: PropTypes.bool.isRequired, + }; + } + + constructor(props) { + super(props); + this.renderFiltersConfigBar = this.renderFiltersConfigBar.bind(this); + this.maybeUpdateLayout = this.maybeUpdateLayout.bind(this); + this.resizeObserver = new ResizeObserver(this.maybeUpdateLayout); + } + + componentDidMount() { + this.filterInputMinWidth = 150; + try { + const filterInput = this.wrapperNode.querySelector(".devtools-searchbox"); + this.filterInputMinWidth = Number( + window.getComputedStyle(filterInput)["min-width"].replace("px", "") + ); + } catch (e) { + // If the min-width of the filter input isn't set, or is set in a different unit + // than px. + console.error("min-width of the filter input couldn't be retrieved.", e); + } + + this.maybeUpdateLayout(); + this.resizeObserver.observe(this.wrapperNode); + } + + shouldComponentUpdate(nextProps, nextState) { + const { + closeButtonVisible, + displayMode, + enableNetworkMonitoring, + filter, + filteredMessagesCount, + groupWarnings, + persistLogs, + timestampsVisible, + eagerEvaluation, + autocomplete, + } = this.props; + + if ( + nextProps.closeButtonVisible !== closeButtonVisible || + nextProps.displayMode !== displayMode || + nextProps.enableNetworkMonitoring !== enableNetworkMonitoring || + nextProps.filter !== filter || + nextProps.groupWarnings !== groupWarnings || + nextProps.persistLogs !== persistLogs || + nextProps.timestampsVisible !== timestampsVisible || + nextProps.eagerEvaluation !== eagerEvaluation || + nextProps.autocomplete !== autocomplete + ) { + return true; + } + + if ( + JSON.stringify(nextProps.filteredMessagesCount) !== + JSON.stringify(filteredMessagesCount) + ) { + return true; + } + + return false; + } + + /** + * Update the boolean state that informs where the filter buttons should be rendered. + * If the filter buttons are rendered inline with the filter input and the filter + * input width is reduced below a threshold, the filter buttons are rendered on a new + * row. When the filter buttons are on a separate row and the filter input grows + * wide enough to display the filter buttons without dropping below the threshold, + * the filter buttons are rendered inline. + */ + maybeUpdateLayout() { + const { dispatch, displayMode } = this.props; + + // If we don't have the wrapperNode reference, or if the wrapperNode 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.wrapperNode || !this.wrapperNode.isConnected) { + this.resizeObserver.disconnect(); + return; + } + + const filterInput = this.wrapperNode.querySelector(".devtools-searchbox"); + const { width: filterInputWidth } = filterInput.getBoundingClientRect(); + + if (displayMode === FILTERBAR_DISPLAY_MODES.WIDE) { + if (filterInputWidth <= this.filterInputMinWidth) { + dispatch( + actions.filterBarDisplayModeSet(FILTERBAR_DISPLAY_MODES.NARROW) + ); + } + + return; + } + + if (displayMode === FILTERBAR_DISPLAY_MODES.NARROW) { + const filterButtonsToolbar = this.wrapperNode.querySelector( + ".webconsole-filterbar-secondary" + ); + + const buttonMargin = 5; + const filterButtonsToolbarWidth = Array.from( + filterButtonsToolbar.children + ).reduce( + (width, el) => width + el.getBoundingClientRect().width + buttonMargin, + 0 + ); + + if ( + filterInputWidth - this.filterInputMinWidth > + filterButtonsToolbarWidth + ) { + dispatch(actions.filterBarDisplayModeSet(FILTERBAR_DISPLAY_MODES.WIDE)); + } + } + } + + renderSeparator() { + return dom.div({ + className: "devtools-separator", + }); + } + + renderClearButton() { + return dom.button({ + className: "devtools-button devtools-clear-icon", + title: l10n.getStr("webconsole.clearButton.tooltip"), + onClick: () => this.props.dispatch(actions.messagesClear()), + }); + } + + renderFiltersConfigBar() { + const { dispatch, filter, filteredMessagesCount } = this.props; + + const getLabel = (baseLabel, filterKey) => { + const count = filteredMessagesCount[filterKey]; + if (filter[filterKey] || count === 0) { + return baseLabel; + } + return `${baseLabel} (${count})`; + }; + + return dom.div( + { + className: "devtools-toolbar webconsole-filterbar-secondary", + key: "config-bar", + }, + FilterButton({ + active: filter[FILTERS.ERROR], + label: getLabel( + l10n.getStr("webconsole.errorsFilterButton.label"), + FILTERS.ERROR + ), + filterKey: FILTERS.ERROR, + dispatch, + }), + FilterButton({ + active: filter[FILTERS.WARN], + label: getLabel( + l10n.getStr("webconsole.warningsFilterButton.label"), + FILTERS.WARN + ), + filterKey: FILTERS.WARN, + dispatch, + }), + FilterButton({ + active: filter[FILTERS.LOG], + label: getLabel( + l10n.getStr("webconsole.logsFilterButton.label"), + FILTERS.LOG + ), + filterKey: FILTERS.LOG, + dispatch, + }), + FilterButton({ + active: filter[FILTERS.INFO], + label: getLabel( + l10n.getStr("webconsole.infoFilterButton.label"), + FILTERS.INFO + ), + filterKey: FILTERS.INFO, + dispatch, + }), + FilterButton({ + active: filter[FILTERS.DEBUG], + label: getLabel( + l10n.getStr("webconsole.debugFilterButton.label"), + FILTERS.DEBUG + ), + filterKey: FILTERS.DEBUG, + dispatch, + }), + dom.div({ + className: "devtools-separator", + }), + FilterButton({ + active: filter[FILTERS.CSS], + title: filter[FILTERS.CSS] ? undefined : disabledCssFilterButtonTitle, + label: l10n.getStr("webconsole.cssFilterButton.label"), + filterKey: FILTERS.CSS, + dispatch, + }), + FilterButton({ + active: filter[FILTERS.NETXHR], + label: l10n.getStr("webconsole.xhrFilterButton.label"), + filterKey: FILTERS.NETXHR, + dispatch, + }), + FilterButton({ + active: filter[FILTERS.NET], + label: l10n.getStr("webconsole.requestsFilterButton.label"), + filterKey: FILTERS.NET, + dispatch, + }) + ); + } + + renderSearchBox() { + const { dispatch, filteredMessagesCount } = this.props; + + let searchBoxSummary; + let searchBoxSummaryTooltip; + if (filteredMessagesCount.text > 0) { + searchBoxSummary = l10n.getStr("webconsole.filteredMessagesByText.label"); + searchBoxSummary = PluralForm.get( + filteredMessagesCount.text, + searchBoxSummary + ).replace("#1", filteredMessagesCount.text); + + searchBoxSummaryTooltip = l10n.getStr( + "webconsole.filteredMessagesByText.tooltip" + ); + searchBoxSummaryTooltip = PluralForm.get( + filteredMessagesCount.text, + searchBoxSummaryTooltip + ).replace("#1", filteredMessagesCount.text); + } + + return SearchBox({ + type: "filter", + placeholder: l10n.getStr("webconsole.filterInput.placeholder"), + keyShortcut: l10n.getStr("webconsole.find.key"), + onChange: text => dispatch(actions.filterTextSet(text)), + summary: searchBoxSummary, + summaryTooltip: searchBoxSummaryTooltip, + }); + } + + renderSettingsButton() { + const { + dispatch, + enableNetworkMonitoring, + eagerEvaluation, + groupWarnings, + persistLogs, + timestampsVisible, + webConsoleUI, + autocomplete, + } = this.props; + + return ConsoleSettings({ + dispatch, + enableNetworkMonitoring, + eagerEvaluation, + groupWarnings, + persistLogs, + timestampsVisible, + webConsoleUI, + autocomplete, + }); + } + + renderCloseButton() { + const { closeSplitConsole } = this.props; + + return dom.div( + { + className: "devtools-toolbar split-console-close-button-wrapper", + key: "wrapper", + }, + dom.button({ + id: "split-console-close-button", + key: "split-console-close-button", + className: "devtools-button", + title: l10n.getStr("webconsole.closeSplitConsoleButton.tooltip"), + onClick: () => { + closeSplitConsole(); + }, + }) + ); + } + + render() { + const { closeButtonVisible, displayMode } = this.props; + + const isNarrow = displayMode === FILTERBAR_DISPLAY_MODES.NARROW; + const isWide = displayMode === FILTERBAR_DISPLAY_MODES.WIDE; + + const separator = this.renderSeparator(); + const clearButton = this.renderClearButton(); + const searchBox = this.renderSearchBox(); + const filtersConfigBar = this.renderFiltersConfigBar(); + const settingsButton = this.renderSettingsButton(); + + const children = [ + dom.div( + { + className: + "devtools-toolbar devtools-input-toolbar webconsole-filterbar-primary", + key: "primary-bar", + }, + clearButton, + separator, + searchBox, + isWide && separator, + isWide && filtersConfigBar, + separator, + settingsButton + ), + ]; + + if (closeButtonVisible) { + children.push(this.renderCloseButton()); + } + + if (isNarrow) { + children.push(filtersConfigBar); + } + + return dom.div( + { + className: `webconsole-filteringbar-wrapper ${displayMode}`, + "aria-live": "off", + ref: node => { + this.wrapperNode = node; + }, + }, + children + ); + } +} + +function mapStateToProps(state) { + const uiState = getAllUi(state); + const prefsState = getAllPrefs(state); + return { + closeButtonVisible: uiState.closeButtonVisible, + filter: getAllFilters(state), + filteredMessagesCount: getFilteredMessagesCount(state), + groupWarnings: prefsState.groupWarnings, + persistLogs: uiState.persistLogs, + eagerEvaluation: prefsState.eagerEvaluation, + timestampsVisible: uiState.timestampsVisible, + autocomplete: prefsState.autocomplete, + enableNetworkMonitoring: uiState.enableNetworkMonitoring, + }; +} + +module.exports = connect(mapStateToProps)(FilterBar); diff --git a/devtools/client/webconsole/components/FilterBar/FilterButton.js b/devtools/client/webconsole/components/FilterBar/FilterButton.js new file mode 100644 index 0000000000..2a2ad6bf70 --- /dev/null +++ b/devtools/client/webconsole/components/FilterBar/FilterButton.js @@ -0,0 +1,37 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); + +FilterButton.displayName = "FilterButton"; + +FilterButton.propTypes = { + label: PropTypes.string.isRequired, + filterKey: PropTypes.string.isRequired, + active: PropTypes.bool.isRequired, + dispatch: PropTypes.func.isRequired, + title: PropTypes.string, +}; + +function FilterButton(props) { + const { active, label, filterKey, dispatch, title } = props; + + return dom.button( + { + "aria-pressed": active === true, + className: "devtools-togglebutton", + "data-category": filterKey, + title, + onClick: () => { + dispatch(actions.filterToggle(filterKey)); + }, + }, + label + ); +} + +module.exports = FilterButton; diff --git a/devtools/client/webconsole/components/FilterBar/FilterCheckbox.js b/devtools/client/webconsole/components/FilterBar/FilterCheckbox.js new file mode 100644 index 0000000000..f36788e998 --- /dev/null +++ b/devtools/client/webconsole/components/FilterBar/FilterCheckbox.js @@ -0,0 +1,31 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +FilterCheckbox.displayName = "FilterCheckbox"; + +FilterCheckbox.propTypes = { + label: PropTypes.string.isRequired, + title: PropTypes.string, + checked: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, +}; + +function FilterCheckbox(props) { + const { checked, label, title, onChange } = props; + return dom.label( + { title, className: "filter-checkbox" }, + dom.input({ + type: "checkbox", + checked, + onChange, + }), + label + ); +} + +module.exports = FilterCheckbox; diff --git a/devtools/client/webconsole/components/FilterBar/moz.build b/devtools/client/webconsole/components/FilterBar/moz.build new file mode 100644 index 0000000000..46ef681317 --- /dev/null +++ b/devtools/client/webconsole/components/FilterBar/moz.build @@ -0,0 +1,11 @@ +# 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( + "ConsoleSettings.js", + "FilterBar.js", + "FilterButton.js", + "FilterCheckbox.js", +) 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..d4deb2b8bf --- /dev/null +++ b/devtools/client/webconsole/components/Input/EagerEvaluation.css @@ -0,0 +1,101 @@ +/* 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); +} + +.theme-light .eager-evaluation-result { + --log-icon-color: var(--grey-35); +} + +.theme-dark .eager-evaluation-result { + --log-icon-color: var(--grey-55); +} + +.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 { + /* Override Reps variables to turn eager eval output gray */ + filter: saturate(0%); + + 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); +} + +/* + * 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..52d5467b45 --- /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, + hud: 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/EvaluationNotification.css b/devtools/client/webconsole/components/Input/EvaluationNotification.css new file mode 100644 index 0000000000..2e387bda7f --- /dev/null +++ b/devtools/client/webconsole/components/Input/EvaluationNotification.css @@ -0,0 +1,32 @@ +/* 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/. */ + +.evaluation-notification.warning { + color: var(--console-warning-color); + border-color: var(--console-warning-border); + background-color: var(--console-warning-background) +} + +.evaluation-notification.warning .evaluation-notification__icon { + color: var(--theme-icon-warning-color); + background-image: url(chrome://devtools/content/debugger/images/sourcemap.svg); +} + +.evaluation-notification__icon { + flex: none; + align-self: flex-start; + width: 16px; + height: 16px; + margin: var(--console-output-vertical-padding) 5px var(--console-output-vertical-padding) 0; + background-image: none; + background-position: center; + background-repeat: no-repeat; + background-size: 16px; + -moz-context-properties: fill; + fill: currentColor; +} + +.evaluation-notification__text { + margin: var(--console-output-vertical-padding) 0; +} diff --git a/devtools/client/webconsole/components/Input/EvaluationNotification.js b/devtools/client/webconsole/components/Input/EvaluationNotification.js new file mode 100644 index 0000000000..3f09fc72cd --- /dev/null +++ b/devtools/client/webconsole/components/Input/EvaluationNotification.js @@ -0,0 +1,63 @@ +/* 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 { + getAllUi, +} = require("resource://devtools/client/webconsole/selectors/ui.js"); + +const { + ORIGINAL_VARIABLE_MAPPING, +} = require("resource://devtools/client/webconsole/constants.js"); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); + +const l10n = require("resource://devtools/client/webconsole/utils/l10n.js"); + +/** + * Show the results of evaluating the current terminal text, if possible. + */ +class EvaluationNotification extends Component { + static get propTypes() { + return { + notification: PropTypes.string, + }; + } + + render() { + const { notification } = this.props; + if (notification == ORIGINAL_VARIABLE_MAPPING) { + return dom.span( + { className: "evaluation-notification warning" }, + dom.span({ className: "evaluation-notification__icon" }), + dom.span( + { className: "evaluation-notification__text" }, + l10n.getStr("evaluationNotifcation.noOriginalVariableMapping.msg") + ) + ); + } + return null; + } +} + +function mapStateToProps(state) { + return { + notification: getAllUi(state).notification, + }; +} + +module.exports = connect(mapStateToProps, null)(EvaluationNotification); 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..6398449959 --- /dev/null +++ b/devtools/client/webconsole/components/Input/ReverseSearchInput.css @@ -0,0 +1,125 @@ +/* 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 { + outline-offset: -2px; + outline: var(--theme-focus-outline); +} + +.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..1feed60d13 --- /dev/null +++ b/devtools/client/webconsole/components/Input/moz.build @@ -0,0 +1,14 @@ +# 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", + "EvaluationNotification.js", + "JSTerm.js", + "ReverseSearchInput.js", +) diff --git a/devtools/client/webconsole/components/Output/CollapseButton.js b/devtools/client/webconsole/components/Output/CollapseButton.js new file mode 100644 index 0000000000..c0594a5855 --- /dev/null +++ b/devtools/client/webconsole/components/Output/CollapseButton.js @@ -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/. */ + +"use strict"; + +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const messageToggleDetails = l10n.getStr("messageToggleDetails"); + +function CollapseButton(props) { + const { open, onClick, title = messageToggleDetails } = props; + + return dom.button({ + "aria-expanded": open ? "true" : "false", + "aria-label": title, + className: "arrow collapse-button", + onClick, + onMouseDown: e => { + // prevent focus from moving to the disclosure if clicked, + // which is annoying if on the input + e.preventDefault(); + // Clearing the text selection to allow the message to collpase. + e.target.ownerDocument.defaultView.getSelection().removeAllRanges(); + }, + title, + }); +} + +module.exports = CollapseButton; diff --git a/devtools/client/webconsole/components/Output/ConsoleOutput.js b/devtools/client/webconsole/components/Output/ConsoleOutput.js new file mode 100644 index 0000000000..a2469b3b46 --- /dev/null +++ b/devtools/client/webconsole/components/Output/ConsoleOutput.js @@ -0,0 +1,381 @@ +/* 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, + createElement, + createRef, +} = 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/redux/visibility-handler-connect.js"); +const { + initialize, +} = require("resource://devtools/client/webconsole/actions/ui.js"); +const LazyMessageList = require("resource://devtools/client/webconsole/components/Output/LazyMessageList.js"); + +const { + getMutableMessagesById, + getAllMessagesUiById, + getAllDisabledMessagesById, + getAllCssMessagesMatchingElements, + getAllNetworkMessagesUpdateById, + getLastMessageId, + getVisibleMessages, + getAllRepeatById, + getAllWarningGroupsById, + isMessageInWarningGroup, +} = require("resource://devtools/client/webconsole/selectors/messages.js"); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +loader.lazyRequireGetter( + this, + "MessageContainer", + "resource://devtools/client/webconsole/components/Output/MessageContainer.js", + true +); +loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js"); + +const { + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); + +class ConsoleOutput extends Component { + static get propTypes() { + return { + initialized: PropTypes.bool.isRequired, + mutableMessages: PropTypes.object.isRequired, + messageCount: PropTypes.number.isRequired, + messagesUi: PropTypes.array.isRequired, + disabledMessages: PropTypes.array.isRequired, + serviceContainer: PropTypes.shape({ + attachRefToWebConsoleUI: PropTypes.func.isRequired, + openContextMenu: PropTypes.func.isRequired, + sourceMapURLService: PropTypes.object, + }), + dispatch: PropTypes.func.isRequired, + timestampsVisible: PropTypes.bool, + cssMatchingElements: PropTypes.object.isRequired, + messagesRepeat: PropTypes.object.isRequired, + warningGroups: PropTypes.object.isRequired, + networkMessagesUpdate: PropTypes.object.isRequired, + visibleMessages: PropTypes.array.isRequired, + networkMessageActiveTabId: PropTypes.string.isRequired, + onFirstMeaningfulPaint: PropTypes.func.isRequired, + editorMode: PropTypes.bool.isRequired, + cacheGeneration: PropTypes.number.isRequired, + disableVirtualization: PropTypes.bool, + lastMessageId: PropTypes.string.isRequired, + }; + } + + constructor(props) { + super(props); + this.onContextMenu = this.onContextMenu.bind(this); + this.maybeScrollToBottom = this.maybeScrollToBottom.bind(this); + this.messageIdsToKeepAlive = new Set(); + this.ref = createRef(); + this.lazyMessageListRef = createRef(); + + this.resizeObserver = new ResizeObserver(entries => { + // If we don't have the outputNode reference, or if the outputNode 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.outputNode || !this.outputNode.isConnected) { + this.resizeObserver.disconnect(); + return; + } + + if (this.scrolledToBottom) { + this.scrollToBottom(); + } + }); + } + + componentDidMount() { + if (this.props.disableVirtualization) { + return; + } + + if (this.props.visibleMessages.length) { + this.scrollToBottom(); + } + + this.scrollDetectionIntersectionObserver = new IntersectionObserver( + entries => { + for (const entry of entries) { + // Consider that we're not pinned to the bottom anymore if the bottom of the + // scrollable area is within 10px of visible (half the typical element height.) + this.scrolledToBottom = entry.intersectionRatio > 0; + } + }, + { root: this.outputNode, rootMargin: "10px" } + ); + + this.resizeObserver.observe(this.getElementToObserve()); + + const { serviceContainer, onFirstMeaningfulPaint, dispatch } = this.props; + serviceContainer.attachRefToWebConsoleUI( + "outputScroller", + this.ref.current + ); + + // Waiting for the next paint. + new Promise(res => requestAnimationFrame(res)).then(() => { + if (onFirstMeaningfulPaint) { + onFirstMeaningfulPaint(); + } + + // Dispatching on next tick so we don't block on action execution. + setTimeout(() => { + dispatch(initialize()); + }, 0); + }); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps, nextState) { + this.isUpdating = true; + if (nextProps.cacheGeneration !== this.props.cacheGeneration) { + this.messageIdsToKeepAlive = new Set(); + } + + if (nextProps.editorMode !== this.props.editorMode) { + this.resizeObserver.disconnect(); + } + + const { outputNode } = this; + if (!outputNode?.lastChild) { + // Force a scroll to bottom when messages are added to an empty console. + // This makes the console stay pinned to the bottom if a batch of messages + // are added after a page refresh (Bug 1402237). + this.shouldScrollBottom = true; + this.scrolledToBottom = true; + return; + } + + const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer; + this.scrollDetectionIntersectionObserver.unobserve(bottomBuffer); + + const visibleMessagesDelta = + nextProps.visibleMessages.length - this.props.visibleMessages.length; + const messagesDelta = nextProps.messageCount - this.props.messageCount; + // Evaluation results are never filtered out, so if it's in the store, it will be + // visible in the output. + const isNewMessageEvaluationResult = + messagesDelta > 0 && + nextProps.lastMessageId && + nextProps.mutableMessages.get(nextProps.lastMessageId)?.type === + MESSAGE_TYPE.RESULT; + + // Use an inline function in order to avoid executing the expensive Array.some() + // unless condition are meant to do this additional check. + const isOpeningGroup = () => { + const messagesUiDelta = + nextProps.messagesUi.length - this.props.messagesUi.length; + return ( + messagesUiDelta > 0 && + nextProps.messagesUi.some( + id => + !this.props.messagesUi.includes(id) && + this.props.visibleMessages.includes(id) && + nextProps.visibleMessages.includes(id) + ) + ); + }; + + // We need to scroll to the bottom if: + this.shouldScrollBottom = + // - we are reacting to "initialize" action, and we are already scrolled to the bottom + (!this.props.initialized && + nextProps.initialized && + this.scrolledToBottom) || + // - the number of messages in the store changed and the new message is an evaluation + // result. + isNewMessageEvaluationResult || + // - the number of messages displayed changed and we are already scrolled to the + // bottom, but not if we are reacting to a group opening. + (this.scrolledToBottom && visibleMessagesDelta > 0 && !isOpeningGroup()); + } + + componentDidUpdate(prevProps) { + this.isUpdating = false; + this.maybeScrollToBottom(); + if (this?.outputNode?.lastChild) { + const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer; + this.scrollDetectionIntersectionObserver.observe(bottomBuffer); + } + + if (prevProps.editorMode !== this.props.editorMode) { + this.resizeObserver.observe(this.getElementToObserve()); + } + } + + get outputNode() { + return this.ref.current; + } + + maybeScrollToBottom() { + if (this.outputNode && this.shouldScrollBottom) { + this.scrollToBottom(); + } + } + + // The maybeScrollToBottom callback we provide to messages needs to be a little bit more + // strict than the one we normally use, because they can potentially interrupt a user + // scroll (between when the intersection observer registers the scroll break and when + // a componentDidUpdate comes through to reconcile it.) + maybeScrollToBottomMessageCallback(index) { + if ( + this.outputNode && + this.shouldScrollBottom && + this.scrolledToBottom && + this.lazyMessageListRef.current?.isItemNearBottom(index) + ) { + this.scrollToBottom(); + } + } + + scrollToBottom() { + if (flags.testing && this.outputNode.hasAttribute("disable-autoscroll")) { + return; + } + if (this.outputNode.scrollHeight > this.outputNode.clientHeight) { + this.outputNode.scrollTop = this.outputNode.scrollHeight; + } + + this.scrolledToBottom = true; + } + + getElementToObserve() { + // In inline mode, we need to observe the output node parent, which contains both the + // output and the input, so we don't trigger the resizeObserver callback when only the + // output size changes (e.g. when a network request is expanded). + return this.props.editorMode + ? this.outputNode + : this.outputNode?.parentNode; + } + + onContextMenu(e) { + this.props.serviceContainer.openContextMenu(e); + e.stopPropagation(); + e.preventDefault(); + } + + render() { + const { + cacheGeneration, + dispatch, + visibleMessages, + disabledMessages, + mutableMessages, + messagesUi, + cssMatchingElements, + messagesRepeat, + warningGroups, + networkMessagesUpdate, + networkMessageActiveTabId, + serviceContainer, + timestampsVisible, + } = this.props; + + const renderMessage = (messageId, index) => { + return createElement(MessageContainer, { + dispatch, + key: messageId, + messageId, + serviceContainer, + open: messagesUi.includes(messageId), + cssMatchingElements: cssMatchingElements.get(messageId), + timestampsVisible, + disabled: disabledMessages.includes(messageId), + repeat: messagesRepeat[messageId], + badge: warningGroups.has(messageId) + ? warningGroups.get(messageId).length + : null, + inWarningGroup: + warningGroups && warningGroups.size > 0 + ? isMessageInWarningGroup( + mutableMessages.get(messageId), + visibleMessages + ) + : false, + networkMessageUpdate: networkMessagesUpdate[messageId], + networkMessageActiveTabId, + getMessage: () => mutableMessages.get(messageId), + maybeScrollToBottom: () => + this.maybeScrollToBottomMessageCallback(index), + // Whenever a node is expanded, we want to make sure we keep the + // message node alive so as to not lose the expanded state. + setExpanded: () => this.messageIdsToKeepAlive.add(messageId), + }); + }; + + // scrollOverdrawCount tells the list to draw extra elements above and + // below the scrollport so that we can avoid flashes of blank space + // when scrolling. When `disableVirtualization` is passed we make it as large as the + // number of messages to render them all and effectively disabling virtualization (this + // should only be used for some actions that requires all the messages to be rendered + // in the DOM, like "Copy All Messages"). + const scrollOverdrawCount = this.props.disableVirtualization + ? visibleMessages.length + : 20; + + const attrs = { + className: "webconsole-output", + role: "main", + onContextMenu: this.onContextMenu, + ref: this.ref, + }; + if (flags.testing) { + attrs["data-visible-messages"] = JSON.stringify(visibleMessages); + } + return dom.div( + attrs, + createElement(LazyMessageList, { + viewportRef: this.ref, + items: visibleMessages, + itemDefaultHeight: 21, + editorMode: this.props.editorMode, + scrollOverdrawCount, + ref: this.lazyMessageListRef, + renderItem: renderMessage, + itemsToKeepAlive: this.messageIdsToKeepAlive, + serviceContainer, + cacheGeneration, + shouldScrollBottom: () => this.shouldScrollBottom && this.isUpdating, + }) + ); + } +} + +function mapStateToProps(state, props) { + const mutableMessages = getMutableMessagesById(state); + return { + initialized: state.ui.initialized, + cacheGeneration: state.ui.cacheGeneration, + // We need to compute this so lifecycle methods can compare the global message count + // on state change (since we can't do it with mutableMessagesById). + messageCount: mutableMessages.size, + mutableMessages, + lastMessageId: getLastMessageId(state), + visibleMessages: getVisibleMessages(state), + disabledMessages: getAllDisabledMessagesById(state), + messagesUi: getAllMessagesUiById(state), + cssMatchingElements: getAllCssMessagesMatchingElements(state), + messagesRepeat: getAllRepeatById(state), + warningGroups: getAllWarningGroupsById(state), + networkMessagesUpdate: getAllNetworkMessagesUpdateById(state), + timestampsVisible: state.ui.timestampsVisible, + networkMessageActiveTabId: state.ui.networkMessageActiveTabId, + }; +} + +module.exports = connect(mapStateToProps)(ConsoleOutput); diff --git a/devtools/client/webconsole/components/Output/ConsoleTable.js b/devtools/client/webconsole/components/Output/ConsoleTable.js new file mode 100644 index 0000000000..f41afce96d --- /dev/null +++ b/devtools/client/webconsole/components/Output/ConsoleTable.js @@ -0,0 +1,272 @@ +/* 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, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + getArrayTypeNames, +} = require("resource://devtools/shared/webconsole/messages.js"); +const { + l10n, + getDescriptorValue, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); + +const GripMessageBody = createFactory( + require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js") +); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); + +const TABLE_ROW_MAX_ITEMS = 1000; +// Match Chrome max column number. +const TABLE_COLUMN_MAX_ITEMS = 21; + +class ConsoleTable extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + parameters: PropTypes.array.isRequired, + serviceContainer: PropTypes.object.isRequired, + id: PropTypes.string.isRequired, + setExpanded: PropTypes.func, + }; + } + + constructor(props) { + super(props); + this.getHeaders = this.getHeaders.bind(this); + this.getRows = this.getRows.bind(this); + } + + getHeaders(columns) { + const headerItems = []; + columns.forEach((value, key) => + headerItems.push( + dom.th( + { + key, + title: value, + }, + value + ) + ) + ); + return dom.thead({}, dom.tr({}, headerItems)); + } + + getRows(columns, items) { + const { dispatch, serviceContainer, setExpanded } = this.props; + + const rows = []; + items.forEach((item, index) => { + const cells = []; + + columns.forEach((value, key) => { + const cellValue = item[key]; + const cellContent = + typeof cellValue === "undefined" + ? "" + : GripMessageBody({ + grip: cellValue, + mode: MODE.SHORT, + useQuotes: false, + serviceContainer, + dispatch, + setExpanded, + }); + + cells.push( + dom.td( + { + key, + }, + cellContent + ) + ); + }); + rows.push(dom.tr({}, cells)); + }); + return dom.tbody({}, rows); + } + + render() { + const { parameters } = this.props; + const { valueGrip, headersGrip } = getValueAndHeadersGrip(parameters); + + const headers = headersGrip?.preview ? headersGrip.preview.items : null; + + const data = valueGrip?.ownProperties; + + // if we don't have any data, don't show anything. + if (!data) { + return null; + } + + const dataType = getParametersDataType(parameters); + const { columns, items } = getTableItems(data, dataType, headers); + + // We need to wrap the <table> in a div so we can have the max-height set properly + // without changing the table display. + return dom.div( + { className: "consoletable-wrapper" }, + dom.table( + { + className: "consoletable", + }, + this.getHeaders(columns), + this.getRows(columns, items) + ) + ); + } +} + +function getValueAndHeadersGrip(parameters) { + const [valueFront, headersFront] = parameters; + + const headersGrip = headersFront?.getGrip + ? headersFront.getGrip() + : headersFront; + + const valueGrip = valueFront?.getGrip ? valueFront.getGrip() : valueFront; + + return { valueGrip, headersGrip }; +} + +function getParametersDataType(parameters = null) { + if (!Array.isArray(parameters) || parameters.length === 0) { + return null; + } + const [firstParam] = parameters; + if (!firstParam || !firstParam.getGrip) { + return null; + } + const grip = firstParam.getGrip(); + return grip.class; +} + +const INDEX_NAME = "_index"; +const VALUE_NAME = "_value"; + +function getNamedIndexes(type) { + return { + [INDEX_NAME]: getArrayTypeNames().concat("Object").includes(type) + ? l10n.getStr("table.index") + : l10n.getStr("table.iterationIndex"), + [VALUE_NAME]: l10n.getStr("table.value"), + key: l10n.getStr("table.key"), + }; +} + +function hasValidCustomHeaders(headers) { + return ( + Array.isArray(headers) && + headers.every( + header => typeof header === "string" || Number.isInteger(Number(header)) + ) + ); +} + +function getTableItems(data = {}, type, headers = null) { + const namedIndexes = getNamedIndexes(type); + + let columns = new Map(); + const items = []; + + const addItem = function (item) { + items.push(item); + Object.keys(item).forEach(key => addColumn(key)); + }; + + const validCustomHeaders = hasValidCustomHeaders(headers); + + const addColumn = function (columnIndex) { + const columnExists = columns.has(columnIndex); + const hasMaxColumns = columns.size == TABLE_COLUMN_MAX_ITEMS; + + if ( + !columnExists && + !hasMaxColumns && + (!validCustomHeaders || + headers.includes(columnIndex) || + columnIndex === INDEX_NAME) + ) { + columns.set(columnIndex, namedIndexes[columnIndex] || columnIndex); + } + }; + + for (let [index, property] of Object.entries(data)) { + if (type !== "Object" && index == parseInt(index, 10)) { + index = parseInt(index, 10); + } + + const item = { + [INDEX_NAME]: index, + }; + + const propertyValue = getDescriptorValue(property); + const propertyValueGrip = propertyValue?.getGrip + ? propertyValue.getGrip() + : propertyValue; + + if (propertyValueGrip?.ownProperties) { + const entries = propertyValueGrip.ownProperties; + for (const [key, entry] of Object.entries(entries)) { + item[key] = getDescriptorValue(entry); + } + } else if ( + propertyValueGrip?.preview && + (type === "Map" || type === "WeakMap") + ) { + item.key = propertyValueGrip.preview.key; + item[VALUE_NAME] = propertyValueGrip.preview.value; + } else { + item[VALUE_NAME] = propertyValue; + } + + addItem(item); + + if (items.length === TABLE_ROW_MAX_ITEMS) { + break; + } + } + + // Some headers might not be present in the items, so we make sure to + // return all the headers set by the user. + if (validCustomHeaders) { + headers.forEach(header => addColumn(header)); + } + + // We want to always have the index column first + if (columns.has(INDEX_NAME)) { + const index = columns.get(INDEX_NAME); + columns.delete(INDEX_NAME); + columns = new Map([[INDEX_NAME, index], ...columns.entries()]); + } + + // We want to always have the values column last + if (columns.has(VALUE_NAME)) { + const index = columns.get(VALUE_NAME); + columns.delete(VALUE_NAME); + columns.set(VALUE_NAME, index); + } + + return { + columns, + items, + }; +} + +module.exports = ConsoleTable; diff --git a/devtools/client/webconsole/components/Output/GripMessageBody.js b/devtools/client/webconsole/components/Output/GripMessageBody.js new file mode 100644 index 0000000000..6ecfe55b8e --- /dev/null +++ b/devtools/client/webconsole/components/Output/GripMessageBody.js @@ -0,0 +1,114 @@ +/* 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 +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + MESSAGE_TYPE, + JSTERM_COMMANDS, +} = require("resource://devtools/client/webconsole/constants.js"); +const { + cleanupStyle, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const { + getObjectInspector, +} = require("resource://devtools/client/webconsole/utils/object-inspector.js"); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); + +loader.lazyGetter(this, "objectInspector", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .objectInspector; +}); + +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); + +GripMessageBody.displayName = "GripMessageBody"; + +GripMessageBody.propTypes = { + grip: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.object, + ]).isRequired, + serviceContainer: PropTypes.shape({ + createElement: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + }), + userProvidedStyle: PropTypes.string, + useQuotes: PropTypes.bool, + escapeWhitespace: PropTypes.bool, + type: PropTypes.string, + helperType: PropTypes.string, + maybeScrollToBottom: PropTypes.func, + setExpanded: PropTypes.func, +}; + +GripMessageBody.defaultProps = { + mode: MODE.LONG, +}; + +function GripMessageBody(props) { + const { + grip, + userProvidedStyle, + serviceContainer, + useQuotes, + escapeWhitespace, + mode = MODE.LONG, + dispatch, + maybeScrollToBottom, + setExpanded, + customFormat = false, + } = props; + + let styleObject; + if (userProvidedStyle && userProvidedStyle !== "") { + styleObject = cleanupStyle( + userProvidedStyle, + serviceContainer.createElement + ); + } + + const objectInspectorProps = { + autoExpandDepth: shouldAutoExpandObjectInspector(props) ? 1 : 0, + mode, + maybeScrollToBottom, + setExpanded, + customFormat, + onCmdCtrlClick: (node, { depth, event, focused, expanded }) => { + const front = objectInspector.utils.node.getFront(node); + if (front) { + dispatch(actions.showObjectInSidebar(front)); + } + }, + }; + + if ( + typeof grip === "string" || + (grip && grip.type === "longString") || + (grip?.getGrip && grip.getGrip().type === "longString") + ) { + Object.assign(objectInspectorProps, { + useQuotes, + transformEmptyString: true, + escapeWhitespace, + style: styleObject, + }); + } + + return getObjectInspector(grip, serviceContainer, objectInspectorProps); +} + +function shouldAutoExpandObjectInspector(props) { + const { helperType, type } = props; + + return type === MESSAGE_TYPE.DIR || helperType === JSTERM_COMMANDS.INSPECT; +} + +module.exports = GripMessageBody; diff --git a/devtools/client/webconsole/components/Output/LazyMessageList.js b/devtools/client/webconsole/components/Output/LazyMessageList.js new file mode 100644 index 0000000000..931b5bb8bd --- /dev/null +++ b/devtools/client/webconsole/components/Output/LazyMessageList.js @@ -0,0 +1,393 @@ +/* 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/. + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * MIT License + * + * Copyright (c) 2019 Oleg Grishechkin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +"use strict"; + +const { + Fragment, + Component, + createElement, + createRef, +} = require("resource://devtools/client/shared/vendor/react.js"); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); + +// This element is a webconsole optimization for handling large numbers of +// console messages. The purpose is to only create DOM elements for messages +// which are actually visible within the scrollport. This code was based on +// Oleg Grishechkin's react-viewport-list element - however, it has been quite +// heavily modified, to the point that it is mostly unrecognizable. The most +// notable behavioral modification is that the list implements the behavior of +// pinning the scrollport to the bottom of the scroll container. +class LazyMessageList extends Component { + static get propTypes() { + return { + viewportRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }) + .isRequired, + items: PropTypes.array.isRequired, + itemsToKeepAlive: PropTypes.shape({ + has: PropTypes.func, + keys: PropTypes.func, + size: PropTypes.number, + }).isRequired, + editorMode: PropTypes.bool.isRequired, + itemDefaultHeight: PropTypes.number.isRequired, + scrollOverdrawCount: PropTypes.number.isRequired, + renderItem: PropTypes.func.isRequired, + shouldScrollBottom: PropTypes.func.isRequired, + cacheGeneration: PropTypes.number.isRequired, + serviceContainer: PropTypes.shape({ + emitForTests: PropTypes.func.isRequired, + }), + }; + } + + constructor(props) { + super(props); + this.#initialized = false; + this.#topBufferRef = createRef(); + this.#bottomBufferRef = createRef(); + this.#viewportHeight = window.innerHeight; + this.#startIndex = 0; + this.#resizeObserver = null; + this.#cachedHeights = []; + + this.#scrollHandlerBinding = this.#scrollHandler.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps, nextState) { + if (nextProps.cacheGeneration !== this.props.cacheGeneration) { + this.#cachedHeights = []; + this.#startIndex = 0; + } else if ( + (this.props.shouldScrollBottom() && + nextProps.items.length > this.props.items.length) || + this.#startIndex > nextProps.items.length - this.#numItemsToDraw + ) { + this.#startIndex = Math.max( + 0, + nextProps.items.length - this.#numItemsToDraw + ); + } + } + + componentDidUpdate(prevProps) { + const { viewportRef, serviceContainer } = this.props; + if (!viewportRef.current || !this.#topBufferRef.current) { + return; + } + + if (!this.#initialized) { + // We set these up from a one-time call in componentDidUpdate, rather than in + // componentDidMount, because we need the parent to be mounted first, to add + // listeners to it, and React orders things such that children mount before + // parents. + this.#addListeners(); + } + + if (!this.#initialized || prevProps.editorMode !== this.props.editorMode) { + this.#resizeObserver.observe(viewportRef.current); + } + + this.#initialized = true; + + // Since we updated, we're now going to compute the heights of all visible + // elements and store them in a cache. This allows us to get more accurate + // buffer regions to make scrolling correct when these elements no longer + // exist. + let index = this.#startIndex; + let element = this.#topBufferRef.current.nextSibling; + let elementRect = element?.getBoundingClientRect(); + while ( + Element.isInstance(element) && + index < this.#clampedEndIndex && + element !== this.#bottomBufferRef.current + ) { + const next = element.nextSibling; + const nextRect = next.getBoundingClientRect(); + this.#cachedHeights[index] = nextRect.top - elementRect.top; + element = next; + elementRect = nextRect; + index++; + } + + serviceContainer.emitForTests("lazy-message-list-updated-or-noop"); + } + + componentWillUnmount() { + this.#removeListeners(); + } + + #initialized; + #topBufferRef; + #bottomBufferRef; + #viewportHeight; + #startIndex; + #resizeObserver; + #cachedHeights; + #scrollHandlerBinding; + + get #maxIndex() { + return this.props.items.length - 1; + } + + get #overdrawHeight() { + return this.props.scrollOverdrawCount * this.props.itemDefaultHeight; + } + + get #numItemsToDraw() { + const scrollingWindowCount = Math.ceil( + this.#viewportHeight / this.props.itemDefaultHeight + ); + return scrollingWindowCount + 2 * this.props.scrollOverdrawCount; + } + + get #unclampedEndIndex() { + return this.#startIndex + this.#numItemsToDraw; + } + + // Since the "end index" is computed based off a fixed offset from the start + // index, it can exceed the length of our items array. This is just a helper + // to ensure we don't exceed that. + get #clampedEndIndex() { + return Math.min(this.#unclampedEndIndex, this.props.items.length); + } + + /** + * Increases our start index until we've passed enough elements to cover + * the difference in px between where we are and where we want to be. + * + * @param Number startIndex + * The current value of our start index. + * @param Number deltaPx + * The difference in pixels between where we want to be and + * where we are. + * @return {Number} The new computed start index. + */ + #increaseStartIndex(startIndex, deltaPx) { + for (let i = startIndex + 1; i < this.props.items.length; i++) { + deltaPx -= this.#cachedHeights[i]; + startIndex = i; + + if (deltaPx <= 0) { + break; + } + } + return startIndex; + } + + /** + * Decreases our start index until we've passed enough elements to cover + * the difference in px between where we are and where we want to be. + * + * @param Number startIndex + * The current value of our start index. + * @param Number deltaPx + * The difference in pixels between where we want to be and + * where we are. + * @return {Number} The new computed start index. + */ + #decreaseStartIndex(startIndex, diff) { + for (let i = startIndex - 1; i >= 0; i--) { + diff -= this.#cachedHeights[i]; + startIndex = i; + + if (diff <= 0) { + break; + } + } + return startIndex; + } + + #scrollHandler() { + if (!this.props.viewportRef.current || !this.#topBufferRef.current) { + return; + } + + const scrollportMin = + this.props.viewportRef.current.getBoundingClientRect().top - + this.#overdrawHeight; + const uppermostItemRect = + this.#topBufferRef.current.nextSibling.getBoundingClientRect(); + const uppermostItemMin = uppermostItemRect.top; + const uppermostItemMax = uppermostItemRect.bottom; + + let nextStartIndex = this.#startIndex; + const downwardPx = scrollportMin - uppermostItemMax; + const upwardPx = uppermostItemMin - scrollportMin; + if (downwardPx > 0) { + nextStartIndex = this.#increaseStartIndex(nextStartIndex, downwardPx); + } else if (upwardPx > 0) { + nextStartIndex = this.#decreaseStartIndex(nextStartIndex, upwardPx); + } + + nextStartIndex = Math.max( + 0, + Math.min(nextStartIndex, this.props.items.length - this.#numItemsToDraw) + ); + + if (nextStartIndex !== this.#startIndex) { + this.#startIndex = nextStartIndex; + this.forceUpdate(); + } else { + const { serviceContainer } = this.props; + serviceContainer.emitForTests("lazy-message-list-updated-or-noop"); + } + } + + #addListeners() { + const { viewportRef } = this.props; + viewportRef.current.addEventListener("scroll", this.#scrollHandlerBinding); + this.#resizeObserver = new ResizeObserver(entries => { + this.#viewportHeight = + viewportRef.current.parentNode.parentNode.clientHeight; + this.forceUpdate(); + }); + } + + #removeListeners() { + const { viewportRef } = this.props; + this.#resizeObserver?.disconnect(); + viewportRef.current?.removeEventListener( + "scroll", + this.#scrollHandlerBinding + ); + } + + get bottomBuffer() { + return this.#bottomBufferRef.current; + } + + isItemNearBottom(index) { + return index >= this.props.items.length - this.#numItemsToDraw; + } + + render() { + const { items, itemDefaultHeight, renderItem, itemsToKeepAlive } = + this.props; + if (!items.length) { + return createElement(Fragment, { + key: "LazyMessageList", + }); + } + + // Resize our cached heights to fit if necessary. + const countUncached = items.length - this.#cachedHeights.length; + if (countUncached > 0) { + // It would be lovely if javascript allowed us to resize an array in one + // go. I think this is the closest we can get to that. This in theory + // allows us to realloc, and doesn't require copying the whole original + // array like concat does. + this.#cachedHeights.push(...Array(countUncached).fill(itemDefaultHeight)); + } + + let topBufferHeight = 0; + let bottomBufferHeight = 0; + // We can't compute the bottom buffer height until the end, so we just + // store the index of where it needs to go. + let bottomBufferIndex = 0; + let currentChild = 0; + const startIndex = this.#startIndex; + const endIndex = this.#clampedEndIndex; + // We preallocate this array to avoid allocations in the loop. The minimum, + // and typical length for it is the size of the body plus 2 for the top and + // bottom buffers. It can be bigger due to itemsToKeepAlive, but we can't just + // add the size, since itemsToKeepAlive could in theory hold items which are + // not even in the list. + const children = new Array(endIndex - startIndex + 2); + const pushChild = c => { + if (currentChild >= children.length) { + children.push(c); + } else { + children[currentChild] = c; + } + return currentChild++; + }; + for (let i = 0; i < items.length; i++) { + const itemId = items[i]; + if (i < startIndex) { + if (i == 0 || itemsToKeepAlive.has(itemId)) { + // If this is our first item, and we wouldn't otherwise be rendering + // it, we want to ensure that it's at the beginning of our children + // array to ensure keyboard navigation functions properly. + pushChild(renderItem(itemId, i)); + } else { + topBufferHeight += this.#cachedHeights[i]; + } + } else if (i < endIndex) { + if (i == startIndex) { + pushChild( + createElement("div", { + key: "LazyMessageListTop", + className: "lazy-message-list-top", + ref: this.#topBufferRef, + style: { height: topBufferHeight }, + }) + ); + } + pushChild(renderItem(itemId, i)); + if (i == endIndex - 1) { + // We're just reserving the bottom buffer's spot in the children + // array here. We will create the actual element and assign it at + // this index after the loop. + bottomBufferIndex = pushChild(null); + } + } else if (i == items.length - 1 || itemsToKeepAlive.has(itemId)) { + // Similarly to the logic for our first item, we also want to ensure + // that our last item is always rendered as the last item in our + // children array. + pushChild(renderItem(itemId, i)); + } else { + bottomBufferHeight += this.#cachedHeights[i]; + } + } + + children[bottomBufferIndex] = createElement("div", { + key: "LazyMessageListBottom", + className: "lazy-message-list-bottom", + ref: this.#bottomBufferRef, + style: { height: bottomBufferHeight }, + }); + + return createElement( + Fragment, + { + key: "LazyMessageList", + }, + children + ); + } +} + +module.exports = LazyMessageList; diff --git a/devtools/client/webconsole/components/Output/Message.js b/devtools/client/webconsole/components/Output/Message.js new file mode 100644 index 0000000000..ee65c8947a --- /dev/null +++ b/devtools/client/webconsole/components/Output/Message.js @@ -0,0 +1,482 @@ +/* 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, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); +const { + MESSAGE_LEVEL, + MESSAGE_SOURCE, + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); +const { + MessageIndent, +} = require("resource://devtools/client/webconsole/components/Output/MessageIndent.js"); +const MessageIcon = require("resource://devtools/client/webconsole/components/Output/MessageIcon.js"); +const FrameView = createFactory( + require("resource://devtools/client/shared/components/Frame.js") +); + +loader.lazyRequireGetter( + this, + "CollapseButton", + "resource://devtools/client/webconsole/components/Output/CollapseButton.js" +); +loader.lazyRequireGetter( + this, + "MessageRepeat", + "resource://devtools/client/webconsole/components/Output/MessageRepeat.js" +); +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +loader.lazyRequireGetter( + this, + "SmartTrace", + "resource://devtools/client/shared/components/SmartTrace.js" +); + +class Message extends Component { + static get propTypes() { + return { + open: PropTypes.bool, + collapsible: PropTypes.bool, + collapseTitle: PropTypes.string, + disabled: PropTypes.bool, + onToggle: PropTypes.func, + source: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + level: PropTypes.string.isRequired, + indent: PropTypes.number.isRequired, + inWarningGroup: PropTypes.bool, + isBlockedNetworkMessage: PropTypes.bool, + topLevelClasses: PropTypes.array.isRequired, + messageBody: PropTypes.any.isRequired, + repeat: PropTypes.any, + frame: PropTypes.any, + attachment: PropTypes.any, + stacktrace: PropTypes.any, + messageId: PropTypes.string, + scrollToMessage: PropTypes.bool, + exceptionDocURL: PropTypes.string, + request: PropTypes.object, + dispatch: PropTypes.func, + timeStamp: PropTypes.number, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.shape({ + emitForTests: PropTypes.func.isRequired, + onViewSource: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func, + onViewSourceInStyleEditor: PropTypes.func, + openContextMenu: PropTypes.func.isRequired, + openLink: PropTypes.func.isRequired, + sourceMapURLService: PropTypes.any, + preventStacktraceInitialRenderDelay: PropTypes.bool, + }), + notes: PropTypes.arrayOf( + PropTypes.shape({ + messageBody: PropTypes.string.isRequired, + frame: PropTypes.any, + }) + ), + maybeScrollToBottom: PropTypes.func, + message: PropTypes.object.isRequired, + }; + } + + static get defaultProps() { + return { + indent: 0, + }; + } + + constructor(props) { + super(props); + this.onLearnMoreClick = this.onLearnMoreClick.bind(this); + this.toggleMessage = this.toggleMessage.bind(this); + this.onContextMenu = this.onContextMenu.bind(this); + this.renderIcon = this.renderIcon.bind(this); + } + + componentDidMount() { + if (this.messageNode) { + if (this.props.scrollToMessage) { + this.messageNode.scrollIntoView(); + } + + this.emitNewMessage(this.messageNode); + } + } + + componentDidCatch(e) { + this.setState({ error: e }); + } + + // Event used in tests. Some message types don't pass it in because existing tests + // did not emit for them. + emitNewMessage(node) { + const { serviceContainer, messageId, timeStamp } = this.props; + serviceContainer.emitForTests( + "new-messages", + new Set([{ node, messageId, timeStamp }]) + ); + } + + onLearnMoreClick(e) { + const { exceptionDocURL } = this.props; + this.props.serviceContainer.openLink(exceptionDocURL, e); + e.preventDefault(); + } + + toggleMessage(e) { + // Don't bubble up to the main App component, which redirects focus to input, + // making difficult for screen reader users to review output + e.stopPropagation(); + const { open, dispatch, messageId, onToggle, disabled } = this.props; + + if (disabled) { + return; + } + + // Early exit the function to avoid the message to collapse if the user is + // selecting a range in the toggle message. + const window = e.target.ownerDocument.defaultView; + if (window.getSelection && window.getSelection().type === "Range") { + return; + } + + // If defined on props, we let the onToggle() method handle the toggling, + // otherwise we toggle the message open/closed ourselves. + if (onToggle) { + onToggle(messageId, e); + } else if (open) { + dispatch(actions.messageClose(messageId)); + } else { + dispatch(actions.messageOpen(messageId)); + } + } + + onContextMenu(e) { + const { serviceContainer, source, request, messageId } = this.props; + const messageInfo = { + source, + request, + messageId, + }; + serviceContainer.openContextMenu(e, messageInfo); + e.stopPropagation(); + e.preventDefault(); + } + + renderIcon() { + const { level, inWarningGroup, isBlockedNetworkMessage, type, disabled } = + this.props; + + if (inWarningGroup) { + return undefined; + } + + if (disabled) { + return MessageIcon({ + level: MESSAGE_LEVEL.INFO, + type, + title: l10n.getStr("webconsole.disableIcon.title"), + }); + } + + if (isBlockedNetworkMessage) { + return MessageIcon({ + level: MESSAGE_LEVEL.ERROR, + type: "blockedReason", + }); + } + + return MessageIcon({ + level, + type, + }); + } + + renderTimestamp() { + if (!this.props.timestampsVisible) { + return null; + } + + return dom.span( + { + className: "timestamp devtools-monospace", + }, + l10n.timestampString(this.props.timeStamp || Date.now()) + ); + } + + renderErrorState() { + const newBugUrl = + "https://bugzilla.mozilla.org/enter_bug.cgi?product=DevTools&component=Console"; + const timestampEl = this.renderTimestamp(); + + return dom.div( + { + className: "message error message-did-catch", + }, + timestampEl, + MessageIcon({ level: "error" }), + dom.span( + { className: "message-body-wrapper" }, + dom.span( + { + className: "message-flex-body", + }, + // Add whitespaces for formatting when copying to the clipboard. + timestampEl ? " " : null, + dom.span( + { className: "message-body devtools-monospace" }, + l10n.getFormatStr("webconsole.message.componentDidCatch.label", [ + newBugUrl, + ]), + dom.button( + { + className: "devtools-button", + onClick: () => + navigator.clipboard.writeText( + JSON.stringify( + this.props.message, + function (key, value) { + // The message can hold one or multiple fronts that we need to serialize + if (value?.getGrip) { + return value.getGrip(); + } + return value; + }, + 2 + ) + ), + }, + l10n.getStr( + "webconsole.message.componentDidCatch.copyButton.label" + ) + ) + ) + ) + ), + dom.br() + ); + } + + // eslint-disable-next-line complexity + render() { + if (this.state && this.state.error) { + return this.renderErrorState(); + } + + const { + open, + collapsible, + collapseTitle, + disabled, + source, + type, + level, + indent, + inWarningGroup, + topLevelClasses, + messageBody, + frame, + stacktrace, + serviceContainer, + exceptionDocURL, + messageId, + notes, + } = this.props; + + topLevelClasses.push("message", source, type, level); + if (open) { + topLevelClasses.push("open"); + } + + if (disabled) { + topLevelClasses.push("disabled"); + } + + const timestampEl = this.renderTimestamp(); + const icon = this.renderIcon(); + + // Figure out if there is an expandable part to the message. + let attachment = null; + if (this.props.attachment) { + attachment = this.props.attachment; + } else if (stacktrace && open) { + const smartTraceAttributes = { + stacktrace, + onViewSourceInDebugger: + serviceContainer.onViewSourceInDebugger || + serviceContainer.onViewSource, + onViewSource: serviceContainer.onViewSource, + onReady: this.props.maybeScrollToBottom, + sourceMapURLService: serviceContainer.sourceMapURLService, + }; + + if (serviceContainer.preventStacktraceInitialRenderDelay) { + smartTraceAttributes.initialRenderDelay = 0; + } + + attachment = dom.div( + { + className: "stacktrace devtools-monospace", + }, + createElement(SmartTrace, smartTraceAttributes) + ); + } + + // If there is an expandable part, make it collapsible. + let collapse = null; + if (collapsible && !disabled) { + collapse = createElement(CollapseButton, { + open, + title: collapseTitle, + onClick: this.toggleMessage, + }); + } + + let notesNodes; + if (notes) { + notesNodes = notes.map(note => + dom.span( + { className: "message-flex-body error-note" }, + dom.span( + { className: "message-body devtools-monospace" }, + "note: " + note.messageBody + ), + dom.span( + { className: "message-location devtools-monospace" }, + note.frame + ? FrameView({ + frame: note.frame, + onClick: serviceContainer + ? serviceContainer.onViewSourceInDebugger || + serviceContainer.onViewSource + : undefined, + showEmptyPathAsHost: true, + sourceMapURLService: serviceContainer + ? serviceContainer.sourceMapURLService + : undefined, + }) + : null + ) + ) + ); + } else { + notesNodes = []; + } + + const repeat = + this.props.repeat && this.props.repeat > 1 + ? createElement(MessageRepeat, { repeat: this.props.repeat }) + : null; + + let onFrameClick; + if (serviceContainer && frame) { + if (source === MESSAGE_SOURCE.CSS) { + onFrameClick = + serviceContainer.onViewSourceInStyleEditor || + serviceContainer.onViewSource; + } else { + // Point everything else to debugger, if source not available, + // it will fall back to view-source. + onFrameClick = + serviceContainer.onViewSourceInDebugger || + serviceContainer.onViewSource; + } + } + + // Configure the location. + const location = frame + ? FrameView({ + className: "message-location devtools-monospace", + frame, + onClick: onFrameClick, + showEmptyPathAsHost: true, + sourceMapURLService: serviceContainer + ? serviceContainer.sourceMapURLService + : undefined, + messageSource: source, + }) + : null; + + let learnMore; + if (exceptionDocURL) { + learnMore = dom.a( + { + className: "learn-more-link webconsole-learn-more-link", + href: exceptionDocURL, + title: exceptionDocURL.split("?")[0], + onClick: this.onLearnMoreClick, + }, + `[${l10n.getStr("webConsoleMoreInfoLabel")}]` + ); + } + + const bodyElements = Array.isArray(messageBody) + ? messageBody + : [messageBody]; + + return dom.div( + { + className: topLevelClasses.join(" "), + onContextMenu: this.onContextMenu, + ref: node => { + this.messageNode = node; + }, + "data-message-id": messageId, + "data-indent": indent || 0, + "aria-live": type === MESSAGE_TYPE.COMMAND ? "off" : "polite", + }, + timestampEl, + MessageIndent({ + indent, + inWarningGroup, + }), + this.props.isBlockedNetworkMessage ? collapse : icon, + this.props.isBlockedNetworkMessage ? icon : collapse, + dom.span( + { className: "message-body-wrapper" }, + dom.span( + { + className: "message-flex-body", + onClick: collapsible ? this.toggleMessage : undefined, + }, + // Add whitespaces for formatting when copying to the clipboard. + timestampEl ? " " : null, + dom.span( + { className: "message-body devtools-monospace" }, + ...bodyElements, + learnMore + ), + repeat ? " " : null, + repeat, + " ", + location + ), + attachment, + ...notesNodes + ), + // If an attachment is displayed, the final newline is handled by the attachment. + attachment ? null : dom.br() + ); + } +} + +module.exports = Message; diff --git a/devtools/client/webconsole/components/Output/MessageContainer.js b/devtools/client/webconsole/components/Output/MessageContainer.js new file mode 100644 index 0000000000..f1140e80c3 --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageContainer.js @@ -0,0 +1,131 @@ +/* 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"); +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +loader.lazyRequireGetter( + this, + "isWarningGroup", + "resource://devtools/client/webconsole/utils/messages.js", + true +); + +const { + MESSAGE_SOURCE, + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const ConsoleApiCall = require("resource://devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js"); +const ConsoleCommand = require("resource://devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js"); +const CSSWarning = require("resource://devtools/client/webconsole/components/Output/message-types/CSSWarning.js"); +const DefaultRenderer = require("resource://devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js"); +const EvaluationResult = require("resource://devtools/client/webconsole/components/Output/message-types/EvaluationResult.js"); +const NavigationMarker = require("resource://devtools/client/webconsole/components/Output/message-types/NavigationMarker.js"); +const NetworkEventMessage = require("resource://devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js"); +const PageError = require("resource://devtools/client/webconsole/components/Output/message-types/PageError.js"); +const SimpleTable = require("resource://devtools/client/webconsole/components/Output/message-types/SimpleTable.js"); +const JSTracerTrace = require("resource://devtools/client/webconsole/components/Output/message-types/JSTracerTrace.js"); +const WarningGroup = require("resource://devtools/client/webconsole/components/Output/message-types/WarningGroup.js"); + +class MessageContainer extends Component { + static get propTypes() { + return { + messageId: PropTypes.string.isRequired, + open: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object.isRequired, + cssMatchingElements: PropTypes.object, + timestampsVisible: PropTypes.bool.isRequired, + repeat: PropTypes.number, + badge: PropTypes.number, + indent: PropTypes.number, + networkMessageUpdate: PropTypes.object, + getMessage: PropTypes.func.isRequired, + inWarningGroup: PropTypes.bool, + disabled: PropTypes.bool, + }; + } + + shouldComponentUpdate(nextProps) { + const triggeringUpdateProps = [ + "repeat", + "open", + "cssMatchingElements", + "timestampsVisible", + "networkMessageUpdate", + "badge", + "inWarningGroup", + "disabled", + ]; + + return triggeringUpdateProps.some( + prop => this.props[prop] !== nextProps[prop] + ); + } + + render() { + const message = this.props.getMessage(); + + const MessageComponent = getMessageComponent(message); + return MessageComponent(Object.assign({ message }, this.props)); + } +} + +function getMessageComponent(message) { + if (!message) { + return DefaultRenderer; + } + + switch (message.source) { + case MESSAGE_SOURCE.CONSOLE_API: + return ConsoleApiCall; + case MESSAGE_SOURCE.JSTRACER: + return JSTracerTrace; + case MESSAGE_SOURCE.NETWORK: + return NetworkEventMessage; + case MESSAGE_SOURCE.CSS: + return CSSWarning; + case MESSAGE_SOURCE.JAVASCRIPT: + switch (message.type) { + case MESSAGE_TYPE.COMMAND: + return ConsoleCommand; + case MESSAGE_TYPE.RESULT: + return EvaluationResult; + // @TODO this is probably not the right behavior, but works for now. + // Chrome doesn't distinguish between page errors and log messages. We + // may want to remove the PageError component and just handle errors + // with ConsoleApiCall. + case MESSAGE_TYPE.LOG: + return PageError; + default: + return DefaultRenderer; + } + case MESSAGE_SOURCE.CONSOLE_FRONTEND: + if (isWarningGroup(message)) { + return WarningGroup; + } + if (message.type === MESSAGE_TYPE.SIMPLE_TABLE) { + return SimpleTable; + } + if (message.type === MESSAGE_TYPE.NAVIGATION_MARKER) { + return NavigationMarker; + } + break; + } + + return DefaultRenderer; +} + +module.exports.MessageContainer = MessageContainer; + +// Exported so we can test it with unit tests. +module.exports.getMessageComponent = getMessageComponent; diff --git a/devtools/client/webconsole/components/Output/MessageIcon.js b/devtools/client/webconsole/components/Output/MessageIcon.js new file mode 100644 index 0000000000..ed2765414e --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageIcon.js @@ -0,0 +1,78 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const { + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const l10nLevels = { + error: "level.error", + warn: "level.warn", + info: "level.info", + log: "level.log", + debug: "level.debug", + jstracer: "level.jstracer", +}; + +// Store common icons so they can be used without recreating the element +// during render. +const CONSTANT_ICONS = Object.entries(l10nLevels).reduce( + (acc, [key, l10nLabel]) => { + acc[key] = getIconElement(l10nLabel); + return acc; + }, + {} +); + +function getIconElement(level, type, title) { + title = title || l10n.getStr(l10nLevels[level] || level); + const classnames = ["icon"]; + + if (type === "logPoint") { + title = l10n.getStr("logpoint.title"); + classnames.push("logpoint"); + } else if (type === "blockedReason") { + title = l10n.getStr("blockedrequest.label"); + } else if (type === MESSAGE_TYPE.COMMAND) { + title = l10n.getStr("command.title"); + } else if (type === MESSAGE_TYPE.RESULT) { + title = l10n.getStr("result.title"); + } else if (level == "level.jstracer" || type == MESSAGE_TYPE.JSTRACER) { + // Actual traces uses JSTracerTrace objects and `level` attribute, + // while log messages relating to tracing uses ConsoleApiCall, where level == "log" and so rather uses `type` attribute. + classnames.push("logtrace"); + } + + return dom.span({ + className: classnames.join(" "), + title, + "aria-live": "off", + }); +} + +MessageIcon.displayName = "MessageIcon"; +MessageIcon.propTypes = { + level: PropTypes.string.isRequired, + type: PropTypes.string, + title: PropTypes.string, +}; + +function MessageIcon(props) { + const { level, type, title } = props; + + if (type) { + return getIconElement(level, type, title); + } + + return CONSTANT_ICONS[level] || getIconElement(level); +} + +module.exports = MessageIcon; diff --git a/devtools/client/webconsole/components/Output/MessageIndent.js b/devtools/client/webconsole/components/Output/MessageIndent.js new file mode 100644 index 0000000000..cb77c5ef8c --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageIndent.js @@ -0,0 +1,47 @@ +/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const INDENT_WIDTH = 12; + +// Store common indents so they can be used without recreating the element during render. +const CONSTANT_INDENTS = [getIndentElement(1)]; +const IN_WARNING_GROUP_INDENT = getIndentElement(1, "warning-indent"); + +function getIndentElement(indent, className) { + return dom.span({ + className: `indent${className ? " " + className : ""}`, + style: { + width: indent * INDENT_WIDTH, + }, + }); +} + +function MessageIndent(props) { + const { indent, inWarningGroup } = props; + + if (inWarningGroup) { + return IN_WARNING_GROUP_INDENT; + } + + if (!indent) { + return null; + } + + return CONSTANT_INDENTS[indent] || getIndentElement(indent); +} + +MessageIndent.propTypes = { + indent: PropTypes.number, + inWarningGroup: PropTypes.bool, +}; + +module.exports.MessageIndent = MessageIndent; + +// Exported so we can test it with unit tests. +module.exports.INDENT_WIDTH = INDENT_WIDTH; diff --git a/devtools/client/webconsole/components/Output/MessageRepeat.js b/devtools/client/webconsole/components/Output/MessageRepeat.js new file mode 100644 index 0000000000..7bf846bcb0 --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageRepeat.js @@ -0,0 +1,35 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const messageRepeatsTooltip = l10n.getStr("messageRepeats.tooltip2"); + +MessageRepeat.displayName = "MessageRepeat"; + +MessageRepeat.propTypes = { + repeat: PropTypes.number.isRequired, +}; + +function MessageRepeat(props) { + const { repeat } = props; + return dom.span( + { + className: "message-repeats", + title: PluralForm.get(repeat, messageRepeatsTooltip).replace( + "#1", + repeat + ), + }, + repeat + ); +} + +module.exports = MessageRepeat; diff --git a/devtools/client/webconsole/components/Output/message-types/CSSWarning.js b/devtools/client/webconsole/components/Output/message-types/CSSWarning.js new file mode 100644 index 0000000000..cef91c22be --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/CSSWarning.js @@ -0,0 +1,173 @@ +/* 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, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +loader.lazyRequireGetter( + this, + "GripMessageBody", + "resource://devtools/client/webconsole/components/Output/GripMessageBody.js" +); + +/** + * This component is responsible for rendering CSS warnings in the Console panel. + * + * CSS warnings are expandable when they have associated CSS selectors so the + * user can inspect any matching DOM elements. Not all CSS warnings have + * associated selectors (those that don't are not expandable) and not all + * selectors match elements in the current page (warnings can appear for styles + * which don't apply to the current page). + * + * @extends Component + */ +class CSSWarning extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + inWarningGroup: PropTypes.bool.isRequired, + message: PropTypes.object.isRequired, + open: PropTypes.bool, + cssMatchingElements: PropTypes.object, + repeat: PropTypes.any, + serviceContainer: PropTypes.object, + timestampsVisible: PropTypes.bool.isRequired, + setExpanded: PropTypes.func, + }; + } + + static get defaultProps() { + return { + open: false, + }; + } + + static get displayName() { + return "CSSWarning"; + } + + constructor(props) { + super(props); + this.onToggle = this.onToggle.bind(this); + } + + onToggle(messageId) { + const { dispatch, message, cssMatchingElements, open } = this.props; + + if (open) { + dispatch(actions.messageClose(messageId)); + } else if (cssMatchingElements) { + // If the message already has information about the elements matching + // the selectors associated with this CSS warning, just open the message. + dispatch(actions.messageOpen(messageId)); + } else { + // Query the server for elements matching the CSS selectors associated + // with this CSS warning and populate the message's additional cssMatchingElements with + // the result. It's an async operation and potentially expensive, so we only do it + // on demand, once, when the component is first expanded. + dispatch(actions.messageGetMatchingElements(message)); + dispatch(actions.messageOpen(messageId)); + } + } + + render() { + const { + dispatch, + message, + open, + cssMatchingElements, + repeat, + serviceContainer, + timestampsVisible, + inWarningGroup, + setExpanded, + } = this.props; + + const { + id: messageId, + indent, + cssSelectors, + source, + type, + level, + messageText, + frame, + exceptionDocURL, + timeStamp, + notes, + } = message; + + let messageBody; + if (typeof messageText === "string") { + messageBody = messageText; + } else if ( + typeof messageText === "object" && + messageText.type === "longString" + ) { + messageBody = `${message.messageText.initial}…`; + } + + // Create a message attachment only when the message is open and there is a result + // to the query for elements matching the CSS selectors associated with the message. + const attachment = + open && + cssMatchingElements !== undefined && + dom.div( + { className: "devtools-monospace" }, + dom.div( + { className: "elements-label" }, + l10n.getFormatStr("webconsole.cssWarningElements.label", [ + cssSelectors, + ]) + ), + GripMessageBody({ + dispatch, + escapeWhitespace: false, + grip: cssMatchingElements, + serviceContainer, + setExpanded, + }) + ); + + return Message({ + attachment, + collapsible: !!cssSelectors.length, + dispatch, + exceptionDocURL, + frame, + indent, + inWarningGroup, + level, + messageBody, + messageId, + notes, + open, + onToggle: this.onToggle, + repeat, + serviceContainer, + source, + timeStamp, + timestampsVisible, + topLevelClasses: [], + type, + message, + }); + } +} + +module.exports = createFactory(CSSWarning); diff --git a/devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js b/devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js new file mode 100644 index 0000000000..155075731f --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js @@ -0,0 +1,221 @@ +/* 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 { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const GripMessageBody = require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js"); +const ConsoleTable = createFactory( + require("resource://devtools/client/webconsole/components/Output/ConsoleTable.js") +); +const { + isGroupType, + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +ConsoleApiCall.displayName = "ConsoleApiCall"; + +ConsoleApiCall.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + open: PropTypes.bool, + serviceContainer: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + maybeScrollToBottom: PropTypes.func, +}; + +ConsoleApiCall.defaultProps = { + open: false, +}; + +function ConsoleApiCall(props) { + const { + dispatch, + message, + open, + serviceContainer, + timestampsVisible, + repeat, + maybeScrollToBottom, + setExpanded, + } = props; + const { + id: messageId, + indent, + source, + type, + level, + stacktrace, + frame, + timeStamp, + parameters, + messageText, + prefix, + userProvidedStyles, + } = message; + + let messageBody; + const messageBodyConfig = { + dispatch, + messageId, + parameters, + userProvidedStyles, + serviceContainer, + type, + maybeScrollToBottom, + setExpanded, + // When the object is a parameter of a console.dir call, we always want to show its + // properties, like regular object (i.e. not showing the DOM tree for an Element, or + // only showing the message + stacktrace for Error object). + customFormat: type !== "dir", + }; + + if (type === "trace") { + const traceParametersBody = + Array.isArray(parameters) && parameters.length + ? [" "].concat(formatReps(messageBodyConfig)) + : []; + + messageBody = [ + dom.span({ className: "cm-variable" }, "console.trace()"), + ...traceParametersBody, + ]; + } else if (type === "assert") { + const reps = formatReps(messageBodyConfig); + messageBody = dom.span({}, "Assertion failed: ", reps); + } else if (type === "table") { + // TODO: Chrome does not output anything, see if we want to keep this + messageBody = dom.span({ className: "cm-variable" }, "console.table()"); + } else if (parameters) { + messageBody = formatReps(messageBodyConfig); + if (prefix) { + messageBody.unshift( + dom.span( + { + className: "console-message-prefix", + }, + `${prefix}: ` + ) + ); + } + } else if (typeof messageText === "string") { + messageBody = messageText; + } else if (messageText) { + messageBody = GripMessageBody({ + dispatch, + messageId, + grip: messageText, + serviceContainer, + useQuotes: false, + transformEmptyString: true, + setExpanded, + type, + }); + } + + let attachment = null; + if (type === "table") { + attachment = ConsoleTable({ + dispatch, + id: message.id, + serviceContainer, + parameters: message.parameters, + }); + } + + let collapseTitle = null; + if (isGroupType(type)) { + collapseTitle = l10n.getStr("groupToggle"); + } + + const collapsible = + isGroupType(type) || (type === "error" && Array.isArray(stacktrace)); + const topLevelClasses = ["cm-s-mozilla"]; + + return Message({ + messageId, + open, + collapsible, + collapseTitle, + source, + type, + level, + topLevelClasses, + messageBody, + repeat, + frame, + stacktrace, + attachment, + serviceContainer, + dispatch, + indent, + timeStamp, + timestampsVisible, + parameters, + message, + maybeScrollToBottom, + }); +} + +function formatReps(options = {}) { + const { + dispatch, + loadedObjectProperties, + loadedObjectEntries, + messageId, + parameters, + serviceContainer, + userProvidedStyles, + type, + maybeScrollToBottom, + setExpanded, + customFormat, + } = options; + + const elements = []; + const parametersLength = parameters.length; + for (let i = 0; i < parametersLength; i++) { + elements.push( + GripMessageBody({ + dispatch, + messageId, + grip: parameters[i], + key: i, + userProvidedStyle: userProvidedStyles ? userProvidedStyles[i] : null, + serviceContainer, + useQuotes: false, + loadedObjectProperties, + loadedObjectEntries, + type, + maybeScrollToBottom, + setExpanded, + customFormat, + }) + ); + + // We need to interleave a space if we are not on the last element AND + // if we are not between 2 messages with user provided style. + if ( + i !== parametersLength - 1 && + (!userProvidedStyles || + userProvidedStyles[i] === undefined || + userProvidedStyles[i + 1] === undefined) + ) { + elements.push(" "); + } + } + + return elements; +} + +module.exports = ConsoleApiCall; diff --git a/devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js b/devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js new file mode 100644 index 0000000000..5cfb87113c --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js @@ -0,0 +1,105 @@ +/* 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 { + createElement, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { ELLIPSIS } = require("resource://devtools/shared/l10n.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +ConsoleCommand.displayName = "ConsoleCommand"; + +ConsoleCommand.propTypes = { + message: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + maybeScrollToBottom: PropTypes.func, + open: PropTypes.bool, +}; + +ConsoleCommand.defaultProps = { + open: false, +}; + +/** + * Displays input from the console. + */ +function ConsoleCommand(props) { + const { + message, + timestampsVisible, + serviceContainer, + maybeScrollToBottom, + dispatch, + open, + } = props; + + const { indent, source, type, level, timeStamp, id: messageId } = message; + + const messageText = trimCode(message.messageText); + const messageLines = messageText.split("\n"); + + const collapsible = messageLines.length > 5; + + // Show only first 5 lines if its collapsible and closed + const visibleMessageText = + collapsible && !open + ? `${messageLines.slice(0, 5).join("\n")}${ELLIPSIS}` + : messageText; + + // This uses a Custom Element to syntax highlight when possible. If it's not + // (no CodeMirror editor), then it will just render text. + const messageBody = createElement( + "syntax-highlighted", + null, + visibleMessageText + ); + + // Enable collapsing the code if it has multiple lines + + return Message({ + messageId, + source, + type, + level, + topLevelClasses: [], + messageBody, + collapsible, + open, + dispatch, + serviceContainer, + indent, + timeStamp, + timestampsVisible, + maybeScrollToBottom, + message, + }); +} + +module.exports = ConsoleCommand; + +/** + * Trim user input to avoid blank lines before and after messages + */ +function trimCode(input) { + if (typeof input !== "string") { + return input; + } + + // Trim on both edges if we have a single line of content + if (input.trim().includes("\n") === false) { + return input.trim(); + } + + // For multiline input we want to keep the indentation of the first line + // with non-whitespace, so we can't .trim()/.trimStart(). + return input.replace(/^\s*\n/, "").trimEnd(); +} diff --git a/devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js b/devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js new file mode 100644 index 0000000000..893e6b04c6 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js @@ -0,0 +1,15 @@ +/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +DefaultRenderer.displayName = "DefaultRenderer"; + +function DefaultRenderer(props) { + return dom.div({}, "This message type is not supported yet."); +} + +module.exports = DefaultRenderer; diff --git a/devtools/client/webconsole/components/Output/message-types/EvaluationResult.js b/devtools/client/webconsole/components/Output/message-types/EvaluationResult.js new file mode 100644 index 0000000000..60d44a9f99 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/EvaluationResult.js @@ -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/. */ + +"use strict"; + +// React & Redux +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); +const GripMessageBody = require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js"); + +EvaluationResult.displayName = "EvaluationResult"; + +EvaluationResult.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + maybeScrollToBottom: PropTypes.func, + open: PropTypes.bool, +}; + +EvaluationResult.defaultProps = { + open: false, +}; + +function EvaluationResult(props) { + const { + dispatch, + message, + serviceContainer, + timestampsVisible, + maybeScrollToBottom, + open, + setExpanded, + } = props; + + const { + source, + type, + helperType, + level, + id: messageId, + indent, + hasException, + exceptionDocURL, + stacktrace, + frame, + timeStamp, + parameters, + notes, + } = message; + + let messageBody; + if ( + typeof message.messageText !== "undefined" && + message.messageText !== null + ) { + const messageText = message.messageText?.getGrip + ? message.messageText.getGrip() + : message.messageText; + if (typeof messageText === "string") { + messageBody = messageText; + } else if ( + typeof messageText === "object" && + messageText.type === "longString" + ) { + messageBody = `${messageText.initial}…`; + } + } else { + messageBody = []; + if (hasException) { + messageBody.push("Uncaught "); + } + messageBody.push( + GripMessageBody({ + dispatch, + messageId, + grip: parameters[0], + key: "grip", + serviceContainer, + useQuotes: !hasException, + escapeWhitespace: false, + type, + helperType, + maybeScrollToBottom, + setExpanded, + customFormat: true, + }) + ); + } + + const topLevelClasses = ["cm-s-mozilla"]; + + return Message({ + dispatch, + source, + type, + level, + indent, + topLevelClasses, + messageBody, + messageId, + serviceContainer, + exceptionDocURL, + stacktrace, + collapsible: Array.isArray(stacktrace), + open, + frame, + timeStamp, + parameters, + notes, + timestampsVisible, + maybeScrollToBottom, + message, + }); +} + +module.exports = EvaluationResult; diff --git a/devtools/client/webconsole/components/Output/message-types/JSTracerTrace.js b/devtools/client/webconsole/components/Output/message-types/JSTracerTrace.js new file mode 100644 index 0000000000..7d6ddd2623 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/JSTracerTrace.js @@ -0,0 +1,174 @@ +/* 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 { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const GripMessageBody = require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js"); + +const { + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +JSTracerTrace.displayName = "JSTracerTrace"; + +JSTracerTrace.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + serviceContainer: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + maybeScrollToBottom: PropTypes.func, +}; + +function JSTracerTrace(props) { + const { + dispatch, + message, + serviceContainer, + timestampsVisible, + repeat, + maybeScrollToBottom, + setExpanded, + } = props; + + const { + // List of common attributes for all tracer messages + timeStamp, + prefix, + depth, + source, + + // Attribute specific to DOM event + eventName, + + // Attributes specific to function calls + frame, + implementation, + displayName, + parameters, + + // Attributes specific to function call returns + returnedValue, + relatedTraceId, + // See tracer.jsm FRAME_EXIT_REASONS + why, + } = message; + + // When we are logging a DOM event, we have the `eventName` defined. + let messageBody; + if (eventName) { + messageBody = [dom.span({ className: "jstracer-dom-event" }, eventName)]; + } else if (typeof relatedTraceId == "number") { + messageBody = [ + dom.span({ className: "jstracer-io" }, "⟵ "), + dom.span({ className: "jstracer-display-name" }, displayName), + ]; + } else { + messageBody = [ + dom.span({ className: "jstracer-io" }, "⟶ "), + dom.span({ className: "jstracer-implementation" }, implementation), + // Add a space in order to improve copy paste rendering + dom.span({ className: "jstracer-display-name" }, " " + displayName), + ]; + } + + let messageBodyConfig; + if (parameters || why) { + messageBodyConfig = { + dispatch, + serviceContainer, + maybeScrollToBottom, + setExpanded, + type: "", + useQuotes: true, + + // Disable custom formatter for now in traces + customFormat: false, + }; + } + // Arguments will only be passed on-demand + + if (parameters) { + messageBody.push("(", ...formatReps(messageBodyConfig, parameters), ")"); + } + // Returned value will also only be passed on-demand + if (why) { + messageBody.push( + // Add a spaces in order to improve copy paste rendering + dom.span({ className: "jstracer-exit-frame-reason" }, " " + why + " "), + formatRep(messageBodyConfig, returnedValue) + ); + } + + if (prefix) { + messageBody.unshift( + dom.span( + { + className: "console-message-prefix", + }, + `${prefix}` + ) + ); + } + + const topLevelClasses = ["cm-s-mozilla"]; + + return Message({ + collapsible: false, + source, + level: MESSAGE_TYPE.JSTRACER, + topLevelClasses, + messageBody, + repeat, + frame, + stacktrace: null, + attachment: null, + serviceContainer, + dispatch, + indent: depth, + timeStamp, + timestampsVisible, + parameters, + message, + maybeScrollToBottom, + }); +} + +/** + * Generated the list of GripMessageBody for a list of objects. + * GripMessageBody is Rep's rendering for a given Object, via its object actor's front. + */ +function formatReps(messageBodyConfig, objects) { + const elements = []; + const length = objects.length; + for (let i = 0; i < length; i++) { + elements.push(formatRep(messageBodyConfig, objects[i], i)); + + // We need to interleave a comma if we are not on the last element + if (i !== length - 1) { + elements.push(", "); + } + } + + return elements; +} + +function formatRep(messageBodyConfig, grip, key) { + return GripMessageBody({ + ...messageBodyConfig, + grip, + key, + }); +} + +module.exports = JSTracerTrace; diff --git a/devtools/client/webconsole/components/Output/message-types/NavigationMarker.js b/devtools/client/webconsole/components/Output/message-types/NavigationMarker.js new file mode 100644 index 0000000000..7d14206a6a --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/NavigationMarker.js @@ -0,0 +1,62 @@ +/* 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 { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +NavigationMarker.displayName = "NavigationMarker"; + +NavigationMarker.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + serviceContainer: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + maybeScrollToBottom: PropTypes.func, +}; + +function NavigationMarker(props) { + const { + dispatch, + message, + serviceContainer, + timestampsVisible, + maybeScrollToBottom, + } = props; + const { + id: messageId, + indent, + source, + type, + level, + timeStamp, + messageText, + } = message; + + return Message({ + messageId, + source, + type, + level, + messageBody: messageText, + serviceContainer, + dispatch, + indent, + timeStamp, + timestampsVisible, + topLevelClasses: [], + message, + maybeScrollToBottom, + }); +} + +module.exports = NavigationMarker; diff --git a/devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js b/devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js new file mode 100644 index 0000000000..ce0961668b --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js @@ -0,0 +1,243 @@ +/* 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 { + createFactory, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); +const { + isMessageNetworkError, + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); + +loader.lazyRequireGetter( + this, + "TabboxPanel", + "resource://devtools/client/netmonitor/src/components/TabboxPanel.js" +); +const { + getHTTPStatusCodeURL, +} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js"); +const { + getUnicodeUrl, +} = require("resource://devtools/client/shared/unicode-url.js"); +loader.lazyRequireGetter( + this, + "BLOCKED_REASON_MESSAGES", + "resource://devtools/client/netmonitor/src/constants.js", + true +); + +const LEARN_MORE = l10n.getStr("webConsoleMoreInfoLabel"); + +const isMacOS = Services.appinfo.OS === "Darwin"; + +NetworkEventMessage.displayName = "NetworkEventMessage"; + +NetworkEventMessage.propTypes = { + message: PropTypes.object.isRequired, + serviceContainer: PropTypes.shape({ + openNetworkPanel: PropTypes.func.isRequired, + resendNetworkRequest: PropTypes.func.isRequired, + }), + timestampsVisible: PropTypes.bool.isRequired, + networkMessageUpdate: PropTypes.object.isRequired, +}; + +/** + * This component is responsible for rendering network messages + * in the Console panel. + * + * Network logs are expandable and the user can inspect it inline + * within the Console panel (no need to switch to the Network panel). + * + * HTTP details are rendered using `TabboxPanel` component used to + * render contents of the side bar in the Network panel. + * + * All HTTP details data are fetched from the backend on-demand + * when the user is expanding network log for the first time. + */ +function NetworkEventMessage({ + message = {}, + serviceContainer, + timestampsVisible, + networkMessageUpdate = {}, + networkMessageActiveTabId, + dispatch, + open, + disabled, +}) { + const { + id, + indent, + source, + type, + level, + url, + method, + isXHR, + timeStamp, + blockedReason, + httpVersion, + status, + statusText, + totalTime, + } = message; + + const topLevelClasses = ["cm-s-mozilla"]; + if (isMessageNetworkError(message)) { + topLevelClasses.push("error"); + } + + let statusCode, statusInfo; + + if ( + httpVersion && + status && + statusText !== undefined && + totalTime !== undefined + ) { + const statusCodeDocURL = getHTTPStatusCodeURL( + status.toString(), + "webconsole" + ); + statusCode = dom.span( + { + className: "status-code", + "data-code": status, + title: LEARN_MORE, + onClick: e => { + e.stopPropagation(); + e.preventDefault(); + serviceContainer.openLink(statusCodeDocURL, e); + }, + }, + status + ); + statusInfo = dom.span( + { className: "status-info" }, + `[${httpVersion} `, + statusCode, + ` ${statusText} ${totalTime}ms]` + ); + } + + if (blockedReason) { + statusInfo = dom.span( + { className: "status-info" }, + BLOCKED_REASON_MESSAGES[blockedReason] + ); + topLevelClasses.push("network-message-blocked"); + } + + // Message body components. + const requestMethod = dom.span({ className: "method" }, method); + const xhr = isXHR + ? dom.span({ className: "xhr" }, l10n.getStr("webConsoleXhrIndicator")) + : null; + const unicodeURL = getUnicodeUrl(url); + const requestUrl = dom.a( + { + className: "url", + title: unicodeURL, + href: url, + onClick: e => { + // The href of the <a> is the actual URL, so we need to prevent the navigation + // within the console panel. + // We only want to handle Ctrl/Cmd + click to open the link in a new tab. + e.preventDefault(); + const shouldOpenLink = + (isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey); + if (shouldOpenLink) { + e.stopPropagation(); + serviceContainer.openLink(url, e); + } + }, + }, + unicodeURL + ); + const statusBody = statusInfo + ? dom.a({ className: "status" }, statusInfo) + : null; + + const messageBody = [xhr, requestMethod, requestUrl, statusBody]; + + // API consumed by Net monitor UI components. Most of the method + // are not needed in context of the Console panel (atm) and thus + // let's just provide empty implementation. + // Individual methods might be implemented step by step as needed. + const connector = { + viewSourceInDebugger: (srcUrl, line, column) => { + serviceContainer.onViewSourceInDebugger({ url: srcUrl, line, column }); + }, + getLongString: grip => { + return serviceContainer.getLongString(grip); + }, + triggerActivity: () => {}, + requestData: (requestId, dataType) => { + return serviceContainer.requestData(requestId, dataType); + }, + }; + + // Only render the attachment if the network-event is + // actually opened (performance optimization) and its not disabled. + const attachment = + open && + !disabled && + dom.div( + { + className: "network-info network-monitor", + }, + createElement(TabboxPanel, { + connector, + activeTabId: networkMessageActiveTabId, + request: networkMessageUpdate, + sourceMapURLService: serviceContainer.sourceMapURLService, + openLink: serviceContainer.openLink, + selectTab: tabId => { + dispatch(actions.selectNetworkMessageTab(tabId)); + }, + openNetworkDetails: enabled => { + if (!enabled) { + dispatch(actions.messageClose(id)); + } + }, + hideToggleButton: true, + showMessagesView: false, + }) + ); + + const request = { url, method }; + return Message({ + dispatch, + messageId: id, + source, + type, + level, + indent, + collapsible: true, + open, + disabled, + attachment, + topLevelClasses, + timeStamp, + messageBody, + serviceContainer, + request, + timestampsVisible, + isBlockedNetworkMessage: !!blockedReason, + message, + }); +} + +module.exports = NetworkEventMessage; diff --git a/devtools/client/webconsole/components/Output/message-types/PageError.js b/devtools/client/webconsole/components/Output/message-types/PageError.js new file mode 100644 index 0000000000..01828e968e --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/PageError.js @@ -0,0 +1,130 @@ +/* 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 { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); +const GripMessageBody = require("resource://devtools/client/webconsole/components/Output/GripMessageBody.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; +}); + +PageError.displayName = "PageError"; + +PageError.propTypes = { + message: PropTypes.object.isRequired, + open: PropTypes.bool, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + maybeScrollToBottom: PropTypes.func, + setExpanded: PropTypes.func, + inWarningGroup: PropTypes.bool.isRequired, +}; + +PageError.defaultProps = { + open: false, +}; + +function PageError(props) { + const { + dispatch, + message, + open, + repeat, + serviceContainer, + timestampsVisible, + maybeScrollToBottom, + setExpanded, + inWarningGroup, + } = props; + const { + id: messageId, + source, + type, + level, + messageText, + stacktrace, + frame, + exceptionDocURL, + timeStamp, + notes, + parameters, + hasException, + isPromiseRejection, + } = message; + + const messageBody = []; + + const repsProps = { + useQuotes: false, + escapeWhitespace: false, + openLink: serviceContainer.openLink, + }; + + if (hasException) { + const prefix = `Uncaught${isPromiseRejection ? " (in promise)" : ""} `; + messageBody.push( + prefix, + GripMessageBody({ + key: "body", + dispatch, + messageId, + grip: parameters[0], + serviceContainer, + type, + customFormat: true, + maybeScrollToBottom, + setExpanded, + ...repsProps, + }) + ); + } else { + messageBody.push( + REPS.StringRep.rep({ + key: "bodytext", + object: messageText, + mode: MODE.LONG, + ...repsProps, + }) + ); + } + + return Message({ + dispatch, + messageId, + open, + collapsible: Array.isArray(stacktrace), + source, + type, + level, + topLevelClasses: [], + indent: message.indent, + inWarningGroup, + messageBody, + repeat, + frame, + stacktrace, + serviceContainer, + exceptionDocURL, + timeStamp, + notes, + timestampsVisible, + maybeScrollToBottom, + message, + }); +} + +module.exports = PageError; diff --git a/devtools/client/webconsole/components/Output/message-types/SimpleTable.js b/devtools/client/webconsole/components/Output/message-types/SimpleTable.js new file mode 100644 index 0000000000..35580e8163 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/SimpleTable.js @@ -0,0 +1,130 @@ +/* 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 { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const GripMessageBody = createFactory( + require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js") +); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); + +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +SimpleTable.displayName = "SimpleTable"; + +SimpleTable.propTypes = { + columns: PropTypes.object.isRequired, + items: PropTypes.array.isRequired, + dispatch: PropTypes.func.isRequired, + serviceContainer: PropTypes.object.isRequired, + message: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + open: PropTypes.bool, +}; + +function SimpleTable(props) { + const { dispatch, message, serviceContainer, timestampsVisible, open } = + props; + + const { + source, + type, + level, + id: messageId, + indent, + timeStamp, + columns, + items, + } = message; + + // if we don't have any data, don't show anything. + if (!items.length) { + return null; + } + const headerItems = []; + columns.forEach((value, key) => + headerItems.push( + dom.th( + { + key, + title: value, + }, + value + ) + ) + ); + + const rowItems = items.map((item, index) => { + const cells = []; + + columns.forEach((_, key) => { + const cellValue = item[key]; + const cellContent = + typeof cellValue === "undefined" + ? "" + : GripMessageBody({ + grip: cellValue, + mode: MODE.SHORT, + useQuotes: false, + serviceContainer, + dispatch, + }); + + cells.push( + dom.td( + { + key, + }, + cellContent + ) + ); + }); + return dom.tr({ key: index }, cells); + }); + + const attachment = dom.table( + { + className: "simple-table", + role: "grid", + }, + dom.thead({}, dom.tr({ className: "simple-table-header" }, headerItems)), + dom.tbody({}, rowItems) + ); + + const topLevelClasses = ["cm-s-mozilla"]; + return Message({ + attachment, + dispatch, + indent, + level, + messageId, + open, + serviceContainer, + source, + timeStamp, + timestampsVisible, + topLevelClasses, + type, + message, + messageBody: [], + }); +} + +module.exports = SimpleTable; diff --git a/devtools/client/webconsole/components/Output/message-types/WarningGroup.js b/devtools/client/webconsole/components/Output/message-types/WarningGroup.js new file mode 100644 index 0000000000..d54976dbcd --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/WarningGroup.js @@ -0,0 +1,80 @@ +/* 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 { + 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 Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const messageCountTooltip = l10n.getStr( + "webconsole.warningGroup.messageCount.tooltip" +); + +WarningGroup.displayName = "WarningGroup"; + +WarningGroup.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + badge: PropTypes.number.isRequired, +}; + +function WarningGroup(props) { + const { + dispatch, + message, + serviceContainer, + timestampsVisible, + badge, + open, + } = props; + + const { source, type, level, id: messageId, indent, timeStamp } = message; + + const messageBody = [ + message.messageText, + " ", + dom.span( + { + className: "warning-group-badge", + title: PluralForm.get(badge, messageCountTooltip).replace("#1", badge), + }, + badge + ), + ]; + const topLevelClasses = ["cm-s-mozilla"]; + + return Message({ + badge, + collapsible: true, + dispatch, + indent, + level, + messageBody, + messageId, + open, + serviceContainer, + source, + timeStamp, + timestampsVisible, + topLevelClasses, + type, + message, + }); +} + +module.exports = WarningGroup; diff --git a/devtools/client/webconsole/components/Output/message-types/moz.build b/devtools/client/webconsole/components/Output/message-types/moz.build new file mode 100644 index 0000000000..926eb80abe --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/moz.build @@ -0,0 +1,18 @@ +# 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( + "ConsoleApiCall.js", + "ConsoleCommand.js", + "CSSWarning.js", + "DefaultRenderer.js", + "EvaluationResult.js", + "JSTracerTrace.js", + "NavigationMarker.js", + "NetworkEventMessage.js", + "PageError.js", + "SimpleTable.js", + "WarningGroup.js", +) diff --git a/devtools/client/webconsole/components/Output/moz.build b/devtools/client/webconsole/components/Output/moz.build new file mode 100644 index 0000000000..9844c2fdeb --- /dev/null +++ b/devtools/client/webconsole/components/Output/moz.build @@ -0,0 +1,21 @@ +# 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/. + +DIRS += [ + "message-types", +] + +DevToolsModules( + "CollapseButton.js", + "ConsoleOutput.js", + "ConsoleTable.js", + "GripMessageBody.js", + "LazyMessageList.js", + "Message.js", + "MessageContainer.js", + "MessageIcon.js", + "MessageIndent.js", + "MessageRepeat.js", +) diff --git a/devtools/client/webconsole/components/SideBar.js b/devtools/client/webconsole/components/SideBar.js new file mode 100644 index 0000000000..e29d7dbbed --- /dev/null +++ b/devtools/client/webconsole/components/SideBar.js @@ -0,0 +1,128 @@ +/* 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, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const GridElementWidthResizer = createFactory( + require("resource://devtools/client/shared/components/splitter/GridElementWidthResizer.js") +); +loader.lazyRequireGetter( + this, + "dom", + "resource://devtools/client/shared/vendor/react-dom-factories.js" +); +loader.lazyRequireGetter( + this, + "getObjectInspector", + "resource://devtools/client/webconsole/utils/object-inspector.js", + true +); +loader.lazyRequireGetter( + this, + "actions", + "resource://devtools/client/webconsole/actions/index.js" +); +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +loader.lazyRequireGetter( + this, + "reps", + "resource://devtools/client/shared/components/reps/index.js" +); +loader.lazyRequireGetter( + this, + "l10n", + "resource://devtools/client/webconsole/utils/messages.js", + true +); + +class SideBar extends Component { + static get propTypes() { + return { + serviceContainer: PropTypes.object, + dispatch: PropTypes.func.isRequired, + front: PropTypes.object, + onResized: PropTypes.func, + }; + } + + constructor(props) { + super(props); + this.onClickSidebarClose = this.onClickSidebarClose.bind(this); + } + + shouldComponentUpdate(nextProps) { + const { front } = nextProps; + return front !== this.props.front; + } + + onClickSidebarClose() { + this.props.dispatch(actions.sidebarClose()); + } + + render() { + const { front, serviceContainer } = this.props; + + const objectInspector = getObjectInspector(front, serviceContainer, { + autoExpandDepth: 1, + mode: reps.MODE.SHORT, + autoFocusRoot: true, + pathPrefix: "WebConsoleSidebar", + customFormat: false, + }); + + return [ + dom.aside( + { + className: "sidebar", + key: "sidebar", + ref: node => { + this.node = node; + }, + }, + dom.header( + { + className: "devtools-toolbar webconsole-sidebar-toolbar", + }, + dom.button({ + className: "devtools-button sidebar-close-button", + title: l10n.getStr("webconsole.closeSidebarButton.tooltip"), + onClick: this.onClickSidebarClose, + }) + ), + dom.aside( + { + className: "sidebar-contents", + }, + objectInspector + ) + ), + GridElementWidthResizer({ + key: "resizer", + enabled: true, + position: "start", + className: "sidebar-resizer", + getControlledElementNode: () => this.node, + }), + ]; + } +} + +function mapStateToProps(state, props) { + return { + front: state.ui.frontInSidebar, + }; +} + +module.exports = connect(mapStateToProps)(SideBar); diff --git a/devtools/client/webconsole/components/moz.build b/devtools/client/webconsole/components/moz.build new file mode 100644 index 0000000000..0b9eac77a5 --- /dev/null +++ b/devtools/client/webconsole/components/moz.build @@ -0,0 +1,15 @@ +# 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/. + +DIRS += [ + "FilterBar", + "Input", + "Output", +] + +DevToolsModules( + "App.js", + "SideBar.js", +) |