381 lines
13 KiB
JavaScript
381 lines
13 KiB
JavaScript
/* 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.mjs");
|
|
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
|
|
const {
|
|
connect,
|
|
} = require("resource://devtools/client/shared/vendor/react-redux.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,
|
|
};
|
|
}
|
|
|
|
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(() => {
|
|
// 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) {
|
|
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) {
|
|
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);
|