summaryrefslogtreecommitdiffstats
path: root/devtools/client/webconsole/components
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/webconsole/components')
-rw-r--r--devtools/client/webconsole/components/App.css502
-rw-r--r--devtools/client/webconsole/components/App.js508
-rw-r--r--devtools/client/webconsole/components/FilterBar/ConsoleSettings.js194
-rw-r--r--devtools/client/webconsole/components/FilterBar/FilterBar.js441
-rw-r--r--devtools/client/webconsole/components/FilterBar/FilterButton.js37
-rw-r--r--devtools/client/webconsole/components/FilterBar/FilterCheckbox.js31
-rw-r--r--devtools/client/webconsole/components/FilterBar/moz.build11
-rw-r--r--devtools/client/webconsole/components/Input/ConfirmDialog.js197
-rw-r--r--devtools/client/webconsole/components/Input/EagerEvaluation.css122
-rw-r--r--devtools/client/webconsole/components/Input/EagerEvaluation.js147
-rw-r--r--devtools/client/webconsole/components/Input/EditorToolbar.js162
-rw-r--r--devtools/client/webconsole/components/Input/EvaluationContextSelector.css33
-rw-r--r--devtools/client/webconsole/components/Input/EvaluationContextSelector.js290
-rw-r--r--devtools/client/webconsole/components/Input/JSTerm.js1605
-rw-r--r--devtools/client/webconsole/components/Input/ReverseSearchInput.css124
-rw-r--r--devtools/client/webconsole/components/Input/ReverseSearchInput.js285
-rw-r--r--devtools/client/webconsole/components/Input/moz.build13
-rw-r--r--devtools/client/webconsole/components/Output/CollapseButton.js33
-rw-r--r--devtools/client/webconsole/components/Output/ConsoleOutput.js378
-rw-r--r--devtools/client/webconsole/components/Output/ConsoleTable.js272
-rw-r--r--devtools/client/webconsole/components/Output/GripMessageBody.js114
-rw-r--r--devtools/client/webconsole/components/Output/LazyMessageList.js393
-rw-r--r--devtools/client/webconsole/components/Output/Message.js482
-rw-r--r--devtools/client/webconsole/components/Output/MessageContainer.js128
-rw-r--r--devtools/client/webconsole/components/Output/MessageIcon.js71
-rw-r--r--devtools/client/webconsole/components/Output/MessageIndent.js41
-rw-r--r--devtools/client/webconsole/components/Output/MessageRepeat.js35
-rw-r--r--devtools/client/webconsole/components/Output/message-types/CSSWarning.js173
-rw-r--r--devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js221
-rw-r--r--devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js105
-rw-r--r--devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js15
-rw-r--r--devtools/client/webconsole/components/Output/message-types/EvaluationResult.js124
-rw-r--r--devtools/client/webconsole/components/Output/message-types/NavigationMarker.js62
-rw-r--r--devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js243
-rw-r--r--devtools/client/webconsole/components/Output/message-types/PageError.js130
-rw-r--r--devtools/client/webconsole/components/Output/message-types/SimpleTable.js134
-rw-r--r--devtools/client/webconsole/components/Output/message-types/WarningGroup.js80
-rw-r--r--devtools/client/webconsole/components/Output/message-types/moz.build17
-rw-r--r--devtools/client/webconsole/components/Output/moz.build21
-rw-r--r--devtools/client/webconsole/components/SideBar.js128
-rw-r--r--devtools/client/webconsole/components/moz.build15
41 files changed, 8117 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..0f420ec77a
--- /dev/null
+++ b/devtools/client/webconsole/components/App.css
@@ -0,0 +1,502 @@
+/* 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] | ↔ |
+ * | +----------------------+ ↔ |
+ * | ↔ |
+ * +----------------------------↔ |
+ * | [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"
+ "reverse-search sidebar";
+ grid-template-rows: auto var(--primary-toolbar-height) auto 1fr 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 {
+ 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 {
+ 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] ↔ ↔ |
+ * +--------------------------↔ ↔ |
+ * | [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"
+ "reverse-search output sidebar";
+ grid-template-rows:
+ auto
+ auto
+ var(--primary-toolbar-height)
+ auto
+ 1fr
+ 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;
+}
+
+.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-highlight-blue);
+ 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..d09157a65a
--- /dev/null
+++ b/devtools/client/webconsole/components/App.js
@@ -0,0 +1,508 @@
+/* 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")
+);
+
+// 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 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
+ ),
+ 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..ac47159892
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EagerEvaluation.css
@@ -0,0 +1,122 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.eager-evaluation-result {
+ flex: none;
+ font-family: var(--monospace-font-family);
+ font-size: var(--theme-code-font-size);
+ line-height: var(--console-output-line-height);
+ color: var(--theme-text-color-alt);
+}
+
+.theme-light .eager-evaluation-result {
+ --log-icon-color: var(--grey-35);
+ /* Override Reps variables to turn eager eval output gray */
+ --object-color: var(--grey-50);
+ --number-color: var(--grey-50);
+ --string-color: var(--grey-50);
+ --node-color: var(--grey-50);
+ --reference-color: var(--grey-50);
+ --location-color: var(--grey-43);
+ --source-link-color: var(--grey-43);
+ --null-color: var(--grey-43);
+}
+
+.theme-dark .eager-evaluation-result {
+ --log-icon-color: var(--grey-55);
+ /* Override Reps variables to turn eager eval output gray */
+ --object-color: var(--grey-43);
+ --number-color: var(--grey-43);
+ --string-color: var(--grey-43);
+ --node-color: var(--grey-43);
+ --reference-color: var(--grey-43);
+ --location-color: var(--grey-50);
+ --source-link-color: var(--grey-50);
+ --null-color: var(--grey-50);
+}
+
+.eager-evaluation-result__row {
+ direction: ltr;
+ display: flex;
+ align-items: center;
+ overflow-y: hidden;
+ height: var(--console-row-height);
+ padding: 0 2px;
+}
+
+.eager-evaluation-result__icon {
+ flex: none;
+ width: 14px;
+ height: 14px;
+ margin: 0 8px;
+ background: url(chrome://devtools/skin/images/webconsole/return.svg) no-repeat
+ center;
+ background-size: 12px;
+ -moz-context-properties: fill;
+ fill: var(--log-icon-color);
+}
+
+.eager-evaluation-result__text {
+ flex: 1 1 auto;
+ height: 14px;
+ overflow: hidden;
+ /* Use pre rather than nowrap because we want to preserve consecutive spaces,
+ * e.g. if we display "some string" we should not collapse spaces. */
+ white-space: pre;
+}
+
+/* Style the reps result */
+.eager-evaluation-result__text > * {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.eager-evaluation-result__text * {
+ /* Some Reps elements define white-space:pre-wrap, which lets the text break
+ * to a new line */
+ white-space: inherit !important;
+}
+
+.eager-evaluation-result__text .objectBox-function .param {
+ color: var(--null-color);
+}
+
+/* Object property label */
+.eager-evaluation-result__text .nodeName {
+ color: var(--object-color);
+}
+
+/*
+ * Inline mode specifics
+ */
+.webconsole-app:not(.jsterm-editor) .eager-evaluation-result {
+ /* It should fill the remaining height in the output+input area */
+ flex-grow: 1;
+ background-color: var(--console-input-background);
+ /* Reserve a bit of whitespace after the content. */
+ min-height: calc(
+ var(--console-row-height) + var(--console-input-extra-padding)
+ );
+}
+
+/*
+ * Editor mode specifics
+ */
+.webconsole-app.jsterm-editor .eager-evaluation-result {
+ border-top: 1px solid var(--theme-splitter-color);
+ border-inline-end: 1px solid var(--theme-splitter-color);
+ /* Make text smaller when displayed in the sidebar */
+ font-size: 10px;
+ line-height: 14px;
+ background-color: var(--theme-sidebar-background);
+}
+
+.webconsole-app.jsterm-editor .eager-evaluation-result:empty {
+ display: none;
+}
+
+.webconsole-app.jsterm-editor .eager-evaluation-result__row {
+ height: var(--theme-toolbar-height);
+}
diff --git a/devtools/client/webconsole/components/Input/EagerEvaluation.js b/devtools/client/webconsole/components/Input/EagerEvaluation.js
new file mode 100644
index 0000000000..fddc0c2aa4
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EagerEvaluation.js
@@ -0,0 +1,147 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const {
+ getTerminalEagerResult,
+} = require("resource://devtools/client/webconsole/selectors/history.js");
+
+const actions = require("resource://devtools/client/webconsole/actions/index.js");
+
+loader.lazyGetter(this, "REPS", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .REPS;
+});
+loader.lazyGetter(this, "MODE", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .MODE;
+});
+loader.lazyRequireGetter(
+ this,
+ "PropTypes",
+ "resource://devtools/client/shared/vendor/react-prop-types.js"
+);
+
+/**
+ * Show the results of evaluating the current terminal text, if possible.
+ */
+class EagerEvaluation extends Component {
+ static get propTypes() {
+ return {
+ terminalEagerResult: PropTypes.any,
+ serviceContainer: PropTypes.object.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ };
+ }
+
+ static getDerivedStateFromError(error) {
+ return { hasError: true };
+ }
+
+ componentDidUpdate(prevProps) {
+ const { highlightDomElement, unHighlightDomElement, terminalEagerResult } =
+ this.props;
+
+ if (canHighlightObject(prevProps.terminalEagerResult)) {
+ unHighlightDomElement(prevProps.terminalEagerResult.getGrip());
+ }
+
+ if (canHighlightObject(terminalEagerResult)) {
+ highlightDomElement(terminalEagerResult.getGrip());
+ }
+
+ if (this.state?.hasError) {
+ // If the render function threw at some point, clear the error after 1s so the
+ // component has a chance to render again.
+ // This way, we don't block instant evaluation for the whole session, in case the
+ // input changed in the meantime. If the input didn't change, we'll hit
+ // getDerivatedStateFromError again (and this won't render anything), so it's safe.
+ setTimeout(() => {
+ this.setState({ hasError: false });
+ }, 1000);
+ }
+ }
+
+ componentWillUnmount() {
+ const { unHighlightDomElement, terminalEagerResult } = this.props;
+
+ if (canHighlightObject(terminalEagerResult)) {
+ unHighlightDomElement(terminalEagerResult.getGrip());
+ }
+ }
+
+ renderRepsResult() {
+ const { terminalEagerResult } = this.props;
+
+ const result = terminalEagerResult.getGrip
+ ? terminalEagerResult.getGrip()
+ : terminalEagerResult;
+ const { isError } = result || {};
+
+ return REPS.Rep({
+ key: "rep",
+ object: result,
+ mode: isError ? MODE.SHORT : MODE.LONG,
+ });
+ }
+
+ render() {
+ const hasResult =
+ this.props.terminalEagerResult !== null &&
+ this.props.terminalEagerResult !== undefined &&
+ !this.state?.hasError;
+
+ return dom.div(
+ { className: "eager-evaluation-result", key: "eager-evaluation-result" },
+ hasResult
+ ? dom.span(
+ { className: "eager-evaluation-result__row" },
+ dom.span({
+ className: "eager-evaluation-result__icon",
+ key: "icon",
+ }),
+ dom.span(
+ { className: "eager-evaluation-result__text", key: "text" },
+ this.renderRepsResult()
+ )
+ )
+ : null
+ );
+ }
+}
+
+function canHighlightObject(obj) {
+ const grip = obj?.getGrip && obj.getGrip();
+ return (
+ grip &&
+ (REPS.ElementNode.supportsObject(grip) ||
+ REPS.TextNode.supportsObject(grip)) &&
+ grip.preview.isConnected
+ );
+}
+
+function mapStateToProps(state) {
+ return {
+ terminalEagerResult: getTerminalEagerResult(state),
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ highlightDomElement: grip => dispatch(actions.highlightDomElement(grip)),
+ unHighlightDomElement: grip =>
+ dispatch(actions.unHighlightDomElement(grip)),
+ };
+}
+module.exports = connect(mapStateToProps, mapDispatchToProps)(EagerEvaluation);
diff --git a/devtools/client/webconsole/components/Input/EditorToolbar.js b/devtools/client/webconsole/components/Input/EditorToolbar.js
new file mode 100644
index 0000000000..8fe82421eb
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EditorToolbar.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// React & Redux
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const EvaluationContextSelector = createFactory(
+ require("resource://devtools/client/webconsole/components/Input/EvaluationContextSelector.js")
+);
+
+const actions = require("resource://devtools/client/webconsole/actions/index.js");
+const {
+ l10n,
+} = require("resource://devtools/client/webconsole/utils/messages.js");
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+// Constants used for defining the direction of JSTerm input history navigation.
+const {
+ HISTORY_BACK,
+ HISTORY_FORWARD,
+} = require("resource://devtools/client/webconsole/constants.js");
+
+class EditorToolbar extends Component {
+ static get propTypes() {
+ return {
+ editorMode: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
+ reverseSearchInputVisible: PropTypes.bool.isRequired,
+ serviceContainer: PropTypes.object.isRequired,
+ webConsoleUI: PropTypes.object.isRequired,
+ showEvaluationContextSelector: PropTypes.bool,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onReverseSearchButtonClick =
+ this.onReverseSearchButtonClick.bind(this);
+ }
+
+ onReverseSearchButtonClick(event) {
+ const { dispatch, serviceContainer } = this.props;
+
+ event.stopPropagation();
+ dispatch(
+ actions.reverseSearchInputToggle({
+ initialValue: serviceContainer.getInputSelection(),
+ access: "editor-toolbar-icon",
+ })
+ );
+ }
+
+ renderEvaluationContextSelector() {
+ if (!this.props.showEvaluationContextSelector) {
+ return null;
+ }
+
+ return EvaluationContextSelector({
+ webConsoleUI: this.props.webConsoleUI,
+ });
+ }
+
+ render() {
+ const { editorMode, dispatch, reverseSearchInputVisible, webConsoleUI } =
+ this.props;
+
+ if (!editorMode) {
+ return null;
+ }
+
+ const enterStr = l10n.getStr("webconsole.enterKey");
+
+ return dom.div(
+ {
+ className:
+ "devtools-toolbar devtools-input-toolbar webconsole-editor-toolbar",
+ },
+ dom.button(
+ {
+ className: "devtools-button webconsole-editor-toolbar-executeButton",
+ title: l10n.getFormatStr(
+ "webconsole.editor.toolbar.executeButton.tooltip",
+ [isMacOS ? `Cmd + ${enterStr}` : `Ctrl + ${enterStr}`]
+ ),
+ onClick: () => dispatch(actions.evaluateExpression()),
+ },
+ l10n.getStr("webconsole.editor.toolbar.executeButton.label")
+ ),
+ this.renderEvaluationContextSelector(),
+ dom.button({
+ className:
+ "devtools-button webconsole-editor-toolbar-prettyPrintButton",
+ title: l10n.getStr(
+ "webconsole.editor.toolbar.prettyPrintButton.tooltip"
+ ),
+ onClick: () => dispatch(actions.prettyPrintEditor()),
+ }),
+ dom.div({
+ className:
+ "devtools-separator webconsole-editor-toolbar-prettyPrintSeparator",
+ }),
+ dom.button({
+ className:
+ "devtools-button webconsole-editor-toolbar-history-prevExpressionButton",
+ title: l10n.getStr(
+ "webconsole.editor.toolbar.history.prevExpressionButton.tooltip"
+ ),
+ onClick: () => {
+ webConsoleUI.jsterm.historyPeruse(HISTORY_BACK);
+ },
+ }),
+ dom.button({
+ className:
+ "devtools-button webconsole-editor-toolbar-history-nextExpressionButton",
+ title: l10n.getStr(
+ "webconsole.editor.toolbar.history.nextExpressionButton.tooltip"
+ ),
+ onClick: () => {
+ webConsoleUI.jsterm.historyPeruse(HISTORY_FORWARD);
+ },
+ }),
+ dom.button({
+ className: `devtools-button webconsole-editor-toolbar-reverseSearchButton ${
+ reverseSearchInputVisible ? "checked" : ""
+ }`,
+ title: reverseSearchInputVisible
+ ? l10n.getFormatStr(
+ "webconsole.editor.toolbar.reverseSearchButton.closeReverseSearch.tooltip",
+ ["Esc" + (isMacOS ? " | Ctrl + C" : "")]
+ )
+ : l10n.getFormatStr(
+ "webconsole.editor.toolbar.reverseSearchButton.openReverseSearch.tooltip",
+ [isMacOS ? "Ctrl + R" : "F9"]
+ ),
+ onClick: this.onReverseSearchButtonClick,
+ }),
+ dom.div({
+ className:
+ "devtools-separator webconsole-editor-toolbar-historyNavSeparator",
+ }),
+ dom.button({
+ className: "devtools-button webconsole-editor-toolbar-closeButton",
+ title: l10n.getFormatStr(
+ "webconsole.editor.toolbar.closeButton.tooltip2",
+ [isMacOS ? "Cmd + B" : "Ctrl + B"]
+ ),
+ onClick: () => dispatch(actions.editorToggle()),
+ })
+ );
+ }
+}
+
+module.exports = EditorToolbar;
diff --git a/devtools/client/webconsole/components/Input/EvaluationContextSelector.css b/devtools/client/webconsole/components/Input/EvaluationContextSelector.css
new file mode 100644
index 0000000000..27b244feae
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EvaluationContextSelector.css
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.webconsole-evaluation-selector-button {
+ padding: 1px 16px 1px 8px !important;
+ margin-top: 2px;
+ background-position-x: right 4px !important;
+ max-width: 150px;
+}
+
+/* This overrides the .devtools-dropdown-button:dir(rtl) rule from toolbars.css */
+html[dir="rtl"] .webconsole-evaluation-selector-button {
+ background-position-x: right 4px !important;
+}
+
+.jsterm-editor .webconsole-editor-toolbar .webconsole-evaluation-selector-button {
+ height: 20px;
+ margin-inline-start: 5px;
+ margin-top: 1px;
+}
+
+.webconsole-evaluation-selector-button.checked.devtools-dropdown-button {
+ background-color: var(--blue-60);
+ color: white;
+ fill: currentColor;
+}
+
+.webconsole-evaluation-selector-button.checked.devtools-dropdown-button:hover,
+.webconsole-evaluation-selector-button.checked.devtools-dropdown-button[aria-expanded="true"] {
+ background-color: var(--blue-70) !important;
+ color: white !important;
+}
diff --git a/devtools/client/webconsole/components/Input/EvaluationContextSelector.js b/devtools/client/webconsole/components/Input/EvaluationContextSelector.js
new file mode 100644
index 0000000000..3842c0e7db
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/EvaluationContextSelector.js
@@ -0,0 +1,290 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// React & Redux
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const targetActions = require("resource://devtools/shared/commands/target/actions/targets.js");
+const webconsoleActions = require("resource://devtools/client/webconsole/actions/index.js");
+
+const {
+ l10n,
+} = require("resource://devtools/client/webconsole/utils/messages.js");
+const targetSelectors = require("resource://devtools/shared/commands/target/selectors/targets.js");
+
+loader.lazyGetter(this, "TARGET_TYPES", function () {
+ return require("resource://devtools/shared/commands/target/target-command.js")
+ .TYPES;
+});
+
+// Additional Components
+const MenuButton = createFactory(
+ require("resource://devtools/client/shared/components/menu/MenuButton.js")
+);
+
+loader.lazyGetter(this, "MenuItem", function () {
+ return createFactory(
+ require("resource://devtools/client/shared/components/menu/MenuItem.js")
+ );
+});
+
+loader.lazyGetter(this, "MenuList", function () {
+ return createFactory(
+ require("resource://devtools/client/shared/components/menu/MenuList.js")
+ );
+});
+
+class EvaluationContextSelector extends Component {
+ static get propTypes() {
+ return {
+ selectTarget: PropTypes.func.isRequired,
+ onContextChange: PropTypes.func.isRequired,
+ selectedTarget: PropTypes.object,
+ lastTargetRefresh: PropTypes.number,
+ targets: PropTypes.array,
+ webConsoleUI: PropTypes.object.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps) {
+ if (this.props.selectedTarget !== nextProps.selectedTarget) {
+ return true;
+ }
+
+ if (this.props.lastTargetRefresh !== nextProps.lastTargetRefresh) {
+ return true;
+ }
+
+ if (this.props.targets.length !== nextProps.targets.length) {
+ return true;
+ }
+
+ for (let i = 0; i < nextProps.targets.length; i++) {
+ const target = this.props.targets[i];
+ const nextTarget = nextProps.targets[i];
+ if (target.url != nextTarget.url || target.name != nextTarget.name) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.selectedTarget !== prevProps.selectedTarget) {
+ this.props.onContextChange();
+ }
+ }
+
+ getIcon(target) {
+ if (target.targetType === TARGET_TYPES.FRAME) {
+ return "chrome://devtools/content/debugger/images/globe-small.svg";
+ }
+
+ if (
+ target.targetType === TARGET_TYPES.WORKER ||
+ target.targetType === TARGET_TYPES.SHARED_WORKER ||
+ target.targetType === TARGET_TYPES.SERVICE_WORKER
+ ) {
+ return "chrome://devtools/content/debugger/images/worker.svg";
+ }
+
+ if (target.targetType === TARGET_TYPES.PROCESS) {
+ return "chrome://devtools/content/debugger/images/window.svg";
+ }
+
+ return null;
+ }
+
+ renderMenuItem(target) {
+ const { selectTarget, selectedTarget } = this.props;
+
+ const label = target.isTopLevel
+ ? l10n.getStr("webconsole.input.selector.top")
+ : target.name;
+
+ return MenuItem({
+ key: `webconsole-evaluation-selector-item-${target.actorID}`,
+ className: "menu-item webconsole-evaluation-selector-item",
+ type: "checkbox",
+ checked: selectedTarget ? selectedTarget == target : target.isTopLevel,
+ label,
+ tooltip: target.url || target.name,
+ icon: this.getIcon(target),
+ onClick: () => selectTarget(target.actorID),
+ });
+ }
+
+ renderMenuItems() {
+ const { targets } = this.props;
+
+ // Let's sort the targets (using "numeric" so Content processes are ordered by PID).
+ const collator = new Intl.Collator("en", { numeric: true });
+ targets.sort((a, b) => collator.compare(a.name, b.name));
+
+ let mainTarget;
+ const sections = {
+ [TARGET_TYPES.FRAME]: [],
+ [TARGET_TYPES.WORKER]: [],
+ [TARGET_TYPES.SHARED_WORKER]: [],
+ [TARGET_TYPES.SERVICE_WORKER]: [],
+ };
+ // When in Browser Toolbox, we want to display the process targets with the frames
+ // in the same process as a group
+ // e.g.
+ // |------------------------------|
+ // | Top |
+ // | -----------------------------|
+ // | (pid 1234) priviledgedabout |
+ // | New Tab |
+ // | -----------------------------|
+ // | (pid 5678) web |
+ // | cnn.com |
+ // | -----------------------------|
+ // | RemoteSettingWorker.js |
+ // |------------------------------|
+ //
+ // This object will be keyed by PID, and each property will be an object with a
+ // `process` property (for the process target item), and a `frames` property (and array
+ // for all the frame target items).
+ const processes = {};
+
+ const { webConsoleUI } = this.props;
+ const handleProcessTargets =
+ webConsoleUI.isBrowserConsole || webConsoleUI.isBrowserToolboxConsole;
+
+ for (const target of targets) {
+ const menuItem = this.renderMenuItem(target);
+
+ if (target.isTopLevel) {
+ mainTarget = menuItem;
+ } else if (target.targetType == TARGET_TYPES.PROCESS) {
+ if (!processes[target.processID]) {
+ processes[target.processID] = { frames: [] };
+ }
+ processes[target.processID].process = menuItem;
+ } else if (
+ target.targetType == TARGET_TYPES.FRAME &&
+ handleProcessTargets &&
+ target.processID
+ ) {
+ // The associated process target might not have been handled yet, so make sure
+ // to create it.
+ if (!processes[target.processID]) {
+ processes[target.processID] = { frames: [] };
+ }
+ processes[target.processID].frames.push(menuItem);
+ } else {
+ sections[target.targetType].push(menuItem);
+ }
+ }
+
+ // Note that while debugging popups, we might have a small period
+ // of time where we don't have any top level target when we reload
+ // the original tab
+ const items = mainTarget ? [mainTarget] : [];
+
+ // Handle PROCESS targets sections first, as we want to display the associated frames
+ // below the process to group them.
+ if (processes) {
+ for (const [pid, { process, frames }] of Object.entries(processes)) {
+ items.push(dom.hr({ role: "menuseparator", key: `${pid}-separator` }));
+ if (process) {
+ items.push(process);
+ }
+ if (frames) {
+ items.push(...frames);
+ }
+ }
+ }
+
+ for (const [targetType, menuItems] of Object.entries(sections)) {
+ if (menuItems.length) {
+ items.push(
+ dom.hr({ role: "menuseparator", key: `${targetType}-separator` }),
+ ...menuItems
+ );
+ }
+ }
+
+ return MenuList(
+ { id: "webconsole-console-evaluation-context-selector-menu-list" },
+ items
+ );
+ }
+
+ getLabel() {
+ const { selectedTarget } = this.props;
+
+ if (!selectedTarget || selectedTarget.isTopLevel) {
+ return l10n.getStr("webconsole.input.selector.top");
+ }
+
+ return selectedTarget.name;
+ }
+
+ render() {
+ const { webConsoleUI, targets, selectedTarget } = this.props;
+
+ // Don't render if there's only one target.
+ // Also bail out if the console is being destroyed (where WebConsoleUI.wrapper gets
+ // nullified).
+ if (targets.length <= 1 || !webConsoleUI.wrapper) {
+ return null;
+ }
+
+ const doc = webConsoleUI.document;
+ const { toolbox } = webConsoleUI.wrapper;
+
+ return MenuButton(
+ {
+ menuId: "webconsole-input-evaluationsButton",
+ toolboxDoc: toolbox ? toolbox.doc : doc,
+ label: this.getLabel(),
+ className:
+ "webconsole-evaluation-selector-button devtools-button devtools-dropdown-button" +
+ (selectedTarget && !selectedTarget.isTopLevel ? " checked" : ""),
+ title: l10n.getStr("webconsole.input.selector.tooltip"),
+ },
+ // We pass the children in a function so we don't require the MenuItem and MenuList
+ // components until we need to display them (i.e. when the button is clicked).
+ () => this.renderMenuItems()
+ );
+ }
+}
+
+const toolboxConnected = connect(
+ state => ({
+ targets: targetSelectors.getToolboxTargets(state),
+ selectedTarget: targetSelectors.getSelectedTarget(state),
+ lastTargetRefresh: targetSelectors.getLastTargetRefresh(state),
+ }),
+ dispatch => ({
+ selectTarget: actorID => dispatch(targetActions.selectTarget(actorID)),
+ }),
+ undefined,
+ { storeKey: "target-store" }
+)(EvaluationContextSelector);
+
+module.exports = connect(
+ state => state,
+ dispatch => ({
+ onContextChange: () => {
+ dispatch(
+ webconsoleActions.updateInstantEvaluationResultForCurrentExpression()
+ );
+ dispatch(webconsoleActions.autocompleteClear());
+ },
+ })
+)(toolboxConnected);
diff --git a/devtools/client/webconsole/components/Input/JSTerm.js b/devtools/client/webconsole/components/Input/JSTerm.js
new file mode 100644
index 0000000000..f00ddd66b0
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/JSTerm.js
@@ -0,0 +1,1605 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { debounce } = require("resource://devtools/shared/debounce.js");
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+loader.lazyRequireGetter(this, "Debugger", "Debugger");
+loader.lazyRequireGetter(
+ this,
+ "EventEmitter",
+ "resource://devtools/shared/event-emitter.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "AutocompletePopup",
+ "resource://devtools/client/shared/autocomplete-popup.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ "PropTypes",
+ "resource://devtools/client/shared/vendor/react-prop-types.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "KeyCodes",
+ "resource://devtools/client/shared/keycodes.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "Editor",
+ "resource://devtools/client/shared/sourceeditor/editor.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "getFocusableElements",
+ "resource://devtools/client/shared/focus.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "l10n",
+ "resource://devtools/client/webconsole/utils/messages.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "saveAs",
+ "resource://devtools/shared/DevToolsUtils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "beautify",
+ "resource://devtools/shared/jsbeautify/beautify.js"
+);
+
+// React & Redux
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+// History Modules
+const {
+ getHistory,
+ getHistoryValue,
+} = require("resource://devtools/client/webconsole/selectors/history.js");
+const {
+ getAutocompleteState,
+} = require("resource://devtools/client/webconsole/selectors/autocomplete.js");
+const actions = require("resource://devtools/client/webconsole/actions/index.js");
+
+const EvaluationContextSelector = createFactory(
+ require("resource://devtools/client/webconsole/components/Input/EvaluationContextSelector.js")
+);
+
+// Constants used for defining the direction of JSTerm input history navigation.
+const {
+ HISTORY_BACK,
+ HISTORY_FORWARD,
+} = require("resource://devtools/client/webconsole/constants.js");
+
+const JSTERM_CODEMIRROR_ORIGIN = "jsterm";
+
+/**
+ * Create a JSTerminal (a JavaScript command line). This is attached to an
+ * existing HeadsUpDisplay (a Web Console instance). This code is responsible
+ * with handling command line input and code evaluation.
+ */
+class JSTerm extends Component {
+ static get propTypes() {
+ return {
+ // Returns previous or next value from the history
+ // (depending on direction argument).
+ getValueFromHistory: PropTypes.func.isRequired,
+ // History of executed expression (state).
+ history: PropTypes.object.isRequired,
+ // Console object.
+ webConsoleUI: PropTypes.object.isRequired,
+ // Needed for opening context menu
+ serviceContainer: PropTypes.object.isRequired,
+ // Handler for clipboard 'paste' event (also used for 'drop' event, callback).
+ onPaste: PropTypes.func,
+ // Evaluate provided expression.
+ evaluateExpression: PropTypes.func.isRequired,
+ // Update position in the history after executing an expression (action).
+ updateHistoryPosition: PropTypes.func.isRequired,
+ // Update autocomplete popup state.
+ autocompleteUpdate: PropTypes.func.isRequired,
+ autocompleteClear: PropTypes.func.isRequired,
+ // Data to be displayed in the autocomplete popup.
+ autocompleteData: PropTypes.object.isRequired,
+ // Toggle the editor mode.
+ editorToggle: PropTypes.func.isRequired,
+ // Dismiss the editor onboarding UI.
+ editorOnboardingDismiss: PropTypes.func.isRequired,
+ // Set the last JS input value.
+ terminalInputChanged: PropTypes.func.isRequired,
+ // Is the input in editor mode.
+ editorMode: PropTypes.bool,
+ editorWidth: PropTypes.number,
+ editorPrettifiedAt: PropTypes.number,
+ showEditorOnboarding: PropTypes.bool,
+ autocomplete: PropTypes.bool,
+ showEvaluationContextSelector: PropTypes.bool,
+ autocompletePopupPosition: PropTypes.string,
+ inputEnabled: PropTypes.bool,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ const { webConsoleUI } = props;
+
+ this.webConsoleUI = webConsoleUI;
+ this.hudId = this.webConsoleUI.hudId;
+
+ this._onEditorChanges = this._onEditorChanges.bind(this);
+ this._onEditorBeforeChange = this._onEditorBeforeChange.bind(this);
+ this._onEditorKeyHandled = this._onEditorKeyHandled.bind(this);
+ this.onContextMenu = this.onContextMenu.bind(this);
+ this.imperativeUpdate = this.imperativeUpdate.bind(this);
+
+ // We debounce the autocompleteUpdate so we don't send too many requests to the server
+ // as the user is typing.
+ // The delay should be small enough to be unnoticed by the user.
+ this.autocompleteUpdate = debounce(this.props.autocompleteUpdate, 75, this);
+
+ // Updates to the terminal input which can trigger eager evaluations are
+ // similarly debounced.
+ this.terminalInputChanged = debounce(
+ this.props.terminalInputChanged,
+ 75,
+ this
+ );
+
+ // Because the autocomplete has a slight delay (75ms), there can be time where the
+ // codeMirror completion text is out-of-date, which might lead to issue when the user
+ // accept the autocompletion while the update of the completion text is still pending.
+ // In order to account for that, we put any future value of the completion text in
+ // this property.
+ this.pendingCompletionText = null;
+
+ /**
+ * Last input value.
+ * @type string
+ */
+ this.lastInputValue = "";
+
+ this.autocompletePopup = null;
+
+ EventEmitter.decorate(this);
+ webConsoleUI.jsterm = this;
+ }
+
+ componentDidMount() {
+ if (this.props.editorMode) {
+ this.setEditorWidth(this.props.editorWidth);
+ }
+
+ const autocompleteOptions = {
+ onSelect: this.onAutocompleteSelect.bind(this),
+ onClick: this.acceptProposedCompletion.bind(this),
+ listId: "webConsole_autocompletePopupListBox",
+ position: this.props.autocompletePopupPosition,
+ autoSelect: true,
+ useXulWrapper: true,
+ };
+
+ const doc = this.webConsoleUI.document;
+ const { toolbox } = this.webConsoleUI.wrapper;
+ const tooltipDoc = toolbox ? toolbox.doc : doc;
+ // The popup will be attached to the toolbox document or HUD document in the case
+ // such as the browser console which doesn't have a toolbox.
+ this.autocompletePopup = new AutocompletePopup(
+ tooltipDoc,
+ autocompleteOptions
+ );
+
+ if (this.node) {
+ const onArrowUp = () => {
+ let inputUpdated;
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.selectPreviousItem();
+ return null;
+ }
+
+ if (this.props.editorMode === false && this.canCaretGoPrevious()) {
+ inputUpdated = this.historyPeruse(HISTORY_BACK);
+ }
+
+ return inputUpdated ? null : "CodeMirror.Pass";
+ };
+
+ const onArrowDown = () => {
+ let inputUpdated;
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.selectNextItem();
+ return null;
+ }
+
+ if (this.props.editorMode === false && this.canCaretGoNext()) {
+ inputUpdated = this.historyPeruse(HISTORY_FORWARD);
+ }
+
+ return inputUpdated ? null : "CodeMirror.Pass";
+ };
+
+ const onArrowLeft = () => {
+ if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) {
+ this.clearCompletion();
+ }
+ return "CodeMirror.Pass";
+ };
+
+ const onArrowRight = () => {
+ // We only want to complete on Right arrow if the completion text is
+ // displayed.
+ if (this.getAutoCompletionText()) {
+ this.acceptProposedCompletion();
+ return null;
+ }
+
+ this.clearCompletion();
+ return "CodeMirror.Pass";
+ };
+
+ const onCtrlCmdEnter = () => {
+ if (this.hasAutocompletionSuggestion()) {
+ return this.acceptProposedCompletion();
+ }
+
+ this._execute();
+ return null;
+ };
+
+ this.editor = new Editor({
+ autofocus: true,
+ enableCodeFolding: this.props.editorMode,
+ lineNumbers: this.props.editorMode,
+ lineWrapping: true,
+ mode: {
+ name: "javascript",
+ globalVars: true,
+ },
+ styleActiveLine: false,
+ tabIndex: "0",
+ viewportMargin: Infinity,
+ disableSearchAddon: true,
+ extraKeys: {
+ Enter: () => {
+ // No need to handle shift + Enter as it's natively handled by CodeMirror.
+
+ const hasSuggestion = this.hasAutocompletionSuggestion();
+ if (
+ !hasSuggestion &&
+ !Debugger.isCompilableUnit(this._getValue())
+ ) {
+ // incomplete statement
+ return "CodeMirror.Pass";
+ }
+
+ if (hasSuggestion) {
+ return this.acceptProposedCompletion();
+ }
+
+ if (!this.props.editorMode) {
+ this._execute();
+ return null;
+ }
+ return "CodeMirror.Pass";
+ },
+
+ "Cmd-Enter": onCtrlCmdEnter,
+ "Ctrl-Enter": onCtrlCmdEnter,
+
+ [Editor.accel("S")]: () => {
+ const value = this._getValue();
+ if (!value) {
+ return null;
+ }
+
+ const date = new Date();
+ const suggestedName =
+ `console-input-${date.getFullYear()}-` +
+ `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` +
+ `${date.getMinutes()}-${date.getSeconds()}.js`;
+ const data = new TextEncoder().encode(value);
+ return saveAs(window, data, suggestedName, [
+ {
+ pattern: "*.js",
+ label: l10n.getStr("webconsole.input.openJavaScriptFileFilter"),
+ },
+ ]);
+ },
+
+ [Editor.accel("O")]: async () => this._openFile(),
+
+ Tab: () => {
+ if (this.hasEmptyInput()) {
+ this.editor.codeMirror.getInputField().blur();
+ return false;
+ }
+
+ if (
+ this.props.autocompleteData &&
+ this.props.autocompleteData.getterPath
+ ) {
+ this.props.autocompleteUpdate(
+ true,
+ this.props.autocompleteData.getterPath
+ );
+ return false;
+ }
+
+ const isSomethingSelected = this.editor.somethingSelected();
+ const hasSuggestion = this.hasAutocompletionSuggestion();
+
+ if (hasSuggestion && !isSomethingSelected) {
+ this.acceptProposedCompletion();
+ return false;
+ }
+
+ if (!isSomethingSelected) {
+ this.insertStringAtCursor("\t");
+ return false;
+ }
+
+ // Something is selected, let the editor handle the indent.
+ return true;
+ },
+
+ "Shift-Tab": () => {
+ if (this.hasEmptyInput()) {
+ this.focusPreviousElement();
+ return false;
+ }
+
+ const hasSuggestion = this.hasAutocompletionSuggestion();
+
+ if (hasSuggestion) {
+ return false;
+ }
+
+ return "CodeMirror.Pass";
+ },
+
+ Up: onArrowUp,
+ "Cmd-Up": onArrowUp,
+
+ Down: onArrowDown,
+ "Cmd-Down": onArrowDown,
+
+ Left: onArrowLeft,
+ "Ctrl-Left": onArrowLeft,
+ "Cmd-Left": onArrowLeft,
+ "Alt-Left": onArrowLeft,
+ // On OSX, Ctrl-A navigates to the beginning of the line.
+ "Ctrl-A": isMacOS ? onArrowLeft : undefined,
+
+ Right: onArrowRight,
+ "Ctrl-Right": onArrowRight,
+ "Cmd-Right": onArrowRight,
+ "Alt-Right": onArrowRight,
+
+ "Ctrl-N": () => {
+ // Control-N differs from down arrow: it ignores autocomplete state.
+ // Note that we preserve the default 'down' navigation within
+ // multiline text.
+ if (
+ Services.appinfo.OS === "Darwin" &&
+ this.props.editorMode === false &&
+ this.canCaretGoNext() &&
+ this.historyPeruse(HISTORY_FORWARD)
+ ) {
+ return null;
+ }
+
+ this.clearCompletion();
+ return "CodeMirror.Pass";
+ },
+
+ "Ctrl-P": () => {
+ // Control-P differs from up arrow: it ignores autocomplete state.
+ // Note that we preserve the default 'up' navigation within
+ // multiline text.
+ if (
+ Services.appinfo.OS === "Darwin" &&
+ this.props.editorMode === false &&
+ this.canCaretGoPrevious() &&
+ this.historyPeruse(HISTORY_BACK)
+ ) {
+ return null;
+ }
+
+ this.clearCompletion();
+ return "CodeMirror.Pass";
+ },
+
+ PageUp: () => {
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.selectPreviousPageItem();
+ } else {
+ const { outputScroller } = this.webConsoleUI;
+ const { scrollTop, clientHeight } = outputScroller;
+ outputScroller.scrollTop = Math.max(0, scrollTop - clientHeight);
+ }
+
+ return null;
+ },
+
+ PageDown: () => {
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.selectNextPageItem();
+ } else {
+ const { outputScroller } = this.webConsoleUI;
+ const { scrollTop, scrollHeight, clientHeight } = outputScroller;
+ outputScroller.scrollTop = Math.min(
+ scrollHeight,
+ scrollTop + clientHeight
+ );
+ }
+
+ return null;
+ },
+
+ Home: () => {
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.selectItemAtIndex(0);
+ return null;
+ }
+
+ if (!this._getValue()) {
+ this.webConsoleUI.outputScroller.scrollTop = 0;
+ return null;
+ }
+
+ if (this.getAutoCompletionText()) {
+ this.clearCompletion();
+ }
+
+ return "CodeMirror.Pass";
+ },
+
+ End: () => {
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.selectItemAtIndex(
+ this.autocompletePopup.itemCount - 1
+ );
+ return null;
+ }
+
+ if (!this._getValue()) {
+ const { outputScroller } = this.webConsoleUI;
+ outputScroller.scrollTop = outputScroller.scrollHeight;
+ return null;
+ }
+
+ if (this.getAutoCompletionText()) {
+ this.clearCompletion();
+ }
+
+ return "CodeMirror.Pass";
+ },
+
+ "Ctrl-Space": () => {
+ if (!this.autocompletePopup.isOpen) {
+ this.props.autocompleteUpdate(
+ true,
+ null,
+ this._getExpressionVariables()
+ );
+ return null;
+ }
+
+ return "CodeMirror.Pass";
+ },
+
+ Esc: false,
+ // Don't handle Ctrl/Cmd + F so it can be listened by a parent node
+ [Editor.accel("F")]: false,
+ },
+ });
+
+ this.editor.on("changes", this._onEditorChanges);
+ this.editor.on("beforeChange", this._onEditorBeforeChange);
+ this.editor.on("blur", this._onEditorBlur);
+ this.editor.on("keyHandled", this._onEditorKeyHandled);
+
+ this.editor.appendToLocalElement(this.node);
+ const cm = this.editor.codeMirror;
+ cm.on("paste", (_, event) => this.props.onPaste(event));
+ cm.on("drop", (_, event) => this.props.onPaste(event));
+
+ this.node.addEventListener("keydown", event => {
+ if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) {
+ if (this.autocompletePopup.isOpen) {
+ this.clearCompletion();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (
+ this.props.autocompleteData &&
+ this.props.autocompleteData.getterPath
+ ) {
+ this.props.autocompleteClear();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ });
+
+ this.resizeObserver = new ResizeObserver(() => {
+ // If we don't have the node reference, or if the node isn't connected
+ // anymore, we disconnect the resize observer (componentWillUnmount is never
+ // called on this component, so we have to do it here).
+ if (!this.node || !this.node.isConnected) {
+ this.resizeObserver.disconnect();
+ return;
+ }
+ // Calling `refresh` will update the cursor position, and all the selection blocks.
+ this.editor.codeMirror.refresh();
+ });
+ this.resizeObserver.observe(this.node);
+
+ // Update the character width needed for the popup offset calculations.
+ this._inputCharWidth = this._getInputCharWidth();
+ this.lastInputValue && this._setValue(this.lastInputValue);
+ }
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ this.imperativeUpdate(nextProps);
+ }
+
+ shouldComponentUpdate(nextProps) {
+ return (
+ this.props.showEditorOnboarding !== nextProps.showEditorOnboarding ||
+ this.props.editorMode !== nextProps.editorMode
+ );
+ }
+
+ /**
+ * Do all the imperative work needed after a Redux store update.
+ *
+ * @param {Object} nextProps: props passed from shouldComponentUpdate.
+ */
+ imperativeUpdate(nextProps) {
+ if (!nextProps) {
+ return;
+ }
+
+ if (
+ nextProps.autocompleteData !== this.props.autocompleteData &&
+ nextProps.autocompleteData.pendingRequestId === null
+ ) {
+ this.updateAutocompletionPopup(nextProps.autocompleteData);
+ }
+
+ if (nextProps.editorMode !== this.props.editorMode) {
+ if (this.editor) {
+ this.editor.setOption("lineNumbers", nextProps.editorMode);
+ this.editor.setOption("enableCodeFolding", nextProps.editorMode);
+ }
+
+ if (nextProps.editorMode && nextProps.editorWidth) {
+ this.setEditorWidth(nextProps.editorWidth);
+ } else {
+ this.setEditorWidth(null);
+ }
+
+ if (this.autocompletePopup.isOpen) {
+ this.autocompletePopup.hidePopup();
+ }
+ }
+
+ if (
+ nextProps.autocompletePopupPosition !==
+ this.props.autocompletePopupPosition &&
+ this.autocompletePopup
+ ) {
+ this.autocompletePopup.position = nextProps.autocompletePopupPosition;
+ }
+
+ if (
+ nextProps.editorPrettifiedAt &&
+ nextProps.editorPrettifiedAt !== this.props.editorPrettifiedAt
+ ) {
+ this._setValue(
+ beautify.js(this._getValue(), {
+ // Read directly from prefs because this.editor.config.indentUnit and
+ // this.editor.getOption('indentUnit') are not really synced with
+ // prefs.
+ indent_size: Services.prefs.getIntPref("devtools.editor.tabsize"),
+ indent_with_tabs: !Services.prefs.getBoolPref(
+ "devtools.editor.expandtab"
+ ),
+ })
+ );
+ }
+ }
+
+ /**
+ *
+ * @param {Number|null} editorWidth: The width to set the node to. If null, removes any
+ * `width` property on node style.
+ */
+ setEditorWidth(editorWidth) {
+ if (!this.node) {
+ return;
+ }
+
+ if (editorWidth) {
+ this.node.style.width = `${editorWidth}px`;
+ } else {
+ this.node.style.removeProperty("width");
+ }
+ }
+
+ focus() {
+ if (this.editor) {
+ this.editor.focus();
+ }
+ }
+
+ focusPreviousElement() {
+ const inputField = this.editor.codeMirror.getInputField();
+
+ const findPreviousFocusableElement = el => {
+ if (!el || !el.querySelectorAll) {
+ return null;
+ }
+
+ // We only want to get visible focusable element, and for that we can assert that
+ // the offsetParent isn't null. We can do that because we don't have fixed position
+ // element in the console.
+ const items = getFocusableElements(el).filter(
+ ({ offsetParent }) => offsetParent !== null
+ );
+ const inputIndex = items.indexOf(inputField);
+
+ if (items.length === 0 || (inputIndex > -1 && items.length === 1)) {
+ return findPreviousFocusableElement(el.parentNode);
+ }
+
+ const index = inputIndex > 0 ? inputIndex - 1 : items.length - 1;
+ return items[index];
+ };
+
+ const focusableEl = findPreviousFocusableElement(this.node.parentNode);
+ if (focusableEl) {
+ focusableEl.focus();
+ }
+ }
+
+ /**
+ * Execute a string. Execution happens asynchronously in the content process.
+ */
+ _execute() {
+ const value = this._getValue();
+ // In editor mode, we only evaluate the text selection if there's one. The feature isn't
+ // enabled in inline mode as it can be confusing since input is cleared when evaluating.
+ const executeString = this.props.editorMode
+ ? this.getSelectedText() || value
+ : value;
+
+ if (!executeString) {
+ return;
+ }
+
+ if (!this.props.editorMode) {
+ // Calling this.props.terminalInputChanged instead of this.terminalInputChanged
+ // because we want to instantly hide the instant evaluation result, and don't want
+ // the delay we have in this.terminalInputChanged.
+ this.props.terminalInputChanged("");
+ this._setValue("");
+ }
+ this.clearCompletion();
+ this.props.evaluateExpression(executeString);
+ }
+
+ /**
+ * Sets the value of the input field.
+ *
+ * @param string newValue
+ * The new value to set.
+ * @returns void
+ */
+ _setValue(newValue = "") {
+ this.lastInputValue = newValue;
+ this.terminalInputChanged(newValue);
+
+ if (this.editor) {
+ // In order to get the autocomplete popup to work properly, we need to set the
+ // editor text and the cursor in the same operation. If we don't, the text change
+ // is done before the cursor is moved, and the autocompletion call to the server
+ // sends an erroneous query.
+ this.editor.codeMirror.operation(() => {
+ this.editor.setText(newValue);
+
+ // Set the cursor at the end of the input.
+ const lines = newValue.split("\n");
+ this.editor.setCursor({
+ line: lines.length - 1,
+ ch: lines[lines.length - 1].length,
+ });
+ this.editor.setAutoCompletionText();
+ });
+ }
+
+ this.emitForTests("set-input-value");
+ }
+
+ /**
+ * Gets the value from the input field
+ * @returns string
+ */
+ _getValue() {
+ return this.editor ? this.editor.getText() || "" : "";
+ }
+
+ /**
+ * Open the file picker for the user to select a javascript file and open it.
+ *
+ */
+ async _openFile() {
+ const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(
+ this.webConsoleUI.document.defaultView,
+ l10n.getStr("webconsole.input.openJavaScriptFile"),
+ Ci.nsIFilePicker.modeOpen
+ );
+
+ // Append file filters
+ fp.appendFilter(
+ l10n.getStr("webconsole.input.openJavaScriptFileFilter"),
+ "*.js"
+ );
+
+ function readFile(file) {
+ return new Promise(resolve => {
+ IOUtils.read(file.path).then(data => {
+ const decoder = new TextDecoder();
+ resolve(decoder.decode(data));
+ });
+ });
+ }
+
+ const content = await new Promise(resolve => {
+ fp.open(rv => {
+ if (rv == Ci.nsIFilePicker.returnOK) {
+ const file = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ file.initWithPath(fp.file.path);
+ readFile(file).then(resolve);
+ }
+ });
+ });
+
+ this._setValue(content);
+ }
+
+ getSelectionStart() {
+ return this.getInputValueBeforeCursor().length;
+ }
+
+ getSelectedText() {
+ return this.editor.getSelection();
+ }
+
+ /**
+ * Even handler for the "beforeChange" event fired by codeMirror. This event is fired
+ * when codeMirror is about to make a change to its DOM representation.
+ */
+ _onEditorBeforeChange(cm, change) {
+ // If the user did not type a character that matches the completion text, then we
+ // clear it before the change is done to prevent a visual glitch.
+ // See Bugs 1491776 & 1558248.
+ const { from, to, origin, text } = change;
+ const isAddedText =
+ from.line === to.line && from.ch === to.ch && origin === "+input";
+
+ // if there was no changes (hitting delete on an empty input, or suppr when at the end
+ // of the input), we bail out.
+ if (
+ !isAddedText &&
+ origin === "+delete" &&
+ from.line === to.line &&
+ from.ch === to.ch
+ ) {
+ return;
+ }
+
+ const addedText = text.join("");
+ const completionText = this.getAutoCompletionText();
+
+ const addedCharacterMatchCompletion =
+ isAddedText && completionText.startsWith(addedText);
+
+ const addedCharacterMatchPopupItem =
+ isAddedText &&
+ this.autocompletePopup.items.some(({ preLabel, label }) =>
+ label.startsWith(preLabel + addedText)
+ );
+ const nextSelectedAutocompleteItemIndex =
+ addedCharacterMatchPopupItem &&
+ this.autocompletePopup.items.findIndex(({ preLabel, label }) =>
+ label.startsWith(preLabel + addedText)
+ );
+
+ if (addedCharacterMatchPopupItem) {
+ this.autocompletePopup.selectItemAtIndex(
+ nextSelectedAutocompleteItemIndex,
+ { preventSelectCallback: true }
+ );
+ }
+
+ if (!completionText || change.canceled || !addedCharacterMatchCompletion) {
+ this.setAutoCompletionText("");
+ }
+
+ if (!addedCharacterMatchCompletion && !addedCharacterMatchPopupItem) {
+ this.autocompletePopup.hidePopup();
+ } else if (
+ !change.canceled &&
+ (completionText ||
+ addedCharacterMatchCompletion ||
+ addedCharacterMatchPopupItem)
+ ) {
+ // The completion text will be updated when the debounced autocomplete update action
+ // is done, so in the meantime we set the pending value to pendingCompletionText.
+ // See Bug 1595068 for more information.
+ this.pendingCompletionText = completionText.substring(text.length);
+ // And we update the preLabel of the matching autocomplete items that may be used
+ // in the acceptProposedAutocompletion function.
+ this.autocompletePopup.items.forEach(item => {
+ if (item.label.startsWith(item.preLabel + addedText)) {
+ item.preLabel += addedText;
+ }
+ });
+ }
+ }
+
+ /**
+ * Even handler for the "blur" event fired by codeMirror.
+ */
+ _onEditorBlur(cm) {
+ if (cm.somethingSelected()) {
+ // If there's a selection when the input is blurred, then we remove it by setting
+ // the cursor at the position that matches the start of the first selection.
+ const [{ head }] = cm.listSelections();
+ cm.setCursor(head, { scroll: false });
+ }
+ }
+
+ /**
+ * Fired after a key is handled through a key map.
+ *
+ * @param {CodeMirror} cm: codeMirror instance
+ * @param {String} key: The key that was handled
+ * @param {Event} e: The keypress event
+ */
+ _onEditorKeyHandled(cm, key, e) {
+ // The autocloseBracket addon handle closing brackets keys when they're typed, but
+ // there's already an existing closing bracket.
+ // ex:
+ // 1. input is `foo(x|)` (where | represents the cursor)
+ // 2. user types `)`
+ // 3. input is now `foo(x)|` (i.e. the typed character wasn't inserted)
+ // In such case, _onEditorBeforeChange isn't triggered, so we need to hide the popup
+ // here. We can do that because this function won't be called when codeMirror _do_
+ // insert the closing char.
+ const closingKeys = [`']'`, `')'`, "'}'"];
+ if (this.autocompletePopup.isOpen && closingKeys.includes(key)) {
+ this.clearCompletion();
+ }
+ }
+
+ /**
+ * Retrieve variable declared in the expression from the CodeMirror state, in order
+ * to display them in the autocomplete popup.
+ */
+ _getExpressionVariables() {
+ const cm = this.editor.codeMirror;
+ const { state } = cm.getTokenAt(cm.getCursor());
+ const variables = [];
+
+ if (state.context) {
+ for (let c = state.context; c; c = c.prev) {
+ for (let v = c.vars; v; v = v.next) {
+ if (v.name) {
+ variables.push(v.name);
+ }
+ }
+ }
+ }
+
+ const keys = ["localVars", "globalVars"];
+ for (const key of keys) {
+ if (state[key]) {
+ for (let v = state[key]; v; v = v.next) {
+ if (v.name) {
+ variables.push(v.name);
+ }
+ }
+ }
+ }
+
+ return variables;
+ }
+
+ /**
+ * The editor "changes" event handler.
+ */
+ _onEditorChanges(cm, changes) {
+ const value = this._getValue();
+
+ if (this.lastInputValue !== value) {
+ // We don't autocomplete if the changes were made by JsTerm (e.g. autocomplete was
+ // accepted).
+ const isJsTermChangeOnly = changes.every(
+ ({ origin }) => origin === JSTERM_CODEMIRROR_ORIGIN
+ );
+
+ if (
+ !isJsTermChangeOnly &&
+ (this.props.autocomplete || this.hasAutocompletionSuggestion())
+ ) {
+ this.autocompleteUpdate(false, null, this._getExpressionVariables());
+ }
+ this.lastInputValue = value;
+ this.terminalInputChanged(value);
+ }
+ }
+
+ /**
+ * Go up/down the history stack of input values.
+ *
+ * @param number direction
+ * History navigation direction: HISTORY_BACK or HISTORY_FORWARD.
+ *
+ * @returns boolean
+ * True if the input value changed, false otherwise.
+ */
+ historyPeruse(direction) {
+ const { history, updateHistoryPosition, getValueFromHistory } = this.props;
+
+ if (!history.entries.length) {
+ return false;
+ }
+
+ const newInputValue = getValueFromHistory(direction);
+ const expression = this._getValue();
+ updateHistoryPosition(direction, expression);
+
+ if (newInputValue != null) {
+ this._setValue(newInputValue);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Test for empty input.
+ *
+ * @return boolean
+ */
+ hasEmptyInput() {
+ return this._getValue() === "";
+ }
+
+ /**
+ * Check if the caret is at a location that allows selecting the previous item
+ * in history when the user presses the Up arrow key.
+ *
+ * @return boolean
+ * True if the caret is at a location that allows selecting the
+ * previous item in history when the user presses the Up arrow key,
+ * otherwise false.
+ */
+ canCaretGoPrevious() {
+ if (!this.editor) {
+ return false;
+ }
+
+ const inputValue = this._getValue();
+ const { line, ch } = this.editor.getCursor();
+ return (line === 0 && ch === 0) || (line === 0 && ch === inputValue.length);
+ }
+
+ /**
+ * Check if the caret is at a location that allows selecting the next item in
+ * history when the user presses the Down arrow key.
+ *
+ * @return boolean
+ * True if the caret is at a location that allows selecting the next
+ * item in history when the user presses the Down arrow key, otherwise
+ * false.
+ */
+ canCaretGoNext() {
+ if (!this.editor) {
+ return false;
+ }
+
+ const inputValue = this._getValue();
+ const multiline = /[\r\n]/.test(inputValue);
+
+ const { line, ch } = this.editor.getCursor();
+ return (
+ (!multiline && ch === 0) ||
+ this.editor.getDoc().getRange({ line: 0, ch: 0 }, { line, ch }).length ===
+ inputValue.length
+ );
+ }
+
+ /**
+ * Takes the data returned by the server and update the autocomplete popup state (i.e.
+ * its visibility and items).
+ *
+ * @param {Object} data
+ * The autocompletion data as returned by the webconsole actor's autocomplete
+ * service. Should be of the following shape:
+ * {
+ * matches: {Array} array of the properties matching the input,
+ * matchProp: {String} The string used to filter the properties,
+ * isElementAccess: {Boolean} True when the input is an element access,
+ * i.e. `document["addEve`.
+ * }
+ * @fires autocomplete-updated
+ */
+ async updateAutocompletionPopup(data) {
+ if (!this.editor) {
+ return;
+ }
+
+ const { matches, matchProp, isElementAccess } = data;
+ if (!matches.length) {
+ this.clearCompletion();
+ return;
+ }
+
+ const inputUntilCursor = this.getInputValueBeforeCursor();
+
+ const items = matches.map(label => {
+ let preLabel = label.substring(0, matchProp.length);
+ // If the user is performing an element access, and if they did not typed a quote,
+ // then we need to adjust the preLabel to match the quote from the label + what
+ // the user entered.
+ if (isElementAccess && /^['"`]/.test(matchProp) === false) {
+ preLabel = label.substring(0, matchProp.length + 1);
+ }
+ return { preLabel, label, isElementAccess };
+ });
+
+ if (items.length) {
+ const { preLabel, label } = items[0];
+ let suffix = label.substring(preLabel.length);
+ if (isElementAccess) {
+ if (!matchProp) {
+ suffix = label;
+ }
+ const inputAfterCursor = this._getValue().substring(
+ inputUntilCursor.length
+ );
+ // If there's not a bracket after the cursor, add it to the completionText.
+ if (!inputAfterCursor.trimLeft().startsWith("]")) {
+ suffix = suffix + "]";
+ }
+ }
+ this.setAutoCompletionText(suffix);
+ }
+
+ const popup = this.autocompletePopup;
+ // We don't want to trigger the onSelect callback since we already set the completion
+ // text a few lines above.
+ popup.setItems(items, 0, {
+ preventSelectCallback: true,
+ });
+
+ const minimumAutoCompleteLength = 2;
+
+ // We want to show the autocomplete popup if:
+ // - there are at least 2 matching results
+ // - OR, if there's 1 result, but whose label does not start like the input (this can
+ // happen with insensitive search: `num` will match `Number`).
+ // - OR, if there's 1 result, but we can't show the completionText (because there's
+ // some text after the cursor), unless the text in the popup is the same as the input.
+ if (
+ items.length >= minimumAutoCompleteLength ||
+ (items.length === 1 && items[0].preLabel !== matchProp) ||
+ (items.length === 1 &&
+ !this.canDisplayAutoCompletionText() &&
+ items[0].label !== matchProp)
+ ) {
+ // We need to show the popup at the "." or "[".
+ const xOffset = -1 * matchProp.length * this._inputCharWidth;
+ const yOffset = 5;
+ const popupAlignElement =
+ this.props.serviceContainer.getJsTermTooltipAnchor();
+ this._openPopupPendingPromise = popup.openPopup(
+ popupAlignElement,
+ xOffset,
+ yOffset,
+ 0,
+ {
+ preventSelectCallback: true,
+ }
+ );
+ await this._openPopupPendingPromise;
+ this._openPopupPendingPromise = null;
+ } else if (
+ items.length < minimumAutoCompleteLength &&
+ (popup.isOpen || this._openPopupPendingPromise)
+ ) {
+ if (this._openPopupPendingPromise) {
+ await this._openPopupPendingPromise;
+ }
+ popup.hidePopup();
+ }
+
+ // Eager evaluation results incorporate the current autocomplete item. We need to
+ // trigger it here as well as in onAutocompleteSelect as we set the items with
+ // preventSelectCallback (which means we won't trigger onAutocompleteSelect when the
+ // popup is open).
+ this.terminalInputChanged(
+ this.getInputValueWithCompletionText().expression
+ );
+
+ this.emit("autocomplete-updated");
+ }
+
+ onAutocompleteSelect() {
+ const { selectedItem } = this.autocompletePopup;
+ if (selectedItem) {
+ const { preLabel, label, isElementAccess } = selectedItem;
+ let suffix = label.substring(preLabel.length);
+
+ // If the user is performing an element access, we need to check if we should add
+ // starting and ending quotes, as well as a closing bracket.
+ if (isElementAccess) {
+ const inputBeforeCursor = this.getInputValueBeforeCursor();
+ if (inputBeforeCursor.trim().endsWith("[")) {
+ suffix = label;
+ }
+
+ const inputAfterCursor = this._getValue().substring(
+ inputBeforeCursor.length
+ );
+ // If there's no closing bracket after the cursor, add it to the completionText.
+ if (!inputAfterCursor.trimLeft().startsWith("]")) {
+ suffix = suffix + "]";
+ }
+ }
+ this.setAutoCompletionText(suffix);
+ } else {
+ this.setAutoCompletionText("");
+ }
+ // Eager evaluation results incorporate the current autocomplete item.
+ this.terminalInputChanged(
+ this.getInputValueWithCompletionText().expression
+ );
+ }
+
+ /**
+ * Clear the current completion information, cancel any pending autocompletion update
+ * and close the autocomplete popup, if needed.
+ * @fires autocomplete-updated
+ */
+ clearCompletion() {
+ this.autocompleteUpdate.cancel();
+ // Update Eager evaluation result as the completion text was removed.
+ this.terminalInputChanged(this._getValue());
+
+ this.setAutoCompletionText("");
+ let onPopupClosed = Promise.resolve();
+ if (this.autocompletePopup) {
+ this.autocompletePopup.clearItems();
+
+ if (this.autocompletePopup.isOpen || this._openPopupPendingPromise) {
+ onPopupClosed = this.autocompletePopup.once("popup-closed");
+
+ if (this._openPopupPendingPromise) {
+ this._openPopupPendingPromise.then(() =>
+ this.autocompletePopup.hidePopup()
+ );
+ } else {
+ this.autocompletePopup.hidePopup();
+ }
+ onPopupClosed.then(() => this.focus());
+ }
+ }
+ onPopupClosed.then(() => this.emit("autocomplete-updated"));
+ }
+
+ /**
+ * Accept the proposed input completion.
+ */
+ acceptProposedCompletion() {
+ const {
+ completionText,
+ numberOfCharsToMoveTheCursorForward,
+ numberOfCharsToReplaceCharsBeforeCursor,
+ } = this.getInputValueWithCompletionText();
+
+ this.autocompleteUpdate.cancel();
+ this.props.autocompleteClear();
+
+ // If the code triggering the opening of the popup was already triggered but not yet
+ // settled, then we need to wait until it's resolved in order to close the popup (See
+ // Bug 1655406).
+ if (this._openPopupPendingPromise) {
+ this._openPopupPendingPromise.then(() =>
+ this.autocompletePopup.hidePopup()
+ );
+ }
+
+ if (completionText) {
+ this.insertStringAtCursor(
+ completionText,
+ numberOfCharsToReplaceCharsBeforeCursor
+ );
+
+ if (numberOfCharsToMoveTheCursorForward) {
+ const { line, ch } = this.editor.getCursor();
+ this.editor.setCursor({
+ line,
+ ch: ch + numberOfCharsToMoveTheCursorForward,
+ });
+ }
+ }
+ }
+
+ /**
+ * Returns an object containing the expression we would get if the user accepted the
+ * current completion text. This is more than the current input + the completion text,
+ * as there are special cases for element access and case-insensitive matches.
+ *
+ * @return {Object}: An object of the following shape:
+ * - {String} expression: The complete expression
+ * - {String} completionText: the completion text only, which should be used
+ * with the next property
+ * - {Integer} numberOfCharsToReplaceCharsBeforeCursor: The number of chars that
+ * should be removed from the current input before the cursor to
+ * cleanly apply the completionText. This is handy when we only want
+ * to insert the completionText.
+ * - {Integer} numberOfCharsToMoveTheCursorForward: The number of chars that the
+ * cursor should be moved after the completion is done. This can
+ * be useful for element access where there's already a closing
+ * quote and/or bracket.
+ */
+ getInputValueWithCompletionText() {
+ const inputBeforeCursor = this.getInputValueBeforeCursor();
+ const inputAfterCursor = this._getValue().substring(
+ inputBeforeCursor.length
+ );
+ let completionText = this.getAutoCompletionText();
+ let numberOfCharsToReplaceCharsBeforeCursor;
+ let numberOfCharsToMoveTheCursorForward = 0;
+
+ // If the autocompletion popup is open, we always get the selected element from there,
+ // since the autocompletion text might not be enough (e.g. `dOcUmEn` should
+ // autocomplete to `document`, but the autocompletion text only shows `t`).
+ if (this.autocompletePopup.isOpen && this.autocompletePopup.selectedItem) {
+ const { selectedItem } = this.autocompletePopup;
+ const { label, preLabel, isElementAccess } = selectedItem;
+
+ completionText = label;
+ numberOfCharsToReplaceCharsBeforeCursor = preLabel.length;
+
+ // If the user is performing an element access, we need to check if we should add
+ // starting and ending quotes, as well as a closing bracket.
+ if (isElementAccess) {
+ const lastOpeningBracketIndex = inputBeforeCursor.lastIndexOf("[");
+ if (lastOpeningBracketIndex > -1) {
+ numberOfCharsToReplaceCharsBeforeCursor = inputBeforeCursor.substring(
+ lastOpeningBracketIndex + 1
+ ).length;
+ }
+
+ // If the autoclose bracket option is enabled, the input might be in a state where
+ // there's already the closing quote and the closing bracket, e.g.
+ // `document["activeEl|"]`, so we don't need to add
+ // Let's retrieve the completionText last character, to see if it's a quote.
+ const completionTextLastChar =
+ completionText[completionText.length - 1];
+ const endingQuote = [`"`, `'`, "`"].includes(completionTextLastChar)
+ ? completionTextLastChar
+ : "";
+ if (
+ endingQuote &&
+ inputAfterCursor.trimLeft().startsWith(endingQuote)
+ ) {
+ completionText = completionText.substring(
+ 0,
+ completionText.length - 1
+ );
+ numberOfCharsToMoveTheCursorForward++;
+ }
+
+ // If there's not a closing bracket already, we add one.
+ if (
+ !inputAfterCursor.trimLeft().match(new RegExp(`^${endingQuote}?]`))
+ ) {
+ completionText = completionText + "]";
+ } else {
+ // if there's already one, we want to move the cursor after the closing bracket.
+ numberOfCharsToMoveTheCursorForward++;
+ }
+ }
+ }
+
+ const expression =
+ inputBeforeCursor.substring(
+ 0,
+ inputBeforeCursor.length -
+ (numberOfCharsToReplaceCharsBeforeCursor || 0)
+ ) +
+ completionText +
+ inputAfterCursor;
+
+ return {
+ completionText,
+ expression,
+ numberOfCharsToMoveTheCursorForward,
+ numberOfCharsToReplaceCharsBeforeCursor,
+ };
+ }
+
+ getInputValueBeforeCursor() {
+ return this.editor
+ ? this.editor
+ .getDoc()
+ .getRange({ line: 0, ch: 0 }, this.editor.getCursor())
+ : null;
+ }
+
+ /**
+ * Insert a string into the console at the cursor location,
+ * moving the cursor to the end of the string.
+ *
+ * @param {string} str
+ * @param {int} numberOfCharsToReplaceCharsBeforeCursor - defaults to 0
+ */
+ insertStringAtCursor(str, numberOfCharsToReplaceCharsBeforeCursor = 0) {
+ if (!this.editor) {
+ return;
+ }
+
+ const cursor = this.editor.getCursor();
+ const from = {
+ line: cursor.line,
+ ch: cursor.ch - numberOfCharsToReplaceCharsBeforeCursor,
+ };
+
+ this.editor
+ .getDoc()
+ .replaceRange(str, from, cursor, JSTERM_CODEMIRROR_ORIGIN);
+ }
+
+ /**
+ * Set the autocompletion text of the input.
+ *
+ * @param string suffix
+ * The proposed suffix for the input value.
+ */
+ setAutoCompletionText(suffix) {
+ if (!this.editor) {
+ return;
+ }
+
+ this.pendingCompletionText = null;
+
+ if (suffix && !this.canDisplayAutoCompletionText()) {
+ suffix = "";
+ }
+
+ this.editor.setAutoCompletionText(suffix);
+ }
+
+ getAutoCompletionText() {
+ const renderedCompletionText =
+ this.editor && this.editor.getAutoCompletionText();
+ return typeof this.pendingCompletionText === "string"
+ ? this.pendingCompletionText
+ : renderedCompletionText;
+ }
+
+ /**
+ * Indicate if the input has an autocompletion suggestion, i.e. that there is either
+ * something in the autocompletion text or that there's a selected item in the
+ * autocomplete popup.
+ */
+ hasAutocompletionSuggestion() {
+ // We can have cases where the popup is opened but we can't display the autocompletion
+ // text.
+ return (
+ this.getAutoCompletionText() ||
+ (this.autocompletePopup.isOpen &&
+ Number.isInteger(this.autocompletePopup.selectedIndex) &&
+ this.autocompletePopup.selectedIndex > -1)
+ );
+ }
+
+ /**
+ * Returns a boolean indicating if we can display an autocompletion text in the input,
+ * i.e. if there is no characters displayed on the same line of the cursor and after it.
+ */
+ canDisplayAutoCompletionText() {
+ if (!this.editor) {
+ return false;
+ }
+
+ const { ch, line } = this.editor.getCursor();
+ const lineContent = this.editor.getLine(line);
+ const textAfterCursor = lineContent.substring(ch);
+ return textAfterCursor === "";
+ }
+
+ /**
+ * Calculates and returns the width of a single character of the input box.
+ * This will be used in opening the popup at the correct offset.
+ *
+ * @returns {Number|null}: Width off the "x" char, or null if the input does not exist.
+ */
+ _getInputCharWidth() {
+ return this.editor ? this.editor.defaultCharWidth() : null;
+ }
+
+ onContextMenu(e) {
+ this.props.serviceContainer.openEditContextMenu(e);
+ }
+
+ destroy() {
+ this.autocompleteUpdate.cancel();
+ this.terminalInputChanged.cancel();
+ this._openPopupPendingPromise = null;
+
+ if (this.autocompletePopup) {
+ this.autocompletePopup.destroy();
+ this.autocompletePopup = null;
+ }
+
+ if (this.editor) {
+ this.resizeObserver.disconnect();
+ this.editor.destroy();
+ this.editor = null;
+ }
+
+ this.webConsoleUI = null;
+ }
+
+ renderOpenEditorButton() {
+ if (this.props.editorMode) {
+ return null;
+ }
+
+ return dom.button({
+ className:
+ "devtools-button webconsole-input-openEditorButton" +
+ (this.props.showEditorOnboarding ? " devtools-feature-callout" : ""),
+ title: l10n.getFormatStr("webconsole.input.openEditorButton.tooltip2", [
+ isMacOS ? "Cmd + B" : "Ctrl + B",
+ ]),
+ onClick: this.props.editorToggle,
+ });
+ }
+
+ renderEvaluationContextSelector() {
+ if (this.props.editorMode || !this.props.showEvaluationContextSelector) {
+ return null;
+ }
+
+ return EvaluationContextSelector(this.props);
+ }
+
+ renderEditorOnboarding() {
+ if (!this.props.showEditorOnboarding) {
+ return null;
+ }
+
+ // We deliberately use getStr, and not getFormatStr, because we want keyboard
+ // shortcuts to be wrapped in their own span.
+ const label = l10n.getStr("webconsole.input.editor.onboarding.label");
+ let [prefix, suffix] = label.split("%1$S");
+ suffix = suffix.split("%2$S");
+
+ const enterString = l10n.getStr("webconsole.enterKey");
+
+ return dom.header(
+ { className: "editor-onboarding" },
+ dom.img({
+ className: "editor-onboarding-fox",
+ src: "chrome://devtools/skin/images/fox-smiling.svg",
+ }),
+ dom.p(
+ {},
+ prefix,
+ dom.span({ className: "editor-onboarding-shortcut" }, enterString),
+ suffix[0],
+ dom.span({ className: "editor-onboarding-shortcut" }, [
+ isMacOS ? `Cmd+${enterString}` : `Ctrl+${enterString}`,
+ ]),
+ suffix[1]
+ ),
+ dom.button(
+ {
+ className: "editor-onboarding-dismiss-button",
+ onClick: () => this.props.editorOnboardingDismiss(),
+ },
+ l10n.getStr("webconsole.input.editor.onboarding.dismiss.label")
+ )
+ );
+ }
+
+ render() {
+ if (!this.props.inputEnabled) {
+ return null;
+ }
+
+ return dom.div(
+ {
+ className: "jsterm-input-container devtools-input",
+ key: "jsterm-container",
+ "aria-live": "off",
+ tabIndex: -1,
+ onContextMenu: this.onContextMenu,
+ ref: node => {
+ this.node = node;
+ },
+ },
+ dom.div(
+ { className: "webconsole-input-buttons" },
+ this.renderEvaluationContextSelector(),
+ this.renderOpenEditorButton()
+ ),
+ this.renderEditorOnboarding()
+ );
+ }
+}
+
+// Redux connect
+
+function mapStateToProps(state) {
+ return {
+ history: getHistory(state),
+ getValueFromHistory: direction => getHistoryValue(state, direction),
+ autocompleteData: getAutocompleteState(state),
+ showEditorOnboarding: state.ui.showEditorOnboarding,
+ showEvaluationContextSelector: state.ui.showEvaluationContextSelector,
+ autocompletePopupPosition: state.prefs.eagerEvaluation ? "top" : "bottom",
+ editorPrettifiedAt: state.ui.editorPrettifiedAt,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ updateHistoryPosition: (direction, expression) =>
+ dispatch(actions.updateHistoryPosition(direction, expression)),
+ autocompleteUpdate: (force, getterPath, expressionVars) =>
+ dispatch(actions.autocompleteUpdate(force, getterPath, expressionVars)),
+ autocompleteClear: () => dispatch(actions.autocompleteClear()),
+ evaluateExpression: expression =>
+ dispatch(actions.evaluateExpression(expression)),
+ editorToggle: () => dispatch(actions.editorToggle()),
+ editorOnboardingDismiss: () => dispatch(actions.editorOnboardingDismiss()),
+ terminalInputChanged: value =>
+ dispatch(actions.terminalInputChanged(value)),
+ };
+}
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);
diff --git a/devtools/client/webconsole/components/Input/ReverseSearchInput.css b/devtools/client/webconsole/components/Input/ReverseSearchInput.css
new file mode 100644
index 0000000000..1347de3ab8
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/ReverseSearchInput.css
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.reverse-search {
+ display: flex;
+ font-size: inherit;
+ min-height: 26px;
+ color: var(--theme-body-color);
+ padding-block-start: 2px;
+ align-items: baseline;
+ border: 1px solid transparent;
+ border-top-color: var(--theme-splitter-color);
+ transition: border-color 0.2s ease-in-out;
+}
+
+.jsterm-editor .reverse-search {
+ border-inline-end-color: var(--theme-splitter-color);
+}
+
+/* Add a border radius match the borders of the window on Mac OS
+ * and hide the border radius on the right if the sidebar or editor
+ * is open. */
+:root[platform="mac"] .webconsole-app .reverse-search {
+ border-end-start-radius: 5px;
+}
+:root[platform="mac"] .webconsole-app:not(.jsterm-editor, .sidebar-visible) .reverse-search
+{
+ border-end-end-radius: 5px;
+}
+
+.reverse-search:focus-within {
+ border-color: var(--blue-50);
+}
+
+.reverse-search {
+ flex-shrink: 0;
+}
+
+.reverse-search input {
+ border: none;
+ flex-grow: 1;
+ background: transparent;
+ color: currentColor;
+ background-image: url(chrome://devtools/skin/images/search.svg);
+ background-repeat: no-repeat;
+ background-size: 12px;
+ --background-position-inline: 10px;
+ background-position: var(--background-position-inline) 2px;
+ -moz-context-properties: fill;
+ fill: var(--theme-icon-dimmed-color);
+ text-align: match-parent;
+ unicode-bidi: plaintext;
+ min-width: 80px;
+ flex-shrink: 1;
+ flex-basis: 0;
+}
+
+.reverse-search:dir(ltr) input {
+ /* Be explicit about left/right direction to prevent the text/placeholder
+ * from overlapping the background image when the user changes the text
+ * direction manually (e.g. via Ctrl+Shift). */
+ padding-left: var(--console-inline-start-gutter);
+}
+
+.reverse-search:dir(rtl) input {
+ background-position-x: right var(--background-position-inline);
+ padding-right: var(--console-inline-start-gutter);
+}
+
+.reverse-search input:focus {
+ border: none;
+ outline: none;
+}
+
+.reverse-search:not(.no-result) input:focus {
+ fill: var(--theme-icon-checked-color);
+}
+
+.reverse-search-actions {
+ flex-shrink: 0;
+ display: flex;
+ align-items: baseline;
+}
+
+.reverse-search-info {
+ flex-shrink: 0;
+ padding: 0 8px;
+ color: var(--comment-node-color);
+}
+
+.search-result-button-prev,
+.search-result-button-next,
+.reverse-search-close-button {
+ padding: 4px 0;
+ margin: 0;
+ border-radius: 0;
+}
+
+.search-result-button-prev::before {
+ background-image: url("chrome://devtools/skin/images/arrowhead-up.svg");
+ background-size: 16px;
+ fill: var(--comment-node-color);
+}
+
+.search-result-button-next::before {
+ background-image: url("chrome://devtools/skin/images/arrowhead-down.svg");
+ background-size: 16px;
+ fill: var(--comment-node-color);
+}
+
+.reverse-search-close-button::before {
+ fill: var(--comment-node-color);
+ background-image: url("chrome://devtools/skin/images/close.svg");
+}
+
+.reverse-search.no-result input {
+ fill: var(--error-color);
+}
+
+.reverse-search.no-result,
+.reverse-search.no-result input {
+ color: var(--error-color);
+}
diff --git a/devtools/client/webconsole/components/Input/ReverseSearchInput.js b/devtools/client/webconsole/components/Input/ReverseSearchInput.js
new file mode 100644
index 0000000000..5cece45bc7
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/ReverseSearchInput.js
@@ -0,0 +1,285 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// React & Redux
+const {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const {
+ getReverseSearchTotalResults,
+ getReverseSearchResultPosition,
+ getReverseSearchResult,
+} = require("resource://devtools/client/webconsole/selectors/history.js");
+
+loader.lazyRequireGetter(
+ this,
+ "PropTypes",
+ "resource://devtools/client/shared/vendor/react-prop-types.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "actions",
+ "resource://devtools/client/webconsole/actions/index.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "l10n",
+ "resource://devtools/client/webconsole/utils/messages.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PluralForm",
+ "resource://devtools/shared/plural-form.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "KeyCodes",
+ "resource://devtools/client/shared/keycodes.js",
+ true
+);
+
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+class ReverseSearchInput extends Component {
+ static get propTypes() {
+ return {
+ dispatch: PropTypes.func.isRequired,
+ setInputValue: PropTypes.func.isRequired,
+ focusInput: PropTypes.func.isRequired,
+ reverseSearchResult: PropTypes.string,
+ reverseSearchTotalResults: PropTypes.number,
+ reverseSearchResultPosition: PropTypes.number,
+ visible: PropTypes.bool,
+ initialValue: PropTypes.string,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onInputKeyDown = this.onInputKeyDown.bind(this);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { setInputValue, focusInput } = this.props;
+ if (
+ prevProps.reverseSearchResult !== this.props.reverseSearchResult &&
+ this.props.visible &&
+ this.props.reverseSearchTotalResults > 0
+ ) {
+ setInputValue(this.props.reverseSearchResult);
+ }
+
+ if (prevProps.visible === true && this.props.visible === false) {
+ focusInput();
+ }
+
+ if (
+ prevProps.visible === false &&
+ this.props.visible === true &&
+ this.props.initialValue
+ ) {
+ this.inputNode.value = this.props.initialValue;
+ }
+ }
+
+ onEnterKeyboardShortcut(event) {
+ const { dispatch } = this.props;
+ event.stopPropagation();
+ dispatch(actions.reverseSearchInputToggle());
+ dispatch(actions.evaluateExpression(undefined, "reverse-search"));
+ }
+
+ onEscapeKeyboardShortcut(event) {
+ const { dispatch } = this.props;
+ event.stopPropagation();
+ dispatch(actions.reverseSearchInputToggle());
+ }
+
+ onBackwardNavigationKeyBoardShortcut(event, canNavigate) {
+ const { dispatch } = this.props;
+ event.stopPropagation();
+ event.preventDefault();
+ if (canNavigate) {
+ dispatch(actions.showReverseSearchBack({ access: "keyboard" }));
+ }
+ }
+
+ onForwardNavigationKeyBoardShortcut(event, canNavigate) {
+ const { dispatch } = this.props;
+ event.stopPropagation();
+ event.preventDefault();
+ if (canNavigate) {
+ dispatch(actions.showReverseSearchNext({ access: "keyboard" }));
+ }
+ }
+
+ onInputKeyDown(event) {
+ const { keyCode, key, ctrlKey, shiftKey } = event;
+ const { reverseSearchTotalResults } = this.props;
+
+ // On Enter, we trigger an execute.
+ if (keyCode === KeyCodes.DOM_VK_RETURN) {
+ return this.onEnterKeyboardShortcut(event);
+ }
+
+ const lowerCaseKey = key.toLowerCase();
+
+ // On Escape (and Ctrl + c on OSX), we close the reverse search input.
+ if (
+ keyCode === KeyCodes.DOM_VK_ESCAPE ||
+ (isMacOS && ctrlKey && lowerCaseKey === "c")
+ ) {
+ return this.onEscapeKeyboardShortcut(event);
+ }
+
+ const canNavigate =
+ Number.isInteger(reverseSearchTotalResults) &&
+ reverseSearchTotalResults > 1;
+
+ if (
+ (!isMacOS && key === "F9" && !shiftKey) ||
+ (isMacOS && ctrlKey && lowerCaseKey === "r")
+ ) {
+ return this.onBackwardNavigationKeyBoardShortcut(event, canNavigate);
+ }
+
+ if (
+ (!isMacOS && key === "F9" && shiftKey) ||
+ (isMacOS && ctrlKey && lowerCaseKey === "s")
+ ) {
+ return this.onForwardNavigationKeyBoardShortcut(event, canNavigate);
+ }
+
+ return null;
+ }
+
+ renderSearchInformation() {
+ const { reverseSearchTotalResults, reverseSearchResultPosition } =
+ this.props;
+
+ if (!Number.isInteger(reverseSearchTotalResults)) {
+ return null;
+ }
+
+ let text;
+ if (reverseSearchTotalResults === 0) {
+ text = l10n.getStr("webconsole.reverseSearch.noResult");
+ } else {
+ const resultsString = l10n.getStr("webconsole.reverseSearch.results");
+ text = PluralForm.get(reverseSearchTotalResults, resultsString)
+ .replace("#1", reverseSearchResultPosition)
+ .replace("#2", reverseSearchTotalResults);
+ }
+
+ return dom.div({ className: "reverse-search-info" }, text);
+ }
+
+ renderNavigationButtons() {
+ const { dispatch, reverseSearchTotalResults } = this.props;
+
+ if (
+ !Number.isInteger(reverseSearchTotalResults) ||
+ reverseSearchTotalResults <= 1
+ ) {
+ return null;
+ }
+
+ return [
+ dom.button({
+ key: "search-result-button-prev",
+ className: "devtools-button search-result-button-prev",
+ title: l10n.getFormatStr(
+ "webconsole.reverseSearch.result.previousButton.tooltip",
+ [isMacOS ? "Ctrl + R" : "F9"]
+ ),
+ onClick: () => {
+ dispatch(actions.showReverseSearchBack({ access: "click" }));
+ this.inputNode.focus();
+ },
+ }),
+ dom.button({
+ key: "search-result-button-next",
+ className: "devtools-button search-result-button-next",
+ title: l10n.getFormatStr(
+ "webconsole.reverseSearch.result.nextButton.tooltip",
+ [isMacOS ? "Ctrl + S" : "Shift + F9"]
+ ),
+ onClick: () => {
+ dispatch(actions.showReverseSearchNext({ access: "click" }));
+ this.inputNode.focus();
+ },
+ }),
+ ];
+ }
+
+ render() {
+ const { dispatch, visible, reverseSearchTotalResults } = this.props;
+
+ if (!visible) {
+ return null;
+ }
+
+ const classNames = ["reverse-search"];
+
+ if (reverseSearchTotalResults === 0) {
+ classNames.push("no-result");
+ }
+
+ return dom.div(
+ { className: classNames.join(" ") },
+ dom.input({
+ ref: node => {
+ this.inputNode = node;
+ },
+ autoFocus: true,
+ placeholder: l10n.getStr("webconsole.reverseSearch.input.placeHolder"),
+ className: "reverse-search-input devtools-monospace",
+ onKeyDown: this.onInputKeyDown,
+ onInput: ({ target }) =>
+ dispatch(actions.reverseSearchInputChange(target.value)),
+ }),
+ dom.div(
+ {
+ className: "reverse-search-actions",
+ },
+ this.renderSearchInformation(),
+ this.renderNavigationButtons(),
+ dom.button({
+ className: "devtools-button reverse-search-close-button",
+ title: l10n.getFormatStr(
+ "webconsole.reverseSearch.closeButton.tooltip",
+ ["Esc" + (isMacOS ? " | Ctrl + C" : "")]
+ ),
+ onClick: () => {
+ dispatch(actions.reverseSearchInputToggle());
+ },
+ })
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ visible: state.ui.reverseSearchInputVisible,
+ reverseSearchTotalResults: getReverseSearchTotalResults(state),
+ reverseSearchResultPosition: getReverseSearchResultPosition(state),
+ reverseSearchResult: getReverseSearchResult(state),
+});
+
+const mapDispatchToProps = dispatch => ({ dispatch });
+
+module.exports = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(ReverseSearchInput);
diff --git a/devtools/client/webconsole/components/Input/moz.build b/devtools/client/webconsole/components/Input/moz.build
new file mode 100644
index 0000000000..ae435b3495
--- /dev/null
+++ b/devtools/client/webconsole/components/Input/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "ConfirmDialog.js",
+ "EagerEvaluation.js",
+ "EditorToolbar.js",
+ "EvaluationContextSelector.js",
+ "JSTerm.js",
+ "ReverseSearchInput.js",
+)
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..064d7ee052
--- /dev/null
+++ b/devtools/client/webconsole/components/Output/ConsoleOutput.js
@@ -0,0 +1,378 @@
+/* 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);
+
+ // We need to scroll to the bottom if:
+ // - we are reacting to "initialize" action, and we are already scrolled to the bottom
+ // - the number of messages displayed changed and we are already scrolled to the
+ // bottom, but not if we are reacting to a group opening.
+ // - the number of messages in the store changed and the new message is an evaluation
+ // result.
+
+ 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;
+
+ const messagesUiDelta =
+ nextProps.messagesUi.length - this.props.messagesUi.length;
+ const isOpeningGroup =
+ messagesUiDelta > 0 &&
+ nextProps.messagesUi.some(
+ id =>
+ !this.props.messagesUi.includes(id) &&
+ nextProps.messagesUi.includes(id) &&
+ this.props.visibleMessages.includes(id) &&
+ nextProps.visibleMessages.includes(id)
+ );
+
+ this.shouldScrollBottom =
+ (!this.props.initialized &&
+ nextProps.initialized &&
+ this.scrolledToBottom) ||
+ isNewMessageEvaluationResult ||
+ (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..db856e909c
--- /dev/null
+++ b/devtools/client/webconsole/components/Output/MessageContainer.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";
+
+// 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 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.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..6e3fb69aaa
--- /dev/null
+++ b/devtools/client/webconsole/components/Output/MessageIcon.js
@@ -0,0 +1,71 @@
+/* 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 l10nLevels = {
+ error: "level.error",
+ warn: "level.warn",
+ info: "level.info",
+ log: "level.log",
+ debug: "level.debug",
+};
+
+// 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 === "logTrace") {
+ title = l10n.getStr("logtrace.title");
+ classnames.push("logtrace");
+ } else if (type === "blockedReason") {
+ title = l10n.getStr("blockedrequest.label");
+ }
+
+ {
+ 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..2abd7b5301
--- /dev/null
+++ b/devtools/client/webconsole/components/Output/MessageIndent.js
@@ -0,0 +1,41 @@
+/* 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 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);
+}
+
+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/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..4f0414e562
--- /dev/null
+++ b/devtools/client/webconsole/components/Output/message-types/SimpleTable.js
@@ -0,0 +1,134 @@
+/* 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,
+};
+
+function SimpleTable(props) {
+ const {
+ dispatch,
+ message,
+ serviceContainer,
+ timestampsVisible,
+ badge,
+ 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,
+ badge,
+ 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..ac1019bf05
--- /dev/null
+++ b/devtools/client/webconsole/components/Output/message-types/moz.build
@@ -0,0 +1,17 @@
+# 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",
+ "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",
+)