/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); const { connect, } = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); const { HTMLTooltip, } = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js"); const { formDataURI, } = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); const { getDisplayedRequests, getColumns, getSelectedRequest, getClickedRequest, } = require("resource://devtools/client/netmonitor/src/selectors/index.js"); loader.lazyRequireGetter( this, "openRequestInTab", "resource://devtools/client/netmonitor/src/utils/firefox/open-request-in-tab.js", true ); loader.lazyGetter(this, "setImageTooltip", function () { return require("resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js") .setImageTooltip; }); loader.lazyGetter(this, "getImageDimensions", function () { return require("resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js") .getImageDimensions; }); // Components const RequestListHeader = createFactory( require("resource://devtools/client/netmonitor/src/components/request-list/RequestListHeader.js") ); const RequestListItem = createFactory( require("resource://devtools/client/netmonitor/src/components/request-list/RequestListItem.js") ); const RequestListContextMenu = require("resource://devtools/client/netmonitor/src/widgets/RequestListContextMenu.js"); const { div } = dom; // Tooltip show / hide delay in ms const REQUESTS_TOOLTIP_TOGGLE_DELAY = 500; // Tooltip image maximum dimension in px const REQUESTS_TOOLTIP_IMAGE_MAX_DIM = 400; const LEFT_MOUSE_BUTTON = 0; const MIDDLE_MOUSE_BUTTON = 1; const RIGHT_MOUSE_BUTTON = 2; /** * Renders the actual contents of the request list. */ class RequestListContent extends Component { static get propTypes() { return { blockedUrls: PropTypes.array.isRequired, connector: PropTypes.object.isRequired, columns: PropTypes.object.isRequired, networkActionOpen: PropTypes.bool, networkDetailsOpen: PropTypes.bool.isRequired, networkDetailsWidth: PropTypes.number, networkDetailsHeight: PropTypes.number, cloneRequest: PropTypes.func.isRequired, clickedRequest: PropTypes.object, openDetailsPanelTab: PropTypes.func.isRequired, openHTTPCustomRequestTab: PropTypes.func.isRequired, closeHTTPCustomRequestTab: PropTypes.func.isRequired, sendCustomRequest: PropTypes.func.isRequired, sendHTTPCustomRequest: PropTypes.func.isRequired, displayedRequests: PropTypes.array.isRequired, firstRequestStartedMs: PropTypes.number.isRequired, fromCache: PropTypes.bool, onInitiatorBadgeMouseDown: PropTypes.func.isRequired, onItemRightMouseButtonDown: PropTypes.func.isRequired, onItemMouseDown: PropTypes.func.isRequired, onSecurityIconMouseDown: PropTypes.func.isRequired, onSelectDelta: PropTypes.func.isRequired, onWaterfallMouseDown: PropTypes.func.isRequired, openStatistics: PropTypes.func.isRequired, openRequestBlockingAndAddUrl: PropTypes.func.isRequired, openRequestBlockingAndDisableUrls: PropTypes.func.isRequired, removeBlockedUrl: PropTypes.func.isRequired, selectedActionBarTabId: PropTypes.string, selectRequest: PropTypes.func.isRequired, selectedRequest: PropTypes.object, requestFilterTypes: PropTypes.object.isRequired, }; } constructor(props) { super(props); this.onHover = this.onHover.bind(this); this.onScroll = this.onScroll.bind(this); this.onResize = this.onResize.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.openRequestInTab = this.openRequestInTab.bind(this); this.onDoubleClick = this.onDoubleClick.bind(this); this.onDragStart = this.onDragStart.bind(this); this.onContextMenu = this.onContextMenu.bind(this); this.onMouseDown = this.onMouseDown.bind(this); this.hasOverflow = false; this.onIntersect = this.onIntersect.bind(this); this.intersectionObserver = null; this.state = { onscreenItems: new Set(), }; } // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 UNSAFE_componentWillMount() { this.tooltip = new HTMLTooltip(window.parent.document, { type: "arrow" }); window.addEventListener("resize", this.onResize); } componentDidMount() { // Install event handler for displaying a tooltip this.tooltip.startTogglingOnHover(this.refs.scrollEl, this.onHover, { toggleDelay: REQUESTS_TOOLTIP_TOGGLE_DELAY, interactive: true, }); // Install event handler to hide the tooltip on scroll this.refs.scrollEl.addEventListener("scroll", this.onScroll, true); this.onResize(); this.intersectionObserver = new IntersectionObserver(this.onIntersect, { root: this.refs.scrollEl, // Render 10% more columns for a scrolling headstart rootMargin: "10%", }); // Prime IntersectionObserver with existing entries for (const item of this.refs.scrollEl.querySelectorAll( ".request-list-item" )) { this.intersectionObserver.observe(item); } } componentDidUpdate(prevProps) { const output = this.refs.scrollEl; if (!this.hasOverflow && output.scrollHeight > output.clientHeight) { output.scrollTop = output.scrollHeight; this.hasOverflow = true; } if ( prevProps.networkDetailsOpen !== this.props.networkDetailsOpen || prevProps.networkDetailsWidth !== this.props.networkDetailsWidth || prevProps.networkDetailsHeight !== this.props.networkDetailsHeight ) { this.onResize(); } } componentWillUnmount() { this.refs.scrollEl.removeEventListener("scroll", this.onScroll, true); // Uninstall the tooltip event handler this.tooltip.stopTogglingOnHover(); window.removeEventListener("resize", this.onResize); if (this.intersectionObserver !== null) { this.intersectionObserver.disconnect(); this.intersectionObserver = null; } } /* * Removing onResize() method causes perf regression - too many repaints of the panel. * So it is needed in ComponentDidMount and ComponentDidUpdate. See Bug 1532914. */ onResize() { const parent = this.refs.scrollEl.parentNode; this.refs.scrollEl.style.width = parent.offsetWidth + "px"; this.refs.scrollEl.style.height = parent.offsetHeight + "px"; } onIntersect(entries) { // Track when off screen elements moved on screen to ensure updates let onscreenDidChange = false; const onscreenItems = new Set(this.state.onscreenItems); for (const { target, isIntersecting } of entries) { const { id } = target.dataset; if (isIntersecting) { if (onscreenItems.add(id)) { onscreenDidChange = true; } } else { onscreenItems.delete(id); } } if (onscreenDidChange) { // Remove ids that are no longer displayed const itemIds = new Set(this.props.displayedRequests.map(({ id }) => id)); for (const id of onscreenItems) { if (!itemIds.has(id)) { onscreenItems.delete(id); } } this.setState({ onscreenItems }); } } /** * The predicate used when deciding whether a popup should be shown * over a request item or not. * * @param Node target * The element node currently being hovered. * @param object tooltip * The current tooltip instance. * @return {Promise} */ async onHover(target, tooltip) { const itemEl = target.closest(".request-list-item"); if (!itemEl) { return false; } const itemId = itemEl.dataset.id; if (!itemId) { return false; } const requestItem = this.props.displayedRequests.find(r => r.id == itemId); if (!requestItem) { return false; } if (!target.closest(".requests-list-file")) { return false; } const { mimeType } = requestItem; if (!mimeType || !mimeType.includes("image/")) { return false; } const responseContent = await this.props.connector.requestData( requestItem.id, "responseContent" ); const { encoding, text } = responseContent.content; const src = formDataURI(mimeType, encoding, text); const maxDim = REQUESTS_TOOLTIP_IMAGE_MAX_DIM; const { naturalWidth, naturalHeight } = await getImageDimensions( tooltip.doc, src ); const options = { maxDim, naturalWidth, naturalHeight }; setImageTooltip(tooltip, tooltip.doc, src, options); return itemEl.querySelector(".requests-list-file"); } /** * Scroll listener for the requests menu view. */ onScroll() { this.tooltip.hide(); } onMouseDown(evt, id, request) { if (evt.button === LEFT_MOUSE_BUTTON) { this.props.selectRequest(id, request); } else if (evt.button === RIGHT_MOUSE_BUTTON) { this.props.onItemRightMouseButtonDown(id); } else if (evt.button === MIDDLE_MOUSE_BUTTON) { this.onMiddleMouseButtonDown(request); } } /** * Handler for keyboard events. For arrow up/down, page up/down, home/end, * move the selection up or down. */ onKeyDown(evt) { let delta; switch (evt.key) { case "ArrowUp": delta = -1; break; case "ArrowDown": delta = +1; break; case "PageUp": delta = "PAGE_UP"; break; case "PageDown": delta = "PAGE_DOWN"; break; case "Home": delta = -Infinity; break; case "End": delta = +Infinity; break; } if (delta) { // Prevent scrolling when pressing navigation keys. evt.preventDefault(); evt.stopPropagation(); this.props.onSelectDelta(delta); } } /** * Opens selected item in a new tab. */ async openRequestInTab(id, url, requestHeaders, requestPostData) { requestHeaders = requestHeaders || (await this.props.connector.requestData(id, "requestHeaders")); requestPostData = requestPostData || (await this.props.connector.requestData(id, "requestPostData")); openRequestInTab(url, requestHeaders, requestPostData); } onDoubleClick({ id, url, requestHeaders, requestPostData }) { this.openRequestInTab(id, url, requestHeaders, requestPostData); } onMiddleMouseButtonDown({ id, url, requestHeaders, requestPostData }) { this.openRequestInTab(id, url, requestHeaders, requestPostData); } onDragStart(evt, { url }) { evt.dataTransfer.setData("text/plain", url); } onContextMenu(evt) { evt.preventDefault(); const { clickedRequest, displayedRequests, blockedUrls } = this.props; if (!this.contextMenu) { const { connector, cloneRequest, openDetailsPanelTab, openHTTPCustomRequestTab, closeHTTPCustomRequestTab, sendCustomRequest, sendHTTPCustomRequest, openStatistics, openRequestBlockingAndAddUrl, openRequestBlockingAndDisableUrls, removeBlockedUrl, } = this.props; this.contextMenu = new RequestListContextMenu({ connector, cloneRequest, openDetailsPanelTab, openHTTPCustomRequestTab, closeHTTPCustomRequestTab, sendCustomRequest, sendHTTPCustomRequest, openStatistics, openRequestBlockingAndAddUrl, openRequestBlockingAndDisableUrls, removeBlockedUrl, openRequestInTab: this.openRequestInTab, }); } this.contextMenu.open(evt, clickedRequest, displayedRequests, blockedUrls); } render() { const { connector, columns, displayedRequests, firstRequestStartedMs, onInitiatorBadgeMouseDown, onSecurityIconMouseDown, onWaterfallMouseDown, requestFilterTypes, selectedRequest, selectedActionBarTabId, openRequestBlockingAndAddUrl, openRequestBlockingAndDisableUrls, networkActionOpen, networkDetailsOpen, } = this.props; return div( { ref: "scrollEl", className: "requests-list-scroll", }, [ dom.table( { className: "requests-list-table", key: "table", }, RequestListHeader(), dom.tbody( { ref: "rowGroupEl", className: "requests-list-row-group", tabIndex: 0, onKeyDown: this.onKeyDown, }, displayedRequests.map((item, index) => { return RequestListItem({ blocked: !!item.blockedReason, firstRequestStartedMs, fromCache: item.status === "304" || item.fromCache, networkDetailsOpen, networkActionOpen, selectedActionBarTabId, connector, columns, item, index, isSelected: item.id === selectedRequest?.id, isVisible: this.state.onscreenItems.has(item.id), key: item.id, intersectionObserver: this.intersectionObserver, onContextMenu: this.onContextMenu, onDoubleClick: () => this.onDoubleClick(item), onDragStart: evt => this.onDragStart(evt, item), onMouseDown: evt => this.onMouseDown(evt, item.id, item), onInitiatorBadgeMouseDown: () => onInitiatorBadgeMouseDown(item.cause), onSecurityIconMouseDown: () => onSecurityIconMouseDown(item.securityState), onWaterfallMouseDown, requestFilterTypes, openRequestBlockingAndAddUrl, openRequestBlockingAndDisableUrls, }); }) ) ), // end of requests-list-row-group"> dom.div({ className: "requests-list-anchor", key: "anchor", }), ] ); } } module.exports = connect( state => ({ blockedUrls: state.requestBlocking.blockedUrls .map(({ enabled, url }) => (enabled ? url : null)) .filter(Boolean), columns: getColumns(state), networkActionOpen: state.ui.networkActionOpen, networkDetailsOpen: state.ui.networkDetailsOpen, networkDetailsWidth: state.ui.networkDetailsWidth, networkDetailsHeight: state.ui.networkDetailsHeight, clickedRequest: getClickedRequest(state), displayedRequests: getDisplayedRequests(state), firstRequestStartedMs: state.requests.firstStartedMs, selectedActionBarTabId: state.ui.selectedActionBarTabId, selectedRequest: getSelectedRequest(state), requestFilterTypes: state.filters.requestFilterTypes, }), (dispatch, props) => ({ cloneRequest: id => dispatch(Actions.cloneRequest(id)), openDetailsPanelTab: () => dispatch(Actions.openNetworkDetails(true)), openHTTPCustomRequestTab: () => dispatch(Actions.openHTTPCustomRequest(true)), closeHTTPCustomRequestTab: () => dispatch(Actions.openHTTPCustomRequest(false)), sendCustomRequest: () => dispatch(Actions.sendCustomRequest()), sendHTTPCustomRequest: request => dispatch(Actions.sendHTTPCustomRequest(request)), openStatistics: open => dispatch(Actions.openStatistics(props.connector, open)), openRequestBlockingAndAddUrl: url => dispatch(Actions.openRequestBlockingAndAddUrl(url)), removeBlockedUrl: url => dispatch(Actions.removeBlockedUrl(url)), openRequestBlockingAndDisableUrls: url => dispatch(Actions.openRequestBlockingAndDisableUrls(url)), /** * A handler that opens the stack trace tab when a stack trace is available */ onInitiatorBadgeMouseDown: cause => { if (cause.lastFrame) { dispatch(Actions.selectDetailsPanelTab("stack-trace")); } }, selectRequest: (id, request) => dispatch(Actions.selectRequest(id, request)), onItemRightMouseButtonDown: id => dispatch(Actions.rightClickRequest(id)), onItemMouseDown: id => dispatch(Actions.selectRequest(id)), /** * A handler that opens the security tab in the details view if secure or * broken security indicator is clicked. */ onSecurityIconMouseDown: securityState => { if (securityState && securityState !== "insecure") { dispatch(Actions.selectDetailsPanelTab("security")); } }, onSelectDelta: delta => dispatch(Actions.selectDelta(delta)), /** * A handler that opens the timing sidebar panel if the waterfall is clicked. */ onWaterfallMouseDown: () => { dispatch(Actions.selectDetailsPanelTab("timings")); }, }) )(RequestListContent);