diff options
Diffstat (limited to 'devtools/client/webconsole/components/Output')
23 files changed, 3462 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..c0594a5855 --- /dev/null +++ b/devtools/client/webconsole/components/Output/CollapseButton.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const messageToggleDetails = l10n.getStr("messageToggleDetails"); + +function CollapseButton(props) { + const { open, onClick, title = messageToggleDetails } = props; + + return dom.button({ + "aria-expanded": open ? "true" : "false", + "aria-label": title, + className: "arrow collapse-button", + onClick, + onMouseDown: e => { + // prevent focus from moving to the disclosure if clicked, + // which is annoying if on the input + e.preventDefault(); + // Clearing the text selection to allow the message to collpase. + e.target.ownerDocument.defaultView.getSelection().removeAllRanges(); + }, + title, + }); +} + +module.exports = CollapseButton; diff --git a/devtools/client/webconsole/components/Output/ConsoleOutput.js b/devtools/client/webconsole/components/Output/ConsoleOutput.js new file mode 100644 index 0000000000..a2469b3b46 --- /dev/null +++ b/devtools/client/webconsole/components/Output/ConsoleOutput.js @@ -0,0 +1,381 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, + createElement, + createRef, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + connect, +} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); +const { + initialize, +} = require("resource://devtools/client/webconsole/actions/ui.js"); +const LazyMessageList = require("resource://devtools/client/webconsole/components/Output/LazyMessageList.js"); + +const { + getMutableMessagesById, + getAllMessagesUiById, + getAllDisabledMessagesById, + getAllCssMessagesMatchingElements, + getAllNetworkMessagesUpdateById, + getLastMessageId, + getVisibleMessages, + getAllRepeatById, + getAllWarningGroupsById, + isMessageInWarningGroup, +} = require("resource://devtools/client/webconsole/selectors/messages.js"); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +loader.lazyRequireGetter( + this, + "MessageContainer", + "resource://devtools/client/webconsole/components/Output/MessageContainer.js", + true +); +loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js"); + +const { + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); + +class ConsoleOutput extends Component { + static get propTypes() { + return { + initialized: PropTypes.bool.isRequired, + mutableMessages: PropTypes.object.isRequired, + messageCount: PropTypes.number.isRequired, + messagesUi: PropTypes.array.isRequired, + disabledMessages: PropTypes.array.isRequired, + serviceContainer: PropTypes.shape({ + attachRefToWebConsoleUI: PropTypes.func.isRequired, + openContextMenu: PropTypes.func.isRequired, + sourceMapURLService: PropTypes.object, + }), + dispatch: PropTypes.func.isRequired, + timestampsVisible: PropTypes.bool, + cssMatchingElements: PropTypes.object.isRequired, + messagesRepeat: PropTypes.object.isRequired, + warningGroups: PropTypes.object.isRequired, + networkMessagesUpdate: PropTypes.object.isRequired, + visibleMessages: PropTypes.array.isRequired, + networkMessageActiveTabId: PropTypes.string.isRequired, + onFirstMeaningfulPaint: PropTypes.func.isRequired, + editorMode: PropTypes.bool.isRequired, + cacheGeneration: PropTypes.number.isRequired, + disableVirtualization: PropTypes.bool, + lastMessageId: PropTypes.string.isRequired, + }; + } + + constructor(props) { + super(props); + this.onContextMenu = this.onContextMenu.bind(this); + this.maybeScrollToBottom = this.maybeScrollToBottom.bind(this); + this.messageIdsToKeepAlive = new Set(); + this.ref = createRef(); + this.lazyMessageListRef = createRef(); + + this.resizeObserver = new ResizeObserver(entries => { + // If we don't have the outputNode reference, or if the outputNode isn't connected + // anymore, we disconnect the resize observer (componentWillUnmount is never called + // on this component, so we have to do it here). + if (!this.outputNode || !this.outputNode.isConnected) { + this.resizeObserver.disconnect(); + return; + } + + if (this.scrolledToBottom) { + this.scrollToBottom(); + } + }); + } + + componentDidMount() { + if (this.props.disableVirtualization) { + return; + } + + if (this.props.visibleMessages.length) { + this.scrollToBottom(); + } + + this.scrollDetectionIntersectionObserver = new IntersectionObserver( + entries => { + for (const entry of entries) { + // Consider that we're not pinned to the bottom anymore if the bottom of the + // scrollable area is within 10px of visible (half the typical element height.) + this.scrolledToBottom = entry.intersectionRatio > 0; + } + }, + { root: this.outputNode, rootMargin: "10px" } + ); + + this.resizeObserver.observe(this.getElementToObserve()); + + const { serviceContainer, onFirstMeaningfulPaint, dispatch } = this.props; + serviceContainer.attachRefToWebConsoleUI( + "outputScroller", + this.ref.current + ); + + // Waiting for the next paint. + new Promise(res => requestAnimationFrame(res)).then(() => { + if (onFirstMeaningfulPaint) { + onFirstMeaningfulPaint(); + } + + // Dispatching on next tick so we don't block on action execution. + setTimeout(() => { + dispatch(initialize()); + }, 0); + }); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps, nextState) { + this.isUpdating = true; + if (nextProps.cacheGeneration !== this.props.cacheGeneration) { + this.messageIdsToKeepAlive = new Set(); + } + + if (nextProps.editorMode !== this.props.editorMode) { + this.resizeObserver.disconnect(); + } + + const { outputNode } = this; + if (!outputNode?.lastChild) { + // Force a scroll to bottom when messages are added to an empty console. + // This makes the console stay pinned to the bottom if a batch of messages + // are added after a page refresh (Bug 1402237). + this.shouldScrollBottom = true; + this.scrolledToBottom = true; + return; + } + + const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer; + this.scrollDetectionIntersectionObserver.unobserve(bottomBuffer); + + const visibleMessagesDelta = + nextProps.visibleMessages.length - this.props.visibleMessages.length; + const messagesDelta = nextProps.messageCount - this.props.messageCount; + // Evaluation results are never filtered out, so if it's in the store, it will be + // visible in the output. + const isNewMessageEvaluationResult = + messagesDelta > 0 && + nextProps.lastMessageId && + nextProps.mutableMessages.get(nextProps.lastMessageId)?.type === + MESSAGE_TYPE.RESULT; + + // Use an inline function in order to avoid executing the expensive Array.some() + // unless condition are meant to do this additional check. + const isOpeningGroup = () => { + const messagesUiDelta = + nextProps.messagesUi.length - this.props.messagesUi.length; + return ( + messagesUiDelta > 0 && + nextProps.messagesUi.some( + id => + !this.props.messagesUi.includes(id) && + this.props.visibleMessages.includes(id) && + nextProps.visibleMessages.includes(id) + ) + ); + }; + + // We need to scroll to the bottom if: + this.shouldScrollBottom = + // - we are reacting to "initialize" action, and we are already scrolled to the bottom + (!this.props.initialized && + nextProps.initialized && + this.scrolledToBottom) || + // - the number of messages in the store changed and the new message is an evaluation + // result. + isNewMessageEvaluationResult || + // - the number of messages displayed changed and we are already scrolled to the + // bottom, but not if we are reacting to a group opening. + (this.scrolledToBottom && visibleMessagesDelta > 0 && !isOpeningGroup()); + } + + componentDidUpdate(prevProps) { + this.isUpdating = false; + this.maybeScrollToBottom(); + if (this?.outputNode?.lastChild) { + const bottomBuffer = this.lazyMessageListRef.current.bottomBuffer; + this.scrollDetectionIntersectionObserver.observe(bottomBuffer); + } + + if (prevProps.editorMode !== this.props.editorMode) { + this.resizeObserver.observe(this.getElementToObserve()); + } + } + + get outputNode() { + return this.ref.current; + } + + maybeScrollToBottom() { + if (this.outputNode && this.shouldScrollBottom) { + this.scrollToBottom(); + } + } + + // The maybeScrollToBottom callback we provide to messages needs to be a little bit more + // strict than the one we normally use, because they can potentially interrupt a user + // scroll (between when the intersection observer registers the scroll break and when + // a componentDidUpdate comes through to reconcile it.) + maybeScrollToBottomMessageCallback(index) { + if ( + this.outputNode && + this.shouldScrollBottom && + this.scrolledToBottom && + this.lazyMessageListRef.current?.isItemNearBottom(index) + ) { + this.scrollToBottom(); + } + } + + scrollToBottom() { + if (flags.testing && this.outputNode.hasAttribute("disable-autoscroll")) { + return; + } + if (this.outputNode.scrollHeight > this.outputNode.clientHeight) { + this.outputNode.scrollTop = this.outputNode.scrollHeight; + } + + this.scrolledToBottom = true; + } + + getElementToObserve() { + // In inline mode, we need to observe the output node parent, which contains both the + // output and the input, so we don't trigger the resizeObserver callback when only the + // output size changes (e.g. when a network request is expanded). + return this.props.editorMode + ? this.outputNode + : this.outputNode?.parentNode; + } + + onContextMenu(e) { + this.props.serviceContainer.openContextMenu(e); + e.stopPropagation(); + e.preventDefault(); + } + + render() { + const { + cacheGeneration, + dispatch, + visibleMessages, + disabledMessages, + mutableMessages, + messagesUi, + cssMatchingElements, + messagesRepeat, + warningGroups, + networkMessagesUpdate, + networkMessageActiveTabId, + serviceContainer, + timestampsVisible, + } = this.props; + + const renderMessage = (messageId, index) => { + return createElement(MessageContainer, { + dispatch, + key: messageId, + messageId, + serviceContainer, + open: messagesUi.includes(messageId), + cssMatchingElements: cssMatchingElements.get(messageId), + timestampsVisible, + disabled: disabledMessages.includes(messageId), + repeat: messagesRepeat[messageId], + badge: warningGroups.has(messageId) + ? warningGroups.get(messageId).length + : null, + inWarningGroup: + warningGroups && warningGroups.size > 0 + ? isMessageInWarningGroup( + mutableMessages.get(messageId), + visibleMessages + ) + : false, + networkMessageUpdate: networkMessagesUpdate[messageId], + networkMessageActiveTabId, + getMessage: () => mutableMessages.get(messageId), + maybeScrollToBottom: () => + this.maybeScrollToBottomMessageCallback(index), + // Whenever a node is expanded, we want to make sure we keep the + // message node alive so as to not lose the expanded state. + setExpanded: () => this.messageIdsToKeepAlive.add(messageId), + }); + }; + + // scrollOverdrawCount tells the list to draw extra elements above and + // below the scrollport so that we can avoid flashes of blank space + // when scrolling. When `disableVirtualization` is passed we make it as large as the + // number of messages to render them all and effectively disabling virtualization (this + // should only be used for some actions that requires all the messages to be rendered + // in the DOM, like "Copy All Messages"). + const scrollOverdrawCount = this.props.disableVirtualization + ? visibleMessages.length + : 20; + + const attrs = { + className: "webconsole-output", + role: "main", + onContextMenu: this.onContextMenu, + ref: this.ref, + }; + if (flags.testing) { + attrs["data-visible-messages"] = JSON.stringify(visibleMessages); + } + return dom.div( + attrs, + createElement(LazyMessageList, { + viewportRef: this.ref, + items: visibleMessages, + itemDefaultHeight: 21, + editorMode: this.props.editorMode, + scrollOverdrawCount, + ref: this.lazyMessageListRef, + renderItem: renderMessage, + itemsToKeepAlive: this.messageIdsToKeepAlive, + serviceContainer, + cacheGeneration, + shouldScrollBottom: () => this.shouldScrollBottom && this.isUpdating, + }) + ); + } +} + +function mapStateToProps(state, props) { + const mutableMessages = getMutableMessagesById(state); + return { + initialized: state.ui.initialized, + cacheGeneration: state.ui.cacheGeneration, + // We need to compute this so lifecycle methods can compare the global message count + // on state change (since we can't do it with mutableMessagesById). + messageCount: mutableMessages.size, + mutableMessages, + lastMessageId: getLastMessageId(state), + visibleMessages: getVisibleMessages(state), + disabledMessages: getAllDisabledMessagesById(state), + messagesUi: getAllMessagesUiById(state), + cssMatchingElements: getAllCssMessagesMatchingElements(state), + messagesRepeat: getAllRepeatById(state), + warningGroups: getAllWarningGroupsById(state), + networkMessagesUpdate: getAllNetworkMessagesUpdateById(state), + timestampsVisible: state.ui.timestampsVisible, + networkMessageActiveTabId: state.ui.networkMessageActiveTabId, + }; +} + +module.exports = connect(mapStateToProps)(ConsoleOutput); diff --git a/devtools/client/webconsole/components/Output/ConsoleTable.js b/devtools/client/webconsole/components/Output/ConsoleTable.js new file mode 100644 index 0000000000..f41afce96d --- /dev/null +++ b/devtools/client/webconsole/components/Output/ConsoleTable.js @@ -0,0 +1,272 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + getArrayTypeNames, +} = require("resource://devtools/shared/webconsole/messages.js"); +const { + l10n, + getDescriptorValue, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); + +const GripMessageBody = createFactory( + require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js") +); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); + +const TABLE_ROW_MAX_ITEMS = 1000; +// Match Chrome max column number. +const TABLE_COLUMN_MAX_ITEMS = 21; + +class ConsoleTable extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + parameters: PropTypes.array.isRequired, + serviceContainer: PropTypes.object.isRequired, + id: PropTypes.string.isRequired, + setExpanded: PropTypes.func, + }; + } + + constructor(props) { + super(props); + this.getHeaders = this.getHeaders.bind(this); + this.getRows = this.getRows.bind(this); + } + + getHeaders(columns) { + const headerItems = []; + columns.forEach((value, key) => + headerItems.push( + dom.th( + { + key, + title: value, + }, + value + ) + ) + ); + return dom.thead({}, dom.tr({}, headerItems)); + } + + getRows(columns, items) { + const { dispatch, serviceContainer, setExpanded } = this.props; + + const rows = []; + items.forEach((item, index) => { + const cells = []; + + columns.forEach((value, key) => { + const cellValue = item[key]; + const cellContent = + typeof cellValue === "undefined" + ? "" + : GripMessageBody({ + grip: cellValue, + mode: MODE.SHORT, + useQuotes: false, + serviceContainer, + dispatch, + setExpanded, + }); + + cells.push( + dom.td( + { + key, + }, + cellContent + ) + ); + }); + rows.push(dom.tr({}, cells)); + }); + return dom.tbody({}, rows); + } + + render() { + const { parameters } = this.props; + const { valueGrip, headersGrip } = getValueAndHeadersGrip(parameters); + + const headers = headersGrip?.preview ? headersGrip.preview.items : null; + + const data = valueGrip?.ownProperties; + + // if we don't have any data, don't show anything. + if (!data) { + return null; + } + + const dataType = getParametersDataType(parameters); + const { columns, items } = getTableItems(data, dataType, headers); + + // We need to wrap the <table> in a div so we can have the max-height set properly + // without changing the table display. + return dom.div( + { className: "consoletable-wrapper" }, + dom.table( + { + className: "consoletable", + }, + this.getHeaders(columns), + this.getRows(columns, items) + ) + ); + } +} + +function getValueAndHeadersGrip(parameters) { + const [valueFront, headersFront] = parameters; + + const headersGrip = headersFront?.getGrip + ? headersFront.getGrip() + : headersFront; + + const valueGrip = valueFront?.getGrip ? valueFront.getGrip() : valueFront; + + return { valueGrip, headersGrip }; +} + +function getParametersDataType(parameters = null) { + if (!Array.isArray(parameters) || parameters.length === 0) { + return null; + } + const [firstParam] = parameters; + if (!firstParam || !firstParam.getGrip) { + return null; + } + const grip = firstParam.getGrip(); + return grip.class; +} + +const INDEX_NAME = "_index"; +const VALUE_NAME = "_value"; + +function getNamedIndexes(type) { + return { + [INDEX_NAME]: getArrayTypeNames().concat("Object").includes(type) + ? l10n.getStr("table.index") + : l10n.getStr("table.iterationIndex"), + [VALUE_NAME]: l10n.getStr("table.value"), + key: l10n.getStr("table.key"), + }; +} + +function hasValidCustomHeaders(headers) { + return ( + Array.isArray(headers) && + headers.every( + header => typeof header === "string" || Number.isInteger(Number(header)) + ) + ); +} + +function getTableItems(data = {}, type, headers = null) { + const namedIndexes = getNamedIndexes(type); + + let columns = new Map(); + const items = []; + + const addItem = function (item) { + items.push(item); + Object.keys(item).forEach(key => addColumn(key)); + }; + + const validCustomHeaders = hasValidCustomHeaders(headers); + + const addColumn = function (columnIndex) { + const columnExists = columns.has(columnIndex); + const hasMaxColumns = columns.size == TABLE_COLUMN_MAX_ITEMS; + + if ( + !columnExists && + !hasMaxColumns && + (!validCustomHeaders || + headers.includes(columnIndex) || + columnIndex === INDEX_NAME) + ) { + columns.set(columnIndex, namedIndexes[columnIndex] || columnIndex); + } + }; + + for (let [index, property] of Object.entries(data)) { + if (type !== "Object" && index == parseInt(index, 10)) { + index = parseInt(index, 10); + } + + const item = { + [INDEX_NAME]: index, + }; + + const propertyValue = getDescriptorValue(property); + const propertyValueGrip = propertyValue?.getGrip + ? propertyValue.getGrip() + : propertyValue; + + if (propertyValueGrip?.ownProperties) { + const entries = propertyValueGrip.ownProperties; + for (const [key, entry] of Object.entries(entries)) { + item[key] = getDescriptorValue(entry); + } + } else if ( + propertyValueGrip?.preview && + (type === "Map" || type === "WeakMap") + ) { + item.key = propertyValueGrip.preview.key; + item[VALUE_NAME] = propertyValueGrip.preview.value; + } else { + item[VALUE_NAME] = propertyValue; + } + + addItem(item); + + if (items.length === TABLE_ROW_MAX_ITEMS) { + break; + } + } + + // Some headers might not be present in the items, so we make sure to + // return all the headers set by the user. + if (validCustomHeaders) { + headers.forEach(header => addColumn(header)); + } + + // We want to always have the index column first + if (columns.has(INDEX_NAME)) { + const index = columns.get(INDEX_NAME); + columns.delete(INDEX_NAME); + columns = new Map([[INDEX_NAME, index], ...columns.entries()]); + } + + // We want to always have the values column last + if (columns.has(VALUE_NAME)) { + const index = columns.get(VALUE_NAME); + columns.delete(VALUE_NAME); + columns.set(VALUE_NAME, index); + } + + return { + columns, + items, + }; +} + +module.exports = ConsoleTable; diff --git a/devtools/client/webconsole/components/Output/GripMessageBody.js b/devtools/client/webconsole/components/Output/GripMessageBody.js new file mode 100644 index 0000000000..6ecfe55b8e --- /dev/null +++ b/devtools/client/webconsole/components/Output/GripMessageBody.js @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + MESSAGE_TYPE, + JSTERM_COMMANDS, +} = require("resource://devtools/client/webconsole/constants.js"); +const { + cleanupStyle, +} = require("resource://devtools/client/shared/components/reps/reps/rep-utils.js"); +const { + getObjectInspector, +} = require("resource://devtools/client/webconsole/utils/object-inspector.js"); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); + +loader.lazyGetter(this, "objectInspector", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .objectInspector; +}); + +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); + +GripMessageBody.displayName = "GripMessageBody"; + +GripMessageBody.propTypes = { + grip: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.object, + ]).isRequired, + serviceContainer: PropTypes.shape({ + createElement: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func.isRequired, + }), + userProvidedStyle: PropTypes.string, + useQuotes: PropTypes.bool, + escapeWhitespace: PropTypes.bool, + type: PropTypes.string, + helperType: PropTypes.string, + maybeScrollToBottom: PropTypes.func, + setExpanded: PropTypes.func, +}; + +GripMessageBody.defaultProps = { + mode: MODE.LONG, +}; + +function GripMessageBody(props) { + const { + grip, + userProvidedStyle, + serviceContainer, + useQuotes, + escapeWhitespace, + mode = MODE.LONG, + dispatch, + maybeScrollToBottom, + setExpanded, + customFormat = false, + } = props; + + let styleObject; + if (userProvidedStyle && userProvidedStyle !== "") { + styleObject = cleanupStyle( + userProvidedStyle, + serviceContainer.createElement + ); + } + + const objectInspectorProps = { + autoExpandDepth: shouldAutoExpandObjectInspector(props) ? 1 : 0, + mode, + maybeScrollToBottom, + setExpanded, + customFormat, + onCmdCtrlClick: (node, { depth, event, focused, expanded }) => { + const front = objectInspector.utils.node.getFront(node); + if (front) { + dispatch(actions.showObjectInSidebar(front)); + } + }, + }; + + if ( + typeof grip === "string" || + (grip && grip.type === "longString") || + (grip?.getGrip && grip.getGrip().type === "longString") + ) { + Object.assign(objectInspectorProps, { + useQuotes, + transformEmptyString: true, + escapeWhitespace, + style: styleObject, + }); + } + + return getObjectInspector(grip, serviceContainer, objectInspectorProps); +} + +function shouldAutoExpandObjectInspector(props) { + const { helperType, type } = props; + + return type === MESSAGE_TYPE.DIR || helperType === JSTERM_COMMANDS.INSPECT; +} + +module.exports = GripMessageBody; diff --git a/devtools/client/webconsole/components/Output/LazyMessageList.js b/devtools/client/webconsole/components/Output/LazyMessageList.js new file mode 100644 index 0000000000..931b5bb8bd --- /dev/null +++ b/devtools/client/webconsole/components/Output/LazyMessageList.js @@ -0,0 +1,393 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * This file incorporates work covered by the following copyright and + * permission notice: + * + * MIT License + * + * Copyright (c) 2019 Oleg Grishechkin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +"use strict"; + +const { + Fragment, + Component, + createElement, + createRef, +} = require("resource://devtools/client/shared/vendor/react.js"); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); + +// This element is a webconsole optimization for handling large numbers of +// console messages. The purpose is to only create DOM elements for messages +// which are actually visible within the scrollport. This code was based on +// Oleg Grishechkin's react-viewport-list element - however, it has been quite +// heavily modified, to the point that it is mostly unrecognizable. The most +// notable behavioral modification is that the list implements the behavior of +// pinning the scrollport to the bottom of the scroll container. +class LazyMessageList extends Component { + static get propTypes() { + return { + viewportRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }) + .isRequired, + items: PropTypes.array.isRequired, + itemsToKeepAlive: PropTypes.shape({ + has: PropTypes.func, + keys: PropTypes.func, + size: PropTypes.number, + }).isRequired, + editorMode: PropTypes.bool.isRequired, + itemDefaultHeight: PropTypes.number.isRequired, + scrollOverdrawCount: PropTypes.number.isRequired, + renderItem: PropTypes.func.isRequired, + shouldScrollBottom: PropTypes.func.isRequired, + cacheGeneration: PropTypes.number.isRequired, + serviceContainer: PropTypes.shape({ + emitForTests: PropTypes.func.isRequired, + }), + }; + } + + constructor(props) { + super(props); + this.#initialized = false; + this.#topBufferRef = createRef(); + this.#bottomBufferRef = createRef(); + this.#viewportHeight = window.innerHeight; + this.#startIndex = 0; + this.#resizeObserver = null; + this.#cachedHeights = []; + + this.#scrollHandlerBinding = this.#scrollHandler.bind(this); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps, nextState) { + if (nextProps.cacheGeneration !== this.props.cacheGeneration) { + this.#cachedHeights = []; + this.#startIndex = 0; + } else if ( + (this.props.shouldScrollBottom() && + nextProps.items.length > this.props.items.length) || + this.#startIndex > nextProps.items.length - this.#numItemsToDraw + ) { + this.#startIndex = Math.max( + 0, + nextProps.items.length - this.#numItemsToDraw + ); + } + } + + componentDidUpdate(prevProps) { + const { viewportRef, serviceContainer } = this.props; + if (!viewportRef.current || !this.#topBufferRef.current) { + return; + } + + if (!this.#initialized) { + // We set these up from a one-time call in componentDidUpdate, rather than in + // componentDidMount, because we need the parent to be mounted first, to add + // listeners to it, and React orders things such that children mount before + // parents. + this.#addListeners(); + } + + if (!this.#initialized || prevProps.editorMode !== this.props.editorMode) { + this.#resizeObserver.observe(viewportRef.current); + } + + this.#initialized = true; + + // Since we updated, we're now going to compute the heights of all visible + // elements and store them in a cache. This allows us to get more accurate + // buffer regions to make scrolling correct when these elements no longer + // exist. + let index = this.#startIndex; + let element = this.#topBufferRef.current.nextSibling; + let elementRect = element?.getBoundingClientRect(); + while ( + Element.isInstance(element) && + index < this.#clampedEndIndex && + element !== this.#bottomBufferRef.current + ) { + const next = element.nextSibling; + const nextRect = next.getBoundingClientRect(); + this.#cachedHeights[index] = nextRect.top - elementRect.top; + element = next; + elementRect = nextRect; + index++; + } + + serviceContainer.emitForTests("lazy-message-list-updated-or-noop"); + } + + componentWillUnmount() { + this.#removeListeners(); + } + + #initialized; + #topBufferRef; + #bottomBufferRef; + #viewportHeight; + #startIndex; + #resizeObserver; + #cachedHeights; + #scrollHandlerBinding; + + get #maxIndex() { + return this.props.items.length - 1; + } + + get #overdrawHeight() { + return this.props.scrollOverdrawCount * this.props.itemDefaultHeight; + } + + get #numItemsToDraw() { + const scrollingWindowCount = Math.ceil( + this.#viewportHeight / this.props.itemDefaultHeight + ); + return scrollingWindowCount + 2 * this.props.scrollOverdrawCount; + } + + get #unclampedEndIndex() { + return this.#startIndex + this.#numItemsToDraw; + } + + // Since the "end index" is computed based off a fixed offset from the start + // index, it can exceed the length of our items array. This is just a helper + // to ensure we don't exceed that. + get #clampedEndIndex() { + return Math.min(this.#unclampedEndIndex, this.props.items.length); + } + + /** + * Increases our start index until we've passed enough elements to cover + * the difference in px between where we are and where we want to be. + * + * @param Number startIndex + * The current value of our start index. + * @param Number deltaPx + * The difference in pixels between where we want to be and + * where we are. + * @return {Number} The new computed start index. + */ + #increaseStartIndex(startIndex, deltaPx) { + for (let i = startIndex + 1; i < this.props.items.length; i++) { + deltaPx -= this.#cachedHeights[i]; + startIndex = i; + + if (deltaPx <= 0) { + break; + } + } + return startIndex; + } + + /** + * Decreases our start index until we've passed enough elements to cover + * the difference in px between where we are and where we want to be. + * + * @param Number startIndex + * The current value of our start index. + * @param Number deltaPx + * The difference in pixels between where we want to be and + * where we are. + * @return {Number} The new computed start index. + */ + #decreaseStartIndex(startIndex, diff) { + for (let i = startIndex - 1; i >= 0; i--) { + diff -= this.#cachedHeights[i]; + startIndex = i; + + if (diff <= 0) { + break; + } + } + return startIndex; + } + + #scrollHandler() { + if (!this.props.viewportRef.current || !this.#topBufferRef.current) { + return; + } + + const scrollportMin = + this.props.viewportRef.current.getBoundingClientRect().top - + this.#overdrawHeight; + const uppermostItemRect = + this.#topBufferRef.current.nextSibling.getBoundingClientRect(); + const uppermostItemMin = uppermostItemRect.top; + const uppermostItemMax = uppermostItemRect.bottom; + + let nextStartIndex = this.#startIndex; + const downwardPx = scrollportMin - uppermostItemMax; + const upwardPx = uppermostItemMin - scrollportMin; + if (downwardPx > 0) { + nextStartIndex = this.#increaseStartIndex(nextStartIndex, downwardPx); + } else if (upwardPx > 0) { + nextStartIndex = this.#decreaseStartIndex(nextStartIndex, upwardPx); + } + + nextStartIndex = Math.max( + 0, + Math.min(nextStartIndex, this.props.items.length - this.#numItemsToDraw) + ); + + if (nextStartIndex !== this.#startIndex) { + this.#startIndex = nextStartIndex; + this.forceUpdate(); + } else { + const { serviceContainer } = this.props; + serviceContainer.emitForTests("lazy-message-list-updated-or-noop"); + } + } + + #addListeners() { + const { viewportRef } = this.props; + viewportRef.current.addEventListener("scroll", this.#scrollHandlerBinding); + this.#resizeObserver = new ResizeObserver(entries => { + this.#viewportHeight = + viewportRef.current.parentNode.parentNode.clientHeight; + this.forceUpdate(); + }); + } + + #removeListeners() { + const { viewportRef } = this.props; + this.#resizeObserver?.disconnect(); + viewportRef.current?.removeEventListener( + "scroll", + this.#scrollHandlerBinding + ); + } + + get bottomBuffer() { + return this.#bottomBufferRef.current; + } + + isItemNearBottom(index) { + return index >= this.props.items.length - this.#numItemsToDraw; + } + + render() { + const { items, itemDefaultHeight, renderItem, itemsToKeepAlive } = + this.props; + if (!items.length) { + return createElement(Fragment, { + key: "LazyMessageList", + }); + } + + // Resize our cached heights to fit if necessary. + const countUncached = items.length - this.#cachedHeights.length; + if (countUncached > 0) { + // It would be lovely if javascript allowed us to resize an array in one + // go. I think this is the closest we can get to that. This in theory + // allows us to realloc, and doesn't require copying the whole original + // array like concat does. + this.#cachedHeights.push(...Array(countUncached).fill(itemDefaultHeight)); + } + + let topBufferHeight = 0; + let bottomBufferHeight = 0; + // We can't compute the bottom buffer height until the end, so we just + // store the index of where it needs to go. + let bottomBufferIndex = 0; + let currentChild = 0; + const startIndex = this.#startIndex; + const endIndex = this.#clampedEndIndex; + // We preallocate this array to avoid allocations in the loop. The minimum, + // and typical length for it is the size of the body plus 2 for the top and + // bottom buffers. It can be bigger due to itemsToKeepAlive, but we can't just + // add the size, since itemsToKeepAlive could in theory hold items which are + // not even in the list. + const children = new Array(endIndex - startIndex + 2); + const pushChild = c => { + if (currentChild >= children.length) { + children.push(c); + } else { + children[currentChild] = c; + } + return currentChild++; + }; + for (let i = 0; i < items.length; i++) { + const itemId = items[i]; + if (i < startIndex) { + if (i == 0 || itemsToKeepAlive.has(itemId)) { + // If this is our first item, and we wouldn't otherwise be rendering + // it, we want to ensure that it's at the beginning of our children + // array to ensure keyboard navigation functions properly. + pushChild(renderItem(itemId, i)); + } else { + topBufferHeight += this.#cachedHeights[i]; + } + } else if (i < endIndex) { + if (i == startIndex) { + pushChild( + createElement("div", { + key: "LazyMessageListTop", + className: "lazy-message-list-top", + ref: this.#topBufferRef, + style: { height: topBufferHeight }, + }) + ); + } + pushChild(renderItem(itemId, i)); + if (i == endIndex - 1) { + // We're just reserving the bottom buffer's spot in the children + // array here. We will create the actual element and assign it at + // this index after the loop. + bottomBufferIndex = pushChild(null); + } + } else if (i == items.length - 1 || itemsToKeepAlive.has(itemId)) { + // Similarly to the logic for our first item, we also want to ensure + // that our last item is always rendered as the last item in our + // children array. + pushChild(renderItem(itemId, i)); + } else { + bottomBufferHeight += this.#cachedHeights[i]; + } + } + + children[bottomBufferIndex] = createElement("div", { + key: "LazyMessageListBottom", + className: "lazy-message-list-bottom", + ref: this.#bottomBufferRef, + style: { height: bottomBufferHeight }, + }); + + return createElement( + Fragment, + { + key: "LazyMessageList", + }, + children + ); + } +} + +module.exports = LazyMessageList; diff --git a/devtools/client/webconsole/components/Output/Message.js b/devtools/client/webconsole/components/Output/Message.js new file mode 100644 index 0000000000..ee65c8947a --- /dev/null +++ b/devtools/client/webconsole/components/Output/Message.js @@ -0,0 +1,482 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + Component, + createFactory, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); +const { + MESSAGE_LEVEL, + MESSAGE_SOURCE, + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); +const { + MessageIndent, +} = require("resource://devtools/client/webconsole/components/Output/MessageIndent.js"); +const MessageIcon = require("resource://devtools/client/webconsole/components/Output/MessageIcon.js"); +const FrameView = createFactory( + require("resource://devtools/client/shared/components/Frame.js") +); + +loader.lazyRequireGetter( + this, + "CollapseButton", + "resource://devtools/client/webconsole/components/Output/CollapseButton.js" +); +loader.lazyRequireGetter( + this, + "MessageRepeat", + "resource://devtools/client/webconsole/components/Output/MessageRepeat.js" +); +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +loader.lazyRequireGetter( + this, + "SmartTrace", + "resource://devtools/client/shared/components/SmartTrace.js" +); + +class Message extends Component { + static get propTypes() { + return { + open: PropTypes.bool, + collapsible: PropTypes.bool, + collapseTitle: PropTypes.string, + disabled: PropTypes.bool, + onToggle: PropTypes.func, + source: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + level: PropTypes.string.isRequired, + indent: PropTypes.number.isRequired, + inWarningGroup: PropTypes.bool, + isBlockedNetworkMessage: PropTypes.bool, + topLevelClasses: PropTypes.array.isRequired, + messageBody: PropTypes.any.isRequired, + repeat: PropTypes.any, + frame: PropTypes.any, + attachment: PropTypes.any, + stacktrace: PropTypes.any, + messageId: PropTypes.string, + scrollToMessage: PropTypes.bool, + exceptionDocURL: PropTypes.string, + request: PropTypes.object, + dispatch: PropTypes.func, + timeStamp: PropTypes.number, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.shape({ + emitForTests: PropTypes.func.isRequired, + onViewSource: PropTypes.func.isRequired, + onViewSourceInDebugger: PropTypes.func, + onViewSourceInStyleEditor: PropTypes.func, + openContextMenu: PropTypes.func.isRequired, + openLink: PropTypes.func.isRequired, + sourceMapURLService: PropTypes.any, + preventStacktraceInitialRenderDelay: PropTypes.bool, + }), + notes: PropTypes.arrayOf( + PropTypes.shape({ + messageBody: PropTypes.string.isRequired, + frame: PropTypes.any, + }) + ), + maybeScrollToBottom: PropTypes.func, + message: PropTypes.object.isRequired, + }; + } + + static get defaultProps() { + return { + indent: 0, + }; + } + + constructor(props) { + super(props); + this.onLearnMoreClick = this.onLearnMoreClick.bind(this); + this.toggleMessage = this.toggleMessage.bind(this); + this.onContextMenu = this.onContextMenu.bind(this); + this.renderIcon = this.renderIcon.bind(this); + } + + componentDidMount() { + if (this.messageNode) { + if (this.props.scrollToMessage) { + this.messageNode.scrollIntoView(); + } + + this.emitNewMessage(this.messageNode); + } + } + + componentDidCatch(e) { + this.setState({ error: e }); + } + + // Event used in tests. Some message types don't pass it in because existing tests + // did not emit for them. + emitNewMessage(node) { + const { serviceContainer, messageId, timeStamp } = this.props; + serviceContainer.emitForTests( + "new-messages", + new Set([{ node, messageId, timeStamp }]) + ); + } + + onLearnMoreClick(e) { + const { exceptionDocURL } = this.props; + this.props.serviceContainer.openLink(exceptionDocURL, e); + e.preventDefault(); + } + + toggleMessage(e) { + // Don't bubble up to the main App component, which redirects focus to input, + // making difficult for screen reader users to review output + e.stopPropagation(); + const { open, dispatch, messageId, onToggle, disabled } = this.props; + + if (disabled) { + return; + } + + // Early exit the function to avoid the message to collapse if the user is + // selecting a range in the toggle message. + const window = e.target.ownerDocument.defaultView; + if (window.getSelection && window.getSelection().type === "Range") { + return; + } + + // If defined on props, we let the onToggle() method handle the toggling, + // otherwise we toggle the message open/closed ourselves. + if (onToggle) { + onToggle(messageId, e); + } else if (open) { + dispatch(actions.messageClose(messageId)); + } else { + dispatch(actions.messageOpen(messageId)); + } + } + + onContextMenu(e) { + const { serviceContainer, source, request, messageId } = this.props; + const messageInfo = { + source, + request, + messageId, + }; + serviceContainer.openContextMenu(e, messageInfo); + e.stopPropagation(); + e.preventDefault(); + } + + renderIcon() { + const { level, inWarningGroup, isBlockedNetworkMessage, type, disabled } = + this.props; + + if (inWarningGroup) { + return undefined; + } + + if (disabled) { + return MessageIcon({ + level: MESSAGE_LEVEL.INFO, + type, + title: l10n.getStr("webconsole.disableIcon.title"), + }); + } + + if (isBlockedNetworkMessage) { + return MessageIcon({ + level: MESSAGE_LEVEL.ERROR, + type: "blockedReason", + }); + } + + return MessageIcon({ + level, + type, + }); + } + + renderTimestamp() { + if (!this.props.timestampsVisible) { + return null; + } + + return dom.span( + { + className: "timestamp devtools-monospace", + }, + l10n.timestampString(this.props.timeStamp || Date.now()) + ); + } + + renderErrorState() { + const newBugUrl = + "https://bugzilla.mozilla.org/enter_bug.cgi?product=DevTools&component=Console"; + const timestampEl = this.renderTimestamp(); + + return dom.div( + { + className: "message error message-did-catch", + }, + timestampEl, + MessageIcon({ level: "error" }), + dom.span( + { className: "message-body-wrapper" }, + dom.span( + { + className: "message-flex-body", + }, + // Add whitespaces for formatting when copying to the clipboard. + timestampEl ? " " : null, + dom.span( + { className: "message-body devtools-monospace" }, + l10n.getFormatStr("webconsole.message.componentDidCatch.label", [ + newBugUrl, + ]), + dom.button( + { + className: "devtools-button", + onClick: () => + navigator.clipboard.writeText( + JSON.stringify( + this.props.message, + function (key, value) { + // The message can hold one or multiple fronts that we need to serialize + if (value?.getGrip) { + return value.getGrip(); + } + return value; + }, + 2 + ) + ), + }, + l10n.getStr( + "webconsole.message.componentDidCatch.copyButton.label" + ) + ) + ) + ) + ), + dom.br() + ); + } + + // eslint-disable-next-line complexity + render() { + if (this.state && this.state.error) { + return this.renderErrorState(); + } + + const { + open, + collapsible, + collapseTitle, + disabled, + source, + type, + level, + indent, + inWarningGroup, + topLevelClasses, + messageBody, + frame, + stacktrace, + serviceContainer, + exceptionDocURL, + messageId, + notes, + } = this.props; + + topLevelClasses.push("message", source, type, level); + if (open) { + topLevelClasses.push("open"); + } + + if (disabled) { + topLevelClasses.push("disabled"); + } + + const timestampEl = this.renderTimestamp(); + const icon = this.renderIcon(); + + // Figure out if there is an expandable part to the message. + let attachment = null; + if (this.props.attachment) { + attachment = this.props.attachment; + } else if (stacktrace && open) { + const smartTraceAttributes = { + stacktrace, + onViewSourceInDebugger: + serviceContainer.onViewSourceInDebugger || + serviceContainer.onViewSource, + onViewSource: serviceContainer.onViewSource, + onReady: this.props.maybeScrollToBottom, + sourceMapURLService: serviceContainer.sourceMapURLService, + }; + + if (serviceContainer.preventStacktraceInitialRenderDelay) { + smartTraceAttributes.initialRenderDelay = 0; + } + + attachment = dom.div( + { + className: "stacktrace devtools-monospace", + }, + createElement(SmartTrace, smartTraceAttributes) + ); + } + + // If there is an expandable part, make it collapsible. + let collapse = null; + if (collapsible && !disabled) { + collapse = createElement(CollapseButton, { + open, + title: collapseTitle, + onClick: this.toggleMessage, + }); + } + + let notesNodes; + if (notes) { + notesNodes = notes.map(note => + dom.span( + { className: "message-flex-body error-note" }, + dom.span( + { className: "message-body devtools-monospace" }, + "note: " + note.messageBody + ), + dom.span( + { className: "message-location devtools-monospace" }, + note.frame + ? FrameView({ + frame: note.frame, + onClick: serviceContainer + ? serviceContainer.onViewSourceInDebugger || + serviceContainer.onViewSource + : undefined, + showEmptyPathAsHost: true, + sourceMapURLService: serviceContainer + ? serviceContainer.sourceMapURLService + : undefined, + }) + : null + ) + ) + ); + } else { + notesNodes = []; + } + + const repeat = + this.props.repeat && this.props.repeat > 1 + ? createElement(MessageRepeat, { repeat: this.props.repeat }) + : null; + + let onFrameClick; + if (serviceContainer && frame) { + if (source === MESSAGE_SOURCE.CSS) { + onFrameClick = + serviceContainer.onViewSourceInStyleEditor || + serviceContainer.onViewSource; + } else { + // Point everything else to debugger, if source not available, + // it will fall back to view-source. + onFrameClick = + serviceContainer.onViewSourceInDebugger || + serviceContainer.onViewSource; + } + } + + // Configure the location. + const location = frame + ? FrameView({ + className: "message-location devtools-monospace", + frame, + onClick: onFrameClick, + showEmptyPathAsHost: true, + sourceMapURLService: serviceContainer + ? serviceContainer.sourceMapURLService + : undefined, + messageSource: source, + }) + : null; + + let learnMore; + if (exceptionDocURL) { + learnMore = dom.a( + { + className: "learn-more-link webconsole-learn-more-link", + href: exceptionDocURL, + title: exceptionDocURL.split("?")[0], + onClick: this.onLearnMoreClick, + }, + `[${l10n.getStr("webConsoleMoreInfoLabel")}]` + ); + } + + const bodyElements = Array.isArray(messageBody) + ? messageBody + : [messageBody]; + + return dom.div( + { + className: topLevelClasses.join(" "), + onContextMenu: this.onContextMenu, + ref: node => { + this.messageNode = node; + }, + "data-message-id": messageId, + "data-indent": indent || 0, + "aria-live": type === MESSAGE_TYPE.COMMAND ? "off" : "polite", + }, + timestampEl, + MessageIndent({ + indent, + inWarningGroup, + }), + this.props.isBlockedNetworkMessage ? collapse : icon, + this.props.isBlockedNetworkMessage ? icon : collapse, + dom.span( + { className: "message-body-wrapper" }, + dom.span( + { + className: "message-flex-body", + onClick: collapsible ? this.toggleMessage : undefined, + }, + // Add whitespaces for formatting when copying to the clipboard. + timestampEl ? " " : null, + dom.span( + { className: "message-body devtools-monospace" }, + ...bodyElements, + learnMore + ), + repeat ? " " : null, + repeat, + " ", + location + ), + attachment, + ...notesNodes + ), + // If an attachment is displayed, the final newline is handled by the attachment. + attachment ? null : dom.br() + ); + } +} + +module.exports = Message; diff --git a/devtools/client/webconsole/components/Output/MessageContainer.js b/devtools/client/webconsole/components/Output/MessageContainer.js new file mode 100644 index 0000000000..f1140e80c3 --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageContainer.js @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); +loader.lazyRequireGetter( + this, + "isWarningGroup", + "resource://devtools/client/webconsole/utils/messages.js", + true +); + +const { + MESSAGE_SOURCE, + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const ConsoleApiCall = require("resource://devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js"); +const ConsoleCommand = require("resource://devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js"); +const CSSWarning = require("resource://devtools/client/webconsole/components/Output/message-types/CSSWarning.js"); +const DefaultRenderer = require("resource://devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js"); +const EvaluationResult = require("resource://devtools/client/webconsole/components/Output/message-types/EvaluationResult.js"); +const NavigationMarker = require("resource://devtools/client/webconsole/components/Output/message-types/NavigationMarker.js"); +const NetworkEventMessage = require("resource://devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js"); +const PageError = require("resource://devtools/client/webconsole/components/Output/message-types/PageError.js"); +const SimpleTable = require("resource://devtools/client/webconsole/components/Output/message-types/SimpleTable.js"); +const JSTracerTrace = require("resource://devtools/client/webconsole/components/Output/message-types/JSTracerTrace.js"); +const WarningGroup = require("resource://devtools/client/webconsole/components/Output/message-types/WarningGroup.js"); + +class MessageContainer extends Component { + static get propTypes() { + return { + messageId: PropTypes.string.isRequired, + open: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object.isRequired, + cssMatchingElements: PropTypes.object, + timestampsVisible: PropTypes.bool.isRequired, + repeat: PropTypes.number, + badge: PropTypes.number, + indent: PropTypes.number, + networkMessageUpdate: PropTypes.object, + getMessage: PropTypes.func.isRequired, + inWarningGroup: PropTypes.bool, + disabled: PropTypes.bool, + }; + } + + shouldComponentUpdate(nextProps) { + const triggeringUpdateProps = [ + "repeat", + "open", + "cssMatchingElements", + "timestampsVisible", + "networkMessageUpdate", + "badge", + "inWarningGroup", + "disabled", + ]; + + return triggeringUpdateProps.some( + prop => this.props[prop] !== nextProps[prop] + ); + } + + render() { + const message = this.props.getMessage(); + + const MessageComponent = getMessageComponent(message); + return MessageComponent(Object.assign({ message }, this.props)); + } +} + +function getMessageComponent(message) { + if (!message) { + return DefaultRenderer; + } + + switch (message.source) { + case MESSAGE_SOURCE.CONSOLE_API: + return ConsoleApiCall; + case MESSAGE_SOURCE.JSTRACER: + return JSTracerTrace; + case MESSAGE_SOURCE.NETWORK: + return NetworkEventMessage; + case MESSAGE_SOURCE.CSS: + return CSSWarning; + case MESSAGE_SOURCE.JAVASCRIPT: + switch (message.type) { + case MESSAGE_TYPE.COMMAND: + return ConsoleCommand; + case MESSAGE_TYPE.RESULT: + return EvaluationResult; + // @TODO this is probably not the right behavior, but works for now. + // Chrome doesn't distinguish between page errors and log messages. We + // may want to remove the PageError component and just handle errors + // with ConsoleApiCall. + case MESSAGE_TYPE.LOG: + return PageError; + default: + return DefaultRenderer; + } + case MESSAGE_SOURCE.CONSOLE_FRONTEND: + if (isWarningGroup(message)) { + return WarningGroup; + } + if (message.type === MESSAGE_TYPE.SIMPLE_TABLE) { + return SimpleTable; + } + if (message.type === MESSAGE_TYPE.NAVIGATION_MARKER) { + return NavigationMarker; + } + break; + } + + return DefaultRenderer; +} + +module.exports.MessageContainer = MessageContainer; + +// Exported so we can test it with unit tests. +module.exports.getMessageComponent = getMessageComponent; diff --git a/devtools/client/webconsole/components/Output/MessageIcon.js b/devtools/client/webconsole/components/Output/MessageIcon.js new file mode 100644 index 0000000000..ed2765414e --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageIcon.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const { + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const l10nLevels = { + error: "level.error", + warn: "level.warn", + info: "level.info", + log: "level.log", + debug: "level.debug", + jstracer: "level.jstracer", +}; + +// Store common icons so they can be used without recreating the element +// during render. +const CONSTANT_ICONS = Object.entries(l10nLevels).reduce( + (acc, [key, l10nLabel]) => { + acc[key] = getIconElement(l10nLabel); + return acc; + }, + {} +); + +function getIconElement(level, type, title) { + title = title || l10n.getStr(l10nLevels[level] || level); + const classnames = ["icon"]; + + if (type === "logPoint") { + title = l10n.getStr("logpoint.title"); + classnames.push("logpoint"); + } else if (type === "blockedReason") { + title = l10n.getStr("blockedrequest.label"); + } else if (type === MESSAGE_TYPE.COMMAND) { + title = l10n.getStr("command.title"); + } else if (type === MESSAGE_TYPE.RESULT) { + title = l10n.getStr("result.title"); + } else if (level == "level.jstracer" || type == MESSAGE_TYPE.JSTRACER) { + // Actual traces uses JSTracerTrace objects and `level` attribute, + // while log messages relating to tracing uses ConsoleApiCall, where level == "log" and so rather uses `type` attribute. + classnames.push("logtrace"); + } + + return dom.span({ + className: classnames.join(" "), + title, + "aria-live": "off", + }); +} + +MessageIcon.displayName = "MessageIcon"; +MessageIcon.propTypes = { + level: PropTypes.string.isRequired, + type: PropTypes.string, + title: PropTypes.string, +}; + +function MessageIcon(props) { + const { level, type, title } = props; + + if (type) { + return getIconElement(level, type, title); + } + + return CONSTANT_ICONS[level] || getIconElement(level); +} + +module.exports = MessageIcon; diff --git a/devtools/client/webconsole/components/Output/MessageIndent.js b/devtools/client/webconsole/components/Output/MessageIndent.js new file mode 100644 index 0000000000..cb77c5ef8c --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageIndent.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const INDENT_WIDTH = 12; + +// Store common indents so they can be used without recreating the element during render. +const CONSTANT_INDENTS = [getIndentElement(1)]; +const IN_WARNING_GROUP_INDENT = getIndentElement(1, "warning-indent"); + +function getIndentElement(indent, className) { + return dom.span({ + className: `indent${className ? " " + className : ""}`, + style: { + width: indent * INDENT_WIDTH, + }, + }); +} + +function MessageIndent(props) { + const { indent, inWarningGroup } = props; + + if (inWarningGroup) { + return IN_WARNING_GROUP_INDENT; + } + + if (!indent) { + return null; + } + + return CONSTANT_INDENTS[indent] || getIndentElement(indent); +} + +MessageIndent.propTypes = { + indent: PropTypes.number, + inWarningGroup: PropTypes.bool, +}; + +module.exports.MessageIndent = MessageIndent; + +// Exported so we can test it with unit tests. +module.exports.INDENT_WIDTH = INDENT_WIDTH; diff --git a/devtools/client/webconsole/components/Output/MessageRepeat.js b/devtools/client/webconsole/components/Output/MessageRepeat.js new file mode 100644 index 0000000000..7bf846bcb0 --- /dev/null +++ b/devtools/client/webconsole/components/Output/MessageRepeat.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const messageRepeatsTooltip = l10n.getStr("messageRepeats.tooltip2"); + +MessageRepeat.displayName = "MessageRepeat"; + +MessageRepeat.propTypes = { + repeat: PropTypes.number.isRequired, +}; + +function MessageRepeat(props) { + const { repeat } = props; + return dom.span( + { + className: "message-repeats", + title: PluralForm.get(repeat, messageRepeatsTooltip).replace( + "#1", + repeat + ), + }, + repeat + ); +} + +module.exports = MessageRepeat; diff --git a/devtools/client/webconsole/components/Output/message-types/CSSWarning.js b/devtools/client/webconsole/components/Output/message-types/CSSWarning.js new file mode 100644 index 0000000000..cef91c22be --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/CSSWarning.js @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +loader.lazyRequireGetter( + this, + "GripMessageBody", + "resource://devtools/client/webconsole/components/Output/GripMessageBody.js" +); + +/** + * This component is responsible for rendering CSS warnings in the Console panel. + * + * CSS warnings are expandable when they have associated CSS selectors so the + * user can inspect any matching DOM elements. Not all CSS warnings have + * associated selectors (those that don't are not expandable) and not all + * selectors match elements in the current page (warnings can appear for styles + * which don't apply to the current page). + * + * @extends Component + */ +class CSSWarning extends Component { + static get propTypes() { + return { + dispatch: PropTypes.func.isRequired, + inWarningGroup: PropTypes.bool.isRequired, + message: PropTypes.object.isRequired, + open: PropTypes.bool, + cssMatchingElements: PropTypes.object, + repeat: PropTypes.any, + serviceContainer: PropTypes.object, + timestampsVisible: PropTypes.bool.isRequired, + setExpanded: PropTypes.func, + }; + } + + static get defaultProps() { + return { + open: false, + }; + } + + static get displayName() { + return "CSSWarning"; + } + + constructor(props) { + super(props); + this.onToggle = this.onToggle.bind(this); + } + + onToggle(messageId) { + const { dispatch, message, cssMatchingElements, open } = this.props; + + if (open) { + dispatch(actions.messageClose(messageId)); + } else if (cssMatchingElements) { + // If the message already has information about the elements matching + // the selectors associated with this CSS warning, just open the message. + dispatch(actions.messageOpen(messageId)); + } else { + // Query the server for elements matching the CSS selectors associated + // with this CSS warning and populate the message's additional cssMatchingElements with + // the result. It's an async operation and potentially expensive, so we only do it + // on demand, once, when the component is first expanded. + dispatch(actions.messageGetMatchingElements(message)); + dispatch(actions.messageOpen(messageId)); + } + } + + render() { + const { + dispatch, + message, + open, + cssMatchingElements, + repeat, + serviceContainer, + timestampsVisible, + inWarningGroup, + setExpanded, + } = this.props; + + const { + id: messageId, + indent, + cssSelectors, + source, + type, + level, + messageText, + frame, + exceptionDocURL, + timeStamp, + notes, + } = message; + + let messageBody; + if (typeof messageText === "string") { + messageBody = messageText; + } else if ( + typeof messageText === "object" && + messageText.type === "longString" + ) { + messageBody = `${message.messageText.initial}…`; + } + + // Create a message attachment only when the message is open and there is a result + // to the query for elements matching the CSS selectors associated with the message. + const attachment = + open && + cssMatchingElements !== undefined && + dom.div( + { className: "devtools-monospace" }, + dom.div( + { className: "elements-label" }, + l10n.getFormatStr("webconsole.cssWarningElements.label", [ + cssSelectors, + ]) + ), + GripMessageBody({ + dispatch, + escapeWhitespace: false, + grip: cssMatchingElements, + serviceContainer, + setExpanded, + }) + ); + + return Message({ + attachment, + collapsible: !!cssSelectors.length, + dispatch, + exceptionDocURL, + frame, + indent, + inWarningGroup, + level, + messageBody, + messageId, + notes, + open, + onToggle: this.onToggle, + repeat, + serviceContainer, + source, + timeStamp, + timestampsVisible, + topLevelClasses: [], + type, + message, + }); + } +} + +module.exports = createFactory(CSSWarning); diff --git a/devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js b/devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js new file mode 100644 index 0000000000..155075731f --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/ConsoleApiCall.js @@ -0,0 +1,221 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const GripMessageBody = require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js"); +const ConsoleTable = createFactory( + require("resource://devtools/client/webconsole/components/Output/ConsoleTable.js") +); +const { + isGroupType, + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +ConsoleApiCall.displayName = "ConsoleApiCall"; + +ConsoleApiCall.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + open: PropTypes.bool, + serviceContainer: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + maybeScrollToBottom: PropTypes.func, +}; + +ConsoleApiCall.defaultProps = { + open: false, +}; + +function ConsoleApiCall(props) { + const { + dispatch, + message, + open, + serviceContainer, + timestampsVisible, + repeat, + maybeScrollToBottom, + setExpanded, + } = props; + const { + id: messageId, + indent, + source, + type, + level, + stacktrace, + frame, + timeStamp, + parameters, + messageText, + prefix, + userProvidedStyles, + } = message; + + let messageBody; + const messageBodyConfig = { + dispatch, + messageId, + parameters, + userProvidedStyles, + serviceContainer, + type, + maybeScrollToBottom, + setExpanded, + // When the object is a parameter of a console.dir call, we always want to show its + // properties, like regular object (i.e. not showing the DOM tree for an Element, or + // only showing the message + stacktrace for Error object). + customFormat: type !== "dir", + }; + + if (type === "trace") { + const traceParametersBody = + Array.isArray(parameters) && parameters.length + ? [" "].concat(formatReps(messageBodyConfig)) + : []; + + messageBody = [ + dom.span({ className: "cm-variable" }, "console.trace()"), + ...traceParametersBody, + ]; + } else if (type === "assert") { + const reps = formatReps(messageBodyConfig); + messageBody = dom.span({}, "Assertion failed: ", reps); + } else if (type === "table") { + // TODO: Chrome does not output anything, see if we want to keep this + messageBody = dom.span({ className: "cm-variable" }, "console.table()"); + } else if (parameters) { + messageBody = formatReps(messageBodyConfig); + if (prefix) { + messageBody.unshift( + dom.span( + { + className: "console-message-prefix", + }, + `${prefix}: ` + ) + ); + } + } else if (typeof messageText === "string") { + messageBody = messageText; + } else if (messageText) { + messageBody = GripMessageBody({ + dispatch, + messageId, + grip: messageText, + serviceContainer, + useQuotes: false, + transformEmptyString: true, + setExpanded, + type, + }); + } + + let attachment = null; + if (type === "table") { + attachment = ConsoleTable({ + dispatch, + id: message.id, + serviceContainer, + parameters: message.parameters, + }); + } + + let collapseTitle = null; + if (isGroupType(type)) { + collapseTitle = l10n.getStr("groupToggle"); + } + + const collapsible = + isGroupType(type) || (type === "error" && Array.isArray(stacktrace)); + const topLevelClasses = ["cm-s-mozilla"]; + + return Message({ + messageId, + open, + collapsible, + collapseTitle, + source, + type, + level, + topLevelClasses, + messageBody, + repeat, + frame, + stacktrace, + attachment, + serviceContainer, + dispatch, + indent, + timeStamp, + timestampsVisible, + parameters, + message, + maybeScrollToBottom, + }); +} + +function formatReps(options = {}) { + const { + dispatch, + loadedObjectProperties, + loadedObjectEntries, + messageId, + parameters, + serviceContainer, + userProvidedStyles, + type, + maybeScrollToBottom, + setExpanded, + customFormat, + } = options; + + const elements = []; + const parametersLength = parameters.length; + for (let i = 0; i < parametersLength; i++) { + elements.push( + GripMessageBody({ + dispatch, + messageId, + grip: parameters[i], + key: i, + userProvidedStyle: userProvidedStyles ? userProvidedStyles[i] : null, + serviceContainer, + useQuotes: false, + loadedObjectProperties, + loadedObjectEntries, + type, + maybeScrollToBottom, + setExpanded, + customFormat, + }) + ); + + // We need to interleave a space if we are not on the last element AND + // if we are not between 2 messages with user provided style. + if ( + i !== parametersLength - 1 && + (!userProvidedStyles || + userProvidedStyles[i] === undefined || + userProvidedStyles[i + 1] === undefined) + ) { + elements.push(" "); + } + } + + return elements; +} + +module.exports = ConsoleApiCall; diff --git a/devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js b/devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js new file mode 100644 index 0000000000..5cfb87113c --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/ConsoleCommand.js @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + createElement, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { ELLIPSIS } = require("resource://devtools/shared/l10n.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +ConsoleCommand.displayName = "ConsoleCommand"; + +ConsoleCommand.propTypes = { + message: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + maybeScrollToBottom: PropTypes.func, + open: PropTypes.bool, +}; + +ConsoleCommand.defaultProps = { + open: false, +}; + +/** + * Displays input from the console. + */ +function ConsoleCommand(props) { + const { + message, + timestampsVisible, + serviceContainer, + maybeScrollToBottom, + dispatch, + open, + } = props; + + const { indent, source, type, level, timeStamp, id: messageId } = message; + + const messageText = trimCode(message.messageText); + const messageLines = messageText.split("\n"); + + const collapsible = messageLines.length > 5; + + // Show only first 5 lines if its collapsible and closed + const visibleMessageText = + collapsible && !open + ? `${messageLines.slice(0, 5).join("\n")}${ELLIPSIS}` + : messageText; + + // This uses a Custom Element to syntax highlight when possible. If it's not + // (no CodeMirror editor), then it will just render text. + const messageBody = createElement( + "syntax-highlighted", + null, + visibleMessageText + ); + + // Enable collapsing the code if it has multiple lines + + return Message({ + messageId, + source, + type, + level, + topLevelClasses: [], + messageBody, + collapsible, + open, + dispatch, + serviceContainer, + indent, + timeStamp, + timestampsVisible, + maybeScrollToBottom, + message, + }); +} + +module.exports = ConsoleCommand; + +/** + * Trim user input to avoid blank lines before and after messages + */ +function trimCode(input) { + if (typeof input !== "string") { + return input; + } + + // Trim on both edges if we have a single line of content + if (input.trim().includes("\n") === false) { + return input.trim(); + } + + // For multiline input we want to keep the indentation of the first line + // with non-whitespace, so we can't .trim()/.trimStart(). + return input.replace(/^\s*\n/, "").trimEnd(); +} diff --git a/devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js b/devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js new file mode 100644 index 0000000000..893e6b04c6 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/DefaultRenderer.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +DefaultRenderer.displayName = "DefaultRenderer"; + +function DefaultRenderer(props) { + return dom.div({}, "This message type is not supported yet."); +} + +module.exports = DefaultRenderer; diff --git a/devtools/client/webconsole/components/Output/message-types/EvaluationResult.js b/devtools/client/webconsole/components/Output/message-types/EvaluationResult.js new file mode 100644 index 0000000000..60d44a9f99 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/EvaluationResult.js @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); +const GripMessageBody = require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js"); + +EvaluationResult.displayName = "EvaluationResult"; + +EvaluationResult.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + maybeScrollToBottom: PropTypes.func, + open: PropTypes.bool, +}; + +EvaluationResult.defaultProps = { + open: false, +}; + +function EvaluationResult(props) { + const { + dispatch, + message, + serviceContainer, + timestampsVisible, + maybeScrollToBottom, + open, + setExpanded, + } = props; + + const { + source, + type, + helperType, + level, + id: messageId, + indent, + hasException, + exceptionDocURL, + stacktrace, + frame, + timeStamp, + parameters, + notes, + } = message; + + let messageBody; + if ( + typeof message.messageText !== "undefined" && + message.messageText !== null + ) { + const messageText = message.messageText?.getGrip + ? message.messageText.getGrip() + : message.messageText; + if (typeof messageText === "string") { + messageBody = messageText; + } else if ( + typeof messageText === "object" && + messageText.type === "longString" + ) { + messageBody = `${messageText.initial}…`; + } + } else { + messageBody = []; + if (hasException) { + messageBody.push("Uncaught "); + } + messageBody.push( + GripMessageBody({ + dispatch, + messageId, + grip: parameters[0], + key: "grip", + serviceContainer, + useQuotes: !hasException, + escapeWhitespace: false, + type, + helperType, + maybeScrollToBottom, + setExpanded, + customFormat: true, + }) + ); + } + + const topLevelClasses = ["cm-s-mozilla"]; + + return Message({ + dispatch, + source, + type, + level, + indent, + topLevelClasses, + messageBody, + messageId, + serviceContainer, + exceptionDocURL, + stacktrace, + collapsible: Array.isArray(stacktrace), + open, + frame, + timeStamp, + parameters, + notes, + timestampsVisible, + maybeScrollToBottom, + message, + }); +} + +module.exports = EvaluationResult; diff --git a/devtools/client/webconsole/components/Output/message-types/JSTracerTrace.js b/devtools/client/webconsole/components/Output/message-types/JSTracerTrace.js new file mode 100644 index 0000000000..7d6ddd2623 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/JSTracerTrace.js @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const GripMessageBody = require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js"); + +const { + MESSAGE_TYPE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +JSTracerTrace.displayName = "JSTracerTrace"; + +JSTracerTrace.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + serviceContainer: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + maybeScrollToBottom: PropTypes.func, +}; + +function JSTracerTrace(props) { + const { + dispatch, + message, + serviceContainer, + timestampsVisible, + repeat, + maybeScrollToBottom, + setExpanded, + } = props; + + const { + // List of common attributes for all tracer messages + timeStamp, + prefix, + depth, + source, + + // Attribute specific to DOM event + eventName, + + // Attributes specific to function calls + frame, + implementation, + displayName, + parameters, + + // Attributes specific to function call returns + returnedValue, + relatedTraceId, + // See tracer.jsm FRAME_EXIT_REASONS + why, + } = message; + + // When we are logging a DOM event, we have the `eventName` defined. + let messageBody; + if (eventName) { + messageBody = [dom.span({ className: "jstracer-dom-event" }, eventName)]; + } else if (typeof relatedTraceId == "number") { + messageBody = [ + dom.span({ className: "jstracer-io" }, "⟵ "), + dom.span({ className: "jstracer-display-name" }, displayName), + ]; + } else { + messageBody = [ + dom.span({ className: "jstracer-io" }, "⟶ "), + dom.span({ className: "jstracer-implementation" }, implementation), + // Add a space in order to improve copy paste rendering + dom.span({ className: "jstracer-display-name" }, " " + displayName), + ]; + } + + let messageBodyConfig; + if (parameters || why) { + messageBodyConfig = { + dispatch, + serviceContainer, + maybeScrollToBottom, + setExpanded, + type: "", + useQuotes: true, + + // Disable custom formatter for now in traces + customFormat: false, + }; + } + // Arguments will only be passed on-demand + + if (parameters) { + messageBody.push("(", ...formatReps(messageBodyConfig, parameters), ")"); + } + // Returned value will also only be passed on-demand + if (why) { + messageBody.push( + // Add a spaces in order to improve copy paste rendering + dom.span({ className: "jstracer-exit-frame-reason" }, " " + why + " "), + formatRep(messageBodyConfig, returnedValue) + ); + } + + if (prefix) { + messageBody.unshift( + dom.span( + { + className: "console-message-prefix", + }, + `${prefix}` + ) + ); + } + + const topLevelClasses = ["cm-s-mozilla"]; + + return Message({ + collapsible: false, + source, + level: MESSAGE_TYPE.JSTRACER, + topLevelClasses, + messageBody, + repeat, + frame, + stacktrace: null, + attachment: null, + serviceContainer, + dispatch, + indent: depth, + timeStamp, + timestampsVisible, + parameters, + message, + maybeScrollToBottom, + }); +} + +/** + * Generated the list of GripMessageBody for a list of objects. + * GripMessageBody is Rep's rendering for a given Object, via its object actor's front. + */ +function formatReps(messageBodyConfig, objects) { + const elements = []; + const length = objects.length; + for (let i = 0; i < length; i++) { + elements.push(formatRep(messageBodyConfig, objects[i], i)); + + // We need to interleave a comma if we are not on the last element + if (i !== length - 1) { + elements.push(", "); + } + } + + return elements; +} + +function formatRep(messageBodyConfig, grip, key) { + return GripMessageBody({ + ...messageBodyConfig, + grip, + key, + }); +} + +module.exports = JSTracerTrace; diff --git a/devtools/client/webconsole/components/Output/message-types/NavigationMarker.js b/devtools/client/webconsole/components/Output/message-types/NavigationMarker.js new file mode 100644 index 0000000000..7d14206a6a --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/NavigationMarker.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +NavigationMarker.displayName = "NavigationMarker"; + +NavigationMarker.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + serviceContainer: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + maybeScrollToBottom: PropTypes.func, +}; + +function NavigationMarker(props) { + const { + dispatch, + message, + serviceContainer, + timestampsVisible, + maybeScrollToBottom, + } = props; + const { + id: messageId, + indent, + source, + type, + level, + timeStamp, + messageText, + } = message; + + return Message({ + messageId, + source, + type, + level, + messageBody: messageText, + serviceContainer, + dispatch, + indent, + timeStamp, + timestampsVisible, + topLevelClasses: [], + message, + maybeScrollToBottom, + }); +} + +module.exports = NavigationMarker; diff --git a/devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js b/devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js new file mode 100644 index 0000000000..ce0961668b --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/NetworkEventMessage.js @@ -0,0 +1,243 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + createFactory, + createElement, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); +const actions = require("resource://devtools/client/webconsole/actions/index.js"); +const { + isMessageNetworkError, + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); + +loader.lazyRequireGetter( + this, + "TabboxPanel", + "resource://devtools/client/netmonitor/src/components/TabboxPanel.js" +); +const { + getHTTPStatusCodeURL, +} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js"); +const { + getUnicodeUrl, +} = require("resource://devtools/client/shared/unicode-url.js"); +loader.lazyRequireGetter( + this, + "BLOCKED_REASON_MESSAGES", + "resource://devtools/client/netmonitor/src/constants.js", + true +); + +const LEARN_MORE = l10n.getStr("webConsoleMoreInfoLabel"); + +const isMacOS = Services.appinfo.OS === "Darwin"; + +NetworkEventMessage.displayName = "NetworkEventMessage"; + +NetworkEventMessage.propTypes = { + message: PropTypes.object.isRequired, + serviceContainer: PropTypes.shape({ + openNetworkPanel: PropTypes.func.isRequired, + resendNetworkRequest: PropTypes.func.isRequired, + }), + timestampsVisible: PropTypes.bool.isRequired, + networkMessageUpdate: PropTypes.object.isRequired, +}; + +/** + * This component is responsible for rendering network messages + * in the Console panel. + * + * Network logs are expandable and the user can inspect it inline + * within the Console panel (no need to switch to the Network panel). + * + * HTTP details are rendered using `TabboxPanel` component used to + * render contents of the side bar in the Network panel. + * + * All HTTP details data are fetched from the backend on-demand + * when the user is expanding network log for the first time. + */ +function NetworkEventMessage({ + message = {}, + serviceContainer, + timestampsVisible, + networkMessageUpdate = {}, + networkMessageActiveTabId, + dispatch, + open, + disabled, +}) { + const { + id, + indent, + source, + type, + level, + url, + method, + isXHR, + timeStamp, + blockedReason, + httpVersion, + status, + statusText, + totalTime, + } = message; + + const topLevelClasses = ["cm-s-mozilla"]; + if (isMessageNetworkError(message)) { + topLevelClasses.push("error"); + } + + let statusCode, statusInfo; + + if ( + httpVersion && + status && + statusText !== undefined && + totalTime !== undefined + ) { + const statusCodeDocURL = getHTTPStatusCodeURL( + status.toString(), + "webconsole" + ); + statusCode = dom.span( + { + className: "status-code", + "data-code": status, + title: LEARN_MORE, + onClick: e => { + e.stopPropagation(); + e.preventDefault(); + serviceContainer.openLink(statusCodeDocURL, e); + }, + }, + status + ); + statusInfo = dom.span( + { className: "status-info" }, + `[${httpVersion} `, + statusCode, + ` ${statusText} ${totalTime}ms]` + ); + } + + if (blockedReason) { + statusInfo = dom.span( + { className: "status-info" }, + BLOCKED_REASON_MESSAGES[blockedReason] + ); + topLevelClasses.push("network-message-blocked"); + } + + // Message body components. + const requestMethod = dom.span({ className: "method" }, method); + const xhr = isXHR + ? dom.span({ className: "xhr" }, l10n.getStr("webConsoleXhrIndicator")) + : null; + const unicodeURL = getUnicodeUrl(url); + const requestUrl = dom.a( + { + className: "url", + title: unicodeURL, + href: url, + onClick: e => { + // The href of the <a> is the actual URL, so we need to prevent the navigation + // within the console panel. + // We only want to handle Ctrl/Cmd + click to open the link in a new tab. + e.preventDefault(); + const shouldOpenLink = + (isMacOS && e.metaKey) || (!isMacOS && e.ctrlKey); + if (shouldOpenLink) { + e.stopPropagation(); + serviceContainer.openLink(url, e); + } + }, + }, + unicodeURL + ); + const statusBody = statusInfo + ? dom.a({ className: "status" }, statusInfo) + : null; + + const messageBody = [xhr, requestMethod, requestUrl, statusBody]; + + // API consumed by Net monitor UI components. Most of the method + // are not needed in context of the Console panel (atm) and thus + // let's just provide empty implementation. + // Individual methods might be implemented step by step as needed. + const connector = { + viewSourceInDebugger: (srcUrl, line, column) => { + serviceContainer.onViewSourceInDebugger({ url: srcUrl, line, column }); + }, + getLongString: grip => { + return serviceContainer.getLongString(grip); + }, + triggerActivity: () => {}, + requestData: (requestId, dataType) => { + return serviceContainer.requestData(requestId, dataType); + }, + }; + + // Only render the attachment if the network-event is + // actually opened (performance optimization) and its not disabled. + const attachment = + open && + !disabled && + dom.div( + { + className: "network-info network-monitor", + }, + createElement(TabboxPanel, { + connector, + activeTabId: networkMessageActiveTabId, + request: networkMessageUpdate, + sourceMapURLService: serviceContainer.sourceMapURLService, + openLink: serviceContainer.openLink, + selectTab: tabId => { + dispatch(actions.selectNetworkMessageTab(tabId)); + }, + openNetworkDetails: enabled => { + if (!enabled) { + dispatch(actions.messageClose(id)); + } + }, + hideToggleButton: true, + showMessagesView: false, + }) + ); + + const request = { url, method }; + return Message({ + dispatch, + messageId: id, + source, + type, + level, + indent, + collapsible: true, + open, + disabled, + attachment, + topLevelClasses, + timeStamp, + messageBody, + serviceContainer, + request, + timestampsVisible, + isBlockedNetworkMessage: !!blockedReason, + message, + }); +} + +module.exports = NetworkEventMessage; diff --git a/devtools/client/webconsole/components/Output/message-types/PageError.js b/devtools/client/webconsole/components/Output/message-types/PageError.js new file mode 100644 index 0000000000..01828e968e --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/PageError.js @@ -0,0 +1,130 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); +const GripMessageBody = require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js"); +loader.lazyGetter(this, "REPS", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .REPS; +}); +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); + +PageError.displayName = "PageError"; + +PageError.propTypes = { + message: PropTypes.object.isRequired, + open: PropTypes.bool, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + maybeScrollToBottom: PropTypes.func, + setExpanded: PropTypes.func, + inWarningGroup: PropTypes.bool.isRequired, +}; + +PageError.defaultProps = { + open: false, +}; + +function PageError(props) { + const { + dispatch, + message, + open, + repeat, + serviceContainer, + timestampsVisible, + maybeScrollToBottom, + setExpanded, + inWarningGroup, + } = props; + const { + id: messageId, + source, + type, + level, + messageText, + stacktrace, + frame, + exceptionDocURL, + timeStamp, + notes, + parameters, + hasException, + isPromiseRejection, + } = message; + + const messageBody = []; + + const repsProps = { + useQuotes: false, + escapeWhitespace: false, + openLink: serviceContainer.openLink, + }; + + if (hasException) { + const prefix = `Uncaught${isPromiseRejection ? " (in promise)" : ""} `; + messageBody.push( + prefix, + GripMessageBody({ + key: "body", + dispatch, + messageId, + grip: parameters[0], + serviceContainer, + type, + customFormat: true, + maybeScrollToBottom, + setExpanded, + ...repsProps, + }) + ); + } else { + messageBody.push( + REPS.StringRep.rep({ + key: "bodytext", + object: messageText, + mode: MODE.LONG, + ...repsProps, + }) + ); + } + + return Message({ + dispatch, + messageId, + open, + collapsible: Array.isArray(stacktrace), + source, + type, + level, + topLevelClasses: [], + indent: message.indent, + inWarningGroup, + messageBody, + repeat, + frame, + stacktrace, + serviceContainer, + exceptionDocURL, + timeStamp, + notes, + timestampsVisible, + maybeScrollToBottom, + message, + }); +} + +module.exports = PageError; diff --git a/devtools/client/webconsole/components/Output/message-types/SimpleTable.js b/devtools/client/webconsole/components/Output/message-types/SimpleTable.js new file mode 100644 index 0000000000..35580e8163 --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/SimpleTable.js @@ -0,0 +1,130 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const GripMessageBody = createFactory( + require("resource://devtools/client/webconsole/components/Output/GripMessageBody.js") +); + +loader.lazyRequireGetter( + this, + "PropTypes", + "resource://devtools/client/shared/vendor/react-prop-types.js" +); + +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); + +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +SimpleTable.displayName = "SimpleTable"; + +SimpleTable.propTypes = { + columns: PropTypes.object.isRequired, + items: PropTypes.array.isRequired, + dispatch: PropTypes.func.isRequired, + serviceContainer: PropTypes.object.isRequired, + message: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + open: PropTypes.bool, +}; + +function SimpleTable(props) { + const { dispatch, message, serviceContainer, timestampsVisible, open } = + props; + + const { + source, + type, + level, + id: messageId, + indent, + timeStamp, + columns, + items, + } = message; + + // if we don't have any data, don't show anything. + if (!items.length) { + return null; + } + const headerItems = []; + columns.forEach((value, key) => + headerItems.push( + dom.th( + { + key, + title: value, + }, + value + ) + ) + ); + + const rowItems = items.map((item, index) => { + const cells = []; + + columns.forEach((_, key) => { + const cellValue = item[key]; + const cellContent = + typeof cellValue === "undefined" + ? "" + : GripMessageBody({ + grip: cellValue, + mode: MODE.SHORT, + useQuotes: false, + serviceContainer, + dispatch, + }); + + cells.push( + dom.td( + { + key, + }, + cellContent + ) + ); + }); + return dom.tr({ key: index }, cells); + }); + + const attachment = dom.table( + { + className: "simple-table", + role: "grid", + }, + dom.thead({}, dom.tr({ className: "simple-table-header" }, headerItems)), + dom.tbody({}, rowItems) + ); + + const topLevelClasses = ["cm-s-mozilla"]; + return Message({ + attachment, + dispatch, + indent, + level, + messageId, + open, + serviceContainer, + source, + timeStamp, + timestampsVisible, + topLevelClasses, + type, + message, + messageBody: [], + }); +} + +module.exports = SimpleTable; diff --git a/devtools/client/webconsole/components/Output/message-types/WarningGroup.js b/devtools/client/webconsole/components/Output/message-types/WarningGroup.js new file mode 100644 index 0000000000..d54976dbcd --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/WarningGroup.js @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// React & Redux +const { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const Message = createFactory( + require("resource://devtools/client/webconsole/components/Output/Message.js") +); + +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const { + l10n, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const messageCountTooltip = l10n.getStr( + "webconsole.warningGroup.messageCount.tooltip" +); + +WarningGroup.displayName = "WarningGroup"; + +WarningGroup.propTypes = { + dispatch: PropTypes.func.isRequired, + message: PropTypes.object.isRequired, + timestampsVisible: PropTypes.bool.isRequired, + serviceContainer: PropTypes.object, + badge: PropTypes.number.isRequired, +}; + +function WarningGroup(props) { + const { + dispatch, + message, + serviceContainer, + timestampsVisible, + badge, + open, + } = props; + + const { source, type, level, id: messageId, indent, timeStamp } = message; + + const messageBody = [ + message.messageText, + " ", + dom.span( + { + className: "warning-group-badge", + title: PluralForm.get(badge, messageCountTooltip).replace("#1", badge), + }, + badge + ), + ]; + const topLevelClasses = ["cm-s-mozilla"]; + + return Message({ + badge, + collapsible: true, + dispatch, + indent, + level, + messageBody, + messageId, + open, + serviceContainer, + source, + timeStamp, + timestampsVisible, + topLevelClasses, + type, + message, + }); +} + +module.exports = WarningGroup; diff --git a/devtools/client/webconsole/components/Output/message-types/moz.build b/devtools/client/webconsole/components/Output/message-types/moz.build new file mode 100644 index 0000000000..926eb80abe --- /dev/null +++ b/devtools/client/webconsole/components/Output/message-types/moz.build @@ -0,0 +1,18 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "ConsoleApiCall.js", + "ConsoleCommand.js", + "CSSWarning.js", + "DefaultRenderer.js", + "EvaluationResult.js", + "JSTracerTrace.js", + "NavigationMarker.js", + "NetworkEventMessage.js", + "PageError.js", + "SimpleTable.js", + "WarningGroup.js", +) diff --git a/devtools/client/webconsole/components/Output/moz.build b/devtools/client/webconsole/components/Output/moz.build new file mode 100644 index 0000000000..9844c2fdeb --- /dev/null +++ b/devtools/client/webconsole/components/Output/moz.build @@ -0,0 +1,21 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "message-types", +] + +DevToolsModules( + "CollapseButton.js", + "ConsoleOutput.js", + "ConsoleTable.js", + "GripMessageBody.js", + "LazyMessageList.js", + "Message.js", + "MessageContainer.js", + "MessageIcon.js", + "MessageIndent.js", + "MessageRepeat.js", +) |