diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/client/webconsole/components/Output | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
19 files changed, 2580 insertions, 0 deletions
diff --git a/devtools/client/webconsole/components/Output/CollapseButton.js b/devtools/client/webconsole/components/Output/CollapseButton.js new file mode 100644 index 0000000000..4a1463e7fc --- /dev/null +++ b/devtools/client/webconsole/components/Output/CollapseButton.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 dom = require("devtools/client/shared/vendor/react-dom-factories"); + +const { l10n } = require("devtools/client/webconsole/utils/messages"); +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: 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..6950e01069 --- /dev/null +++ b/devtools/client/webconsole/components/Output/ConsoleOutput.js @@ -0,0 +1,294 @@ +/* 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, +} = require("devtools/client/shared/vendor/react"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { + connect, +} = require("devtools/client/shared/redux/visibility-handler-connect"); +const { initialize } = require("devtools/client/webconsole/actions/ui"); + +const { + getAllMessagesById, + getAllMessagesUiById, + getAllMessagesPayloadById, + getAllNetworkMessagesUpdateById, + getVisibleMessages, + getAllRepeatById, + getAllWarningGroupsById, + isMessageInWarningGroup, +} = require("devtools/client/webconsole/selectors/messages"); + +loader.lazyRequireGetter( + this, + "PropTypes", + "devtools/client/shared/vendor/react-prop-types" +); +loader.lazyRequireGetter( + this, + "MessageContainer", + "devtools/client/webconsole/components/Output/MessageContainer", + true +); + +const { MESSAGE_TYPE } = require("devtools/client/webconsole/constants"); +const { + getInitialMessageCountForViewport, +} = require("devtools/client/webconsole/utils/messages.js"); + +class ConsoleOutput extends Component { + static get propTypes() { + return { + initialized: PropTypes.bool.isRequired, + messages: PropTypes.object.isRequired, + messagesUi: PropTypes.array.isRequired, + serviceContainer: PropTypes.shape({ + attachRefToWebConsoleUI: PropTypes.func.isRequired, + openContextMenu: PropTypes.func.isRequired, + sourceMapURLService: PropTypes.object, + }), + dispatch: PropTypes.func.isRequired, + timestampsVisible: PropTypes.bool, + messagesPayload: 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, + }; + } + + constructor(props) { + super(props); + this.onContextMenu = this.onContextMenu.bind(this); + this.maybeScrollToBottom = this.maybeScrollToBottom.bind(this); + + 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.visibleMessages.length > 0) { + this.scrollToBottom(); + } + + this.lastMessageIntersectionObserver = new IntersectionObserver( + entries => { + for (const entry of entries) { + // Consider that we're not pinned to the bottom anymore if the last message is + // less than half-visible. + this.scrolledToBottom = entry.intersectionRatio >= 0.5; + } + }, + { root: this.outputNode, threshold: [0.5] } + ); + + this.resizeObserver.observe(this.getElementToObserve()); + + const { serviceContainer, onFirstMeaningfulPaint, dispatch } = this.props; + serviceContainer.attachRefToWebConsoleUI("outputScroller", this.outputNode); + + // 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); + }); + } + + componentWillUpdate(nextProps, nextState) { + 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; + return; + } + + const { lastChild } = outputNode; + this.lastMessageIntersectionObserver.unobserve(lastChild); + + // 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.messages.size - this.props.messages.size; + const isNewMessageEvaluationResult = + messagesDelta > 0 && + [...nextProps.messages.values()][nextProps.messages.size - 1].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.maybeScrollToBottom(); + if (this?.outputNode?.lastChild) { + this.lastMessageIntersectionObserver.observe(this.outputNode.lastChild); + } + + if (prevProps.editorMode !== this.props.editorMode) { + this.resizeObserver.observe(this.getElementToObserve()); + } + } + + maybeScrollToBottom() { + if (this.outputNode && this.shouldScrollBottom) { + this.scrollToBottom(); + } + } + + scrollToBottom() { + 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() { + let { + dispatch, + visibleMessages, + messages, + messagesUi, + messagesPayload, + messagesRepeat, + warningGroups, + networkMessagesUpdate, + networkMessageActiveTabId, + serviceContainer, + timestampsVisible, + initialized, + } = this.props; + + if (!initialized) { + const numberMessagesFitViewport = getInitialMessageCountForViewport( + window + ); + if (numberMessagesFitViewport < visibleMessages.length) { + visibleMessages = visibleMessages.slice( + visibleMessages.length - numberMessagesFitViewport + ); + } + } + + const messageNodes = visibleMessages.map(messageId => + createElement(MessageContainer, { + dispatch, + key: messageId, + messageId, + serviceContainer, + open: messagesUi.includes(messageId), + payload: messagesPayload.get(messageId), + timestampsVisible, + repeat: messagesRepeat[messageId], + badge: warningGroups.has(messageId) + ? warningGroups.get(messageId).length + : null, + inWarningGroup: + warningGroups && warningGroups.size > 0 + ? isMessageInWarningGroup(messages.get(messageId), visibleMessages) + : false, + networkMessageUpdate: networkMessagesUpdate[messageId], + networkMessageActiveTabId, + getMessage: () => messages.get(messageId), + maybeScrollToBottom: this.maybeScrollToBottom, + }) + ); + + return dom.div( + { + className: "webconsole-output", + role: "main", + onContextMenu: this.onContextMenu, + ref: node => { + this.outputNode = node; + }, + }, + messageNodes + ); + } +} + +function mapStateToProps(state, props) { + return { + initialized: state.ui.initialized, + messages: getAllMessagesById(state), + visibleMessages: getVisibleMessages(state), + messagesUi: getAllMessagesUiById(state), + messagesPayload: getAllMessagesPayloadById(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..49d1fda2db --- /dev/null +++ b/devtools/client/webconsole/components/Output/ConsoleTable.js @@ -0,0 +1,271 @@ +/* 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("devtools/client/shared/vendor/react"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { + l10n, + getArrayTypeNames, + getDescriptorValue, +} = require("devtools/client/webconsole/utils/messages"); +loader.lazyGetter(this, "MODE", function() { + return require("devtools/client/shared/components/reps/index").MODE; +}); + +const GripMessageBody = createFactory( + require("devtools/client/webconsole/components/Output/GripMessageBody") +); + +loader.lazyRequireGetter( + this, + "PropTypes", + "devtools/client/shared/vendor/react-prop-types" +); + +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, + }; + } + + 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.div( + { + className: "new-consoletable-header", + role: "columnheader", + key, + title: value, + }, + value + ) + ) + ); + return headerItems; + } + + getRows(columns, items) { + const { dispatch, serviceContainer } = this.props; + + return items.map((item, index) => { + const cells = []; + const className = index % 2 ? "odd" : "even"; + + columns.forEach((value, key) => { + const cellValue = item[key]; + const cellContent = + typeof cellValue === "undefined" + ? "" + : GripMessageBody({ + grip: cellValue, + mode: MODE.SHORT, + useQuotes: false, + serviceContainer, + dispatch, + }); + + cells.push( + dom.div( + { + role: "gridcell", + className, + key, + }, + cellContent + ) + ); + }); + return cells; + }); + } + + 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); + + return dom.div( + { + className: "new-consoletable", + role: "grid", + style: { + gridTemplateColumns: `repeat(${columns.size}, calc(100% / ${columns.size}))`, + }, + }, + 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..a6ac4231b2 --- /dev/null +++ b/devtools/client/webconsole/components/Output/GripMessageBody.js @@ -0,0 +1,148 @@ +/* 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("devtools/client/shared/vendor/react-prop-types"); +const { + MESSAGE_TYPE, + JSTERM_COMMANDS, +} = require("devtools/client/webconsole/constants"); +const { + getObjectInspector, +} = require("devtools/client/webconsole/utils/object-inspector"); +const actions = require("devtools/client/webconsole/actions/index"); + +loader.lazyGetter(this, "objectInspector", function() { + return require("devtools/client/shared/components/reps/index") + .objectInspector; +}); + +loader.lazyGetter(this, "MODE", function() { + return require("devtools/client/shared/components/reps/index").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, +}; + +GripMessageBody.defaultProps = { + mode: MODE.LONG, +}; + +function GripMessageBody(props) { + const { + grip, + userProvidedStyle, + serviceContainer, + useQuotes, + escapeWhitespace, + mode = MODE.LONG, + dispatch, + maybeScrollToBottom, + customFormat = false, + } = props; + + let styleObject; + if (userProvidedStyle && userProvidedStyle !== "") { + styleObject = cleanupStyle( + userProvidedStyle, + serviceContainer.createElement + ); + } + + const objectInspectorProps = { + autoExpandDepth: shouldAutoExpandObjectInspector(props) ? 1 : 0, + mode, + maybeScrollToBottom, + 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); +} + +// Regular expression that matches the allowed CSS property names. +const allowedStylesRegex = new RegExp( + "^(?:-moz-)?(?:background|border|box|clear|color|cursor|display|float|font|line|" + + "margin|padding|text|transition|outline|white-space|word|writing|" + + "(?:min-|max-)?width|(?:min-|max-)?height)" +); + +// Regular expression that matches the forbidden CSS property values. +const forbiddenValuesRegexs = [ + // -moz-element() + /\b((?:-moz-)?element)[\s('"]+/gi, + + // various URL protocols + /['"(]*(?:chrome|resource|about|app|https?|ftp|file):+\/*/gi, +]; + +function cleanupStyle(userProvidedStyle, createElement) { + // Use a dummy element to parse the style string. + const dummy = createElement("div"); + dummy.style = userProvidedStyle; + + // Return a style object as expected by React DOM components, e.g. + // {color: "red"} + // without forbidden properties and values. + return Array.from(dummy.style) + .filter(name => { + return ( + allowedStylesRegex.test(name) && + !forbiddenValuesRegexs.some(regex => regex.test(dummy.style[name])) + ); + }) + .reduce((object, name) => { + return Object.assign( + { + [name]: dummy.style[name], + }, + object + ); + }, {}); +} + +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/Message.js b/devtools/client/webconsole/components/Output/Message.js new file mode 100644 index 0000000000..bdcb6825a3 --- /dev/null +++ b/devtools/client/webconsole/components/Output/Message.js @@ -0,0 +1,455 @@ +/* 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("devtools/client/shared/vendor/react"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { l10n } = require("devtools/client/webconsole/utils/messages"); +const actions = require("devtools/client/webconsole/actions/index"); +const { + MESSAGE_LEVEL, + MESSAGE_SOURCE, + MESSAGE_TYPE, +} = require("devtools/client/webconsole/constants"); +const { + MessageIndent, +} = require("devtools/client/webconsole/components/Output/MessageIndent"); +const MessageIcon = require("devtools/client/webconsole/components/Output/MessageIcon"); +const FrameView = createFactory( + require("devtools/client/shared/components/Frame") +); + +loader.lazyRequireGetter( + this, + "CollapseButton", + "devtools/client/webconsole/components/Output/CollapseButton" +); +loader.lazyRequireGetter( + this, + "MessageRepeat", + "devtools/client/webconsole/components/Output/MessageRepeat" +); +loader.lazyRequireGetter( + this, + "PropTypes", + "devtools/client/shared/vendor/react-prop-types" +); +loader.lazyRequireGetter( + this, + "SmartTrace", + "devtools/client/shared/components/SmartTrace" +); + +class Message extends Component { + static get propTypes() { + return { + open: PropTypes.bool, + collapsible: PropTypes.bool, + collapseTitle: PropTypes.string, + 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, + }), + 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 } = this.props; + + // 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 } = this.props; + + if (inWarningGroup) { + return undefined; + } + + 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, + 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"); + } + + 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) { + attachment = dom.div( + { + className: "stacktrace devtools-monospace", + }, + createElement(SmartTrace, { + stacktrace, + onViewSourceInDebugger: + serviceContainer.onViewSourceInDebugger || + serviceContainer.onViewSource, + onViewSource: serviceContainer.onViewSource, + onReady: this.props.maybeScrollToBottom, + sourceMapURLService: serviceContainer.sourceMapURLService, + }) + ); + } + + // If there is an expandable part, make it collapsible. + let collapse = null; + if (collapsible) { + 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 = dom.span( + { className: "message-location devtools-monospace" }, + frame + ? FrameView({ + 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, + "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..cbc4ed163b --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageContainer.js @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { Component } = require("devtools/client/shared/vendor/react"); +loader.lazyRequireGetter( + this, + "PropTypes", + "devtools/client/shared/vendor/react-prop-types" +); +loader.lazyRequireGetter( + this, + "isWarningGroup", + "devtools/client/webconsole/utils/messages", + true +); + +const { + MESSAGE_SOURCE, + MESSAGE_TYPE, +} = require("devtools/client/webconsole/constants"); + +const componentMap = new Map([ + [ + "ConsoleApiCall", + require("devtools/client/webconsole/components/Output/message-types/ConsoleApiCall"), + ], + [ + "ConsoleCommand", + require("devtools/client/webconsole/components/Output/message-types/ConsoleCommand"), + ], + [ + "CSSWarning", + require("devtools/client/webconsole/components/Output/message-types/CSSWarning"), + ], + [ + "DefaultRenderer", + require("devtools/client/webconsole/components/Output/message-types/DefaultRenderer"), + ], + [ + "EvaluationResult", + require("devtools/client/webconsole/components/Output/message-types/EvaluationResult"), + ], + [ + "NetworkEventMessage", + require("devtools/client/webconsole/components/Output/message-types/NetworkEventMessage"), + ], + [ + "PageError", + require("devtools/client/webconsole/components/Output/message-types/PageError"), + ], + [ + "WarningGroup", + require("devtools/client/webconsole/components/Output/message-types/WarningGroup"), + ], +]); + +class MessageContainer extends Component { + static get propTypes() { + return { + messageId: PropTypes.string.isRequired, + open: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object.isRequired, + payload: 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, + }; + } + + static get defaultProps() { + return { + open: false, + }; + } + + shouldComponentUpdate(nextProps) { + const triggeringUpdateProps = [ + "repeat", + "open", + "payload", + "timestampsVisible", + "networkMessageUpdate", + "badge", + "inWarningGroup", + ]; + + 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 componentMap.get("DefaultRenderer"); + } + + switch (message.source) { + case MESSAGE_SOURCE.CONSOLE_API: + return componentMap.get("ConsoleApiCall"); + case MESSAGE_SOURCE.NETWORK: + return componentMap.get("NetworkEventMessage"); + case MESSAGE_SOURCE.CSS: + return componentMap.get("CSSWarning"); + case MESSAGE_SOURCE.JAVASCRIPT: + switch (message.type) { + case MESSAGE_TYPE.COMMAND: + return componentMap.get("ConsoleCommand"); + case MESSAGE_TYPE.RESULT: + return componentMap.get("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 componentMap.get("PageError"); + default: + return componentMap.get("DefaultRenderer"); + } + case MESSAGE_SOURCE.CONSOLE_FRONTEND: + if (isWarningGroup(message)) { + return componentMap.get("WarningGroup"); + } + break; + } + + return componentMap.get("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..3569e8531a --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageIcon.js @@ -0,0 +1,67 @@ +/* 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("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { l10n } = require("devtools/client/webconsole/utils/messages"); + +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) { + let title = l10n.getStr(l10nLevels[level] || level); + const classnames = ["icon"]; + + if (type && type === "logPoint") { + title = l10n.getStr("logpoint.title"); + classnames.push("logpoint"); + } + + if (type && 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, +}; + +function MessageIcon(props) { + const { level, type } = props; + + if (type) { + return getIconElement(level, type); + } + + 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..91b3c31dc3 --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageIndent.js @@ -0,0 +1,38 @@ +/* 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("devtools/client/shared/vendor/react-dom-factories"); + +const INDENT_WIDTH = 12; + +// Store common indents so they can be used without recreating the element during render. +const CONSTANT_INDENTS = [getIndentElement(0), getIndentElement(1)]; +const IN_WARNING_GROUP_INDENT = getIndentElement(1, "warning-indent"); + +function getIndentElement(indent, className) { + return dom.span({ + "data-indent": indent, + className: `indent${className ? " " + className : ""}`, + style: { + width: indent * INDENT_WIDTH, + }, + }); +} + +function MessageIndent(props) { + const { indent, inWarningGroup } = props; + + if (inWarningGroup) { + return IN_WARNING_GROUP_INDENT; + } + + 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..647bd975ad --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageRepeat.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 PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { PluralForm } = require("devtools/shared/plural-form"); +const { l10n } = require("devtools/client/webconsole/utils/messages"); +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..9a7cabf442 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/CSSWarning.js @@ -0,0 +1,170 @@ +/* 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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const { l10n } = require("devtools/client/webconsole/utils/messages"); +const actions = require("devtools/client/webconsole/actions/index"); + +const Message = createFactory( + require("devtools/client/webconsole/components/Output/Message") +); + +loader.lazyRequireGetter( + this, + "GripMessageBody", + "devtools/client/webconsole/components/Output/GripMessageBody" +); + +/** + * 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, + payload: PropTypes.object, + repeat: PropTypes.any, + serviceContainer: PropTypes.object, + timestampsVisible: PropTypes.bool.isRequired, + }; + } + + 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, payload, open } = this.props; + + const { cssSelectors } = message; + + if (open) { + dispatch(actions.messageClose(messageId)); + } else if (payload) { + // 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 data payload 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(messageId, cssSelectors)); + dispatch(actions.messageOpen(messageId)); + } + } + + render() { + const { + dispatch, + message, + open, + payload, + repeat, + serviceContainer, + timestampsVisible, + inWarningGroup, + } = 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 && + payload !== undefined && + dom.div( + { className: "devtools-monospace" }, + dom.div( + { className: "elements-label" }, + l10n.getFormatStr("webconsole.cssWarningElements.label", [ + cssSelectors, + ]) + ), + GripMessageBody({ + dispatch, + escapeWhitespace: false, + grip: payload, + serviceContainer, + }) + ); + + 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..bce693fab7 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js @@ -0,0 +1,217 @@ +/* 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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const GripMessageBody = require("devtools/client/webconsole/components/Output/GripMessageBody"); +const ConsoleTable = createFactory( + require("devtools/client/webconsole/components/Output/ConsoleTable") +); +const { + isGroupType, + l10n, +} = require("devtools/client/webconsole/utils/messages"); + +const Message = createFactory( + require("devtools/client/webconsole/components/Output/Message") +); + +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, + payload, + serviceContainer, + timestampsVisible, + repeat, + maybeScrollToBottom, + } = 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, + // 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 > 0 + ? [" "].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, + type, + }); + } + + let attachment = null; + if (type === "table") { + attachment = ConsoleTable({ + dispatch, + id: message.id, + serviceContainer, + parameters: message.parameters, + tableData: payload, + }); + } + + 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, + customFormat, + } = options; + + return ( + parameters + // Get all the grips. + .map((grip, key) => + GripMessageBody({ + dispatch, + messageId, + grip, + key, + userProvidedStyle: userProvidedStyles + ? userProvidedStyles[key] + : null, + serviceContainer, + useQuotes: false, + loadedObjectProperties, + loadedObjectEntries, + type, + maybeScrollToBottom, + customFormat, + }) + ) + // Interleave spaces. + .reduce((arr, v, i) => { + // 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. + const needSpace = + i + 1 < parameters.length && + (!userProvidedStyles || + userProvidedStyles[i] === undefined || + userProvidedStyles[i + 1] === undefined); + + return needSpace ? arr.concat(v, " ") : arr.concat(v); + }, []) + ); +} + +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..4c43c6ea53 --- /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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const { ELLIPSIS } = require("devtools/shared/l10n"); +const Message = createFactory( + require("devtools/client/webconsole/components/Output/Message") +); + +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..2262e54f2c --- /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("devtools/client/shared/vendor/react-dom-factories"); + +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..1d6e6d27d4 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/EvaluationResult.js @@ -0,0 +1,120 @@ +/* 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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const Message = createFactory( + require("devtools/client/webconsole/components/Output/Message") +); +const GripMessageBody = require("devtools/client/webconsole/components/Output/GripMessageBody"); + +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, + } = 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, + 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/NetworkEventMessage.js b/devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js new file mode 100644 index 0000000000..69c57777de --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js @@ -0,0 +1,234 @@ +/* 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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); +const Message = createFactory( + require("devtools/client/webconsole/components/Output/Message") +); +const actions = require("devtools/client/webconsole/actions/index"); +const { + isMessageNetworkError, + l10n, +} = require("devtools/client/webconsole/utils/messages"); + +loader.lazyRequireGetter( + this, + "TabboxPanel", + "devtools/client/netmonitor/src/components/TabboxPanel" +); +const { + getHTTPStatusCodeURL, +} = require("devtools/client/netmonitor/src/utils/mdn-utils"); +loader.lazyRequireGetter( + this, + "BLOCKED_REASON_MESSAGES", + "devtools/client/netmonitor/src/constants", + true +); + +const LEARN_MORE = l10n.getStr("webConsoleMoreInfoLabel"); + +const Services = require("Services"); +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, +}) { + 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"); + } + + const onToggle = (messageId, e) => { + const shouldOpenLink = (isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey); + if (shouldOpenLink) { + serviceContainer.openLink(url, e); + e.stopPropagation(); + } else if (open) { + dispatch(actions.messageClose(messageId)); + } else { + dispatch(actions.messageOpen(messageId)); + } + }; + + // Message body components. + const requestMethod = dom.span({ className: "method" }, method); + const xhr = isXHR + ? dom.span({ className: "xhr" }, l10n.getStr("webConsoleXhrIndicator")) + : null; + const requestUrl = dom.span({ className: "url", title: url }, url); + 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); + }, + getTabTarget: () => {}, + sendHTTPRequest: () => {}, + setPreferences: () => {}, + triggerActivity: () => {}, + requestData: (requestId, dataType) => { + return serviceContainer.requestData(requestId, dataType); + }, + }; + + // Only render the attachment if the network-event is + // actually opened (performance optimization). + const attachment = + open && + 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, + onToggle, + 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..d093e4346c --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/PageError.js @@ -0,0 +1,123 @@ +/* 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("devtools/client/shared/vendor/react"); +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const Message = createFactory( + require("devtools/client/webconsole/components/Output/Message") +); +const GripMessageBody = require("devtools/client/webconsole/components/Output/GripMessageBody"); +loader.lazyGetter(this, "REPS", function() { + return require("devtools/client/shared/components/reps/index").REPS; +}); +loader.lazyGetter(this, "MODE", function() { + return require("devtools/client/shared/components/reps/index").MODE; +}); + +PageError.displayName = "PageError"; + +PageError.propTypes = { + message: PropTypes.object.isRequired, + open: PropTypes.bool, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + maybeScrollToBottom: PropTypes.func, + inWarningGroup: PropTypes.bool.isRequired, +}; + +PageError.defaultProps = { + open: false, +}; + +function PageError(props) { + const { + dispatch, + message, + open, + repeat, + serviceContainer, + timestampsVisible, + maybeScrollToBottom, + 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, + ...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/WarningGroup.js b/devtools/client/webconsole/components/Output/message-types/WarningGroup.js new file mode 100644 index 0000000000..7ff17d24c5 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/WarningGroup.js @@ -0,0 +1,76 @@ +/* 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("devtools/client/shared/vendor/react"); +const dom = require("devtools/client/shared/vendor/react-dom-factories"); + +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const Message = createFactory( + require("devtools/client/webconsole/components/Output/Message") +); + +const { PluralForm } = require("devtools/shared/plural-form"); +const { l10n } = require("devtools/client/webconsole/utils/messages"); +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..5b24c72b7d --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/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/. + +DevToolsModules( + "ConsoleApiCall.js", + "ConsoleCommand.js", + "CSSWarning.js", + "DefaultRenderer.js", + "EvaluationResult.js", + "NetworkEventMessage.js", + "PageError.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..5721ca2014 --- /dev/null +++ b/devtools/client/webconsole/components/Output/moz.build @@ -0,0 +1,20 @@ +# 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", + "Message.js", + "MessageContainer.js", + "MessageIcon.js", + "MessageIndent.js", + "MessageRepeat.js", +) |