RequestListContent.js (17956B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { 8 Component, 9 createFactory, 10 } = require("resource://devtools/client/shared/vendor/react.mjs"); 11 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 12 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 13 const { 14 connect, 15 } = require("resource://devtools/client/shared/vendor/react-redux.js"); 16 const { 17 HTMLTooltip, 18 } = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); 19 20 const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js"); 21 const { 22 formDataURI, 23 } = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); 24 const { 25 getDisplayedRequests, 26 getColumns, 27 getSelectedRequest, 28 getClickedRequest, 29 getWaterfallScale, 30 hasOverride, 31 } = require("resource://devtools/client/netmonitor/src/selectors/index.js"); 32 33 loader.lazyRequireGetter( 34 this, 35 "openRequestInTab", 36 "resource://devtools/client/netmonitor/src/utils/firefox/open-request-in-tab.js", 37 true 38 ); 39 loader.lazyGetter(this, "setImageTooltip", function () { 40 return require("resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js") 41 .setImageTooltip; 42 }); 43 loader.lazyGetter(this, "getImageDimensions", function () { 44 return require("resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js") 45 .getImageDimensions; 46 }); 47 48 // Components 49 const RequestListHeader = createFactory( 50 require("resource://devtools/client/netmonitor/src/components/request-list/RequestListHeader.js") 51 ); 52 const RequestListItem = createFactory( 53 require("resource://devtools/client/netmonitor/src/components/request-list/RequestListItem.js") 54 ); 55 const RequestListContextMenu = require("resource://devtools/client/netmonitor/src/widgets/RequestListContextMenu.js"); 56 57 const { div } = dom; 58 59 // Tooltip show / hide delay in ms 60 const REQUESTS_TOOLTIP_TOGGLE_DELAY = 500; 61 // Tooltip image maximum dimension in px 62 const REQUESTS_TOOLTIP_IMAGE_MAX_DIM = 400; 63 64 const LEFT_MOUSE_BUTTON = 0; 65 const MIDDLE_MOUSE_BUTTON = 1; 66 const RIGHT_MOUSE_BUTTON = 2; 67 68 /** 69 * Renders the actual contents of the request list. 70 */ 71 class RequestListContentComponent extends Component { 72 static get propTypes() { 73 return { 74 blockedUrls: PropTypes.array.isRequired, 75 connector: PropTypes.object.isRequired, 76 columns: PropTypes.object.isRequired, 77 networkActionOpen: PropTypes.bool, 78 networkDetailsOpen: PropTypes.bool.isRequired, 79 networkDetailsWidth: PropTypes.number, 80 networkDetailsHeight: PropTypes.number, 81 waterfallScale: PropTypes.number, 82 slowLimit: PropTypes.number, 83 cloneRequest: PropTypes.func.isRequired, 84 clickedRequest: PropTypes.object, 85 openDetailsPanelTab: PropTypes.func.isRequired, 86 openHTTPCustomRequestTab: PropTypes.func.isRequired, 87 closeHTTPCustomRequestTab: PropTypes.func.isRequired, 88 sendCustomRequest: PropTypes.func.isRequired, 89 sendHTTPCustomRequest: PropTypes.func.isRequired, 90 displayedRequests: PropTypes.array.isRequired, 91 firstRequestStartedMs: PropTypes.number.isRequired, 92 fromCache: PropTypes.bool, 93 onInitiatorBadgeMouseDown: PropTypes.func.isRequired, 94 onItemRightMouseButtonDown: PropTypes.func.isRequired, 95 onItemMouseDown: PropTypes.func.isRequired, 96 onSecurityIconMouseDown: PropTypes.func.isRequired, 97 onSelectDelta: PropTypes.func.isRequired, 98 onWaterfallMouseDown: PropTypes.func.isRequired, 99 openStatistics: PropTypes.func.isRequired, 100 openRequestBlockingAndAddUrl: PropTypes.func.isRequired, 101 openRequestBlockingAndDisableUrls: PropTypes.func.isRequired, 102 removeBlockedUrl: PropTypes.func.isRequired, 103 selectedActionBarTabId: PropTypes.string, 104 selectRequest: PropTypes.func.isRequired, 105 selectedRequest: PropTypes.object, 106 requestFilterTypes: PropTypes.object.isRequired, 107 }; 108 } 109 110 constructor(props) { 111 super(props); 112 this.onHover = this.onHover.bind(this); 113 this.onScroll = this.onScroll.bind(this); 114 this.onResize = this.onResize.bind(this); 115 this.onKeyDown = this.onKeyDown.bind(this); 116 this.openRequestInTab = this.openRequestInTab.bind(this); 117 this.onDoubleClick = this.onDoubleClick.bind(this); 118 this.onDragStart = this.onDragStart.bind(this); 119 this.onContextMenu = this.onContextMenu.bind(this); 120 this.onMouseDown = this.onMouseDown.bind(this); 121 this.hasOverflow = false; 122 this.onIntersect = this.onIntersect.bind(this); 123 this.intersectionObserver = null; 124 this.state = { 125 onscreenItems: new Set(), 126 }; 127 } 128 129 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 130 UNSAFE_componentWillMount() { 131 this.tooltip = new HTMLTooltip(window.parent.document, { type: "arrow" }); 132 window.addEventListener("resize", this.onResize); 133 } 134 135 componentDidMount() { 136 // Install event handler for displaying a tooltip 137 this.tooltip.startTogglingOnHover(this.refs.scrollEl, this.onHover, { 138 toggleDelay: REQUESTS_TOOLTIP_TOGGLE_DELAY, 139 interactive: true, 140 }); 141 // Install event handler to hide the tooltip on scroll 142 this.refs.scrollEl.addEventListener("scroll", this.onScroll, true); 143 this.onResize(); 144 this.intersectionObserver = new IntersectionObserver(this.onIntersect, { 145 root: this.refs.scrollEl, 146 // Render 10% more columns for a scrolling headstart 147 rootMargin: "10%", 148 }); 149 // Prime IntersectionObserver with existing entries 150 for (const item of this.refs.scrollEl.querySelectorAll( 151 ".request-list-item" 152 )) { 153 this.intersectionObserver.observe(item); 154 } 155 } 156 157 componentDidUpdate(prevProps) { 158 const output = this.refs.scrollEl; 159 if (!this.hasOverflow && output.scrollHeight > output.clientHeight) { 160 output.scrollTop = output.scrollHeight; 161 this.hasOverflow = true; 162 } 163 if ( 164 prevProps.networkDetailsOpen !== this.props.networkDetailsOpen || 165 prevProps.networkDetailsWidth !== this.props.networkDetailsWidth || 166 prevProps.networkDetailsHeight !== this.props.networkDetailsHeight 167 ) { 168 this.onResize(); 169 } 170 } 171 172 componentWillUnmount() { 173 this.refs.scrollEl.removeEventListener("scroll", this.onScroll, true); 174 175 // Uninstall the tooltip event handler 176 this.tooltip.stopTogglingOnHover(); 177 window.removeEventListener("resize", this.onResize); 178 if (this.intersectionObserver !== null) { 179 this.intersectionObserver.disconnect(); 180 this.intersectionObserver = null; 181 } 182 } 183 184 /* 185 * Removing onResize() method causes perf regression - too many repaints of the panel. 186 * So it is needed in ComponentDidMount and ComponentDidUpdate. See Bug 1532914. 187 */ 188 onResize() { 189 // Wait for the next animation frame to measure the parentNode dimensions. 190 // Bug 1900682. 191 requestAnimationFrame(() => { 192 if (document.visibilityState == "visible") { 193 const parent = this.refs.scrollEl.parentNode; 194 this.refs.scrollEl.style.width = parent.offsetWidth + "px"; 195 this.refs.scrollEl.style.height = parent.offsetHeight + "px"; 196 } 197 }); 198 } 199 200 onIntersect(entries) { 201 // Track when off screen elements moved on screen to ensure updates 202 let onscreenDidChange = false; 203 const onscreenItems = new Set(this.state.onscreenItems); 204 for (const { target, isIntersecting } of entries) { 205 const { id } = target.dataset; 206 if (isIntersecting) { 207 if (onscreenItems.add(id)) { 208 onscreenDidChange = true; 209 } 210 } else { 211 onscreenItems.delete(id); 212 } 213 } 214 if (onscreenDidChange) { 215 // Remove ids that are no longer displayed 216 const itemIds = new Set(this.props.displayedRequests.map(({ id }) => id)); 217 for (const id of onscreenItems) { 218 if (!itemIds.has(id)) { 219 onscreenItems.delete(id); 220 } 221 } 222 this.setState({ onscreenItems }); 223 } 224 } 225 226 /** 227 * The predicate used when deciding whether a popup should be shown 228 * over a request item or not. 229 * 230 * @param Node target 231 * The element node currently being hovered. 232 * @param object tooltip 233 * The current tooltip instance. 234 * @return {Promise} 235 */ 236 async onHover(target, tooltip) { 237 const itemEl = target.closest(".request-list-item"); 238 if (!itemEl) { 239 return false; 240 } 241 const itemId = itemEl.dataset.id; 242 if (!itemId) { 243 return false; 244 } 245 const requestItem = this.props.displayedRequests.find(r => r.id == itemId); 246 if (!requestItem) { 247 return false; 248 } 249 250 if (!target.closest(".requests-list-file")) { 251 return false; 252 } 253 254 const { mimeType } = requestItem; 255 if (!mimeType || !mimeType.includes("image/")) { 256 return false; 257 } 258 259 const responseContent = await this.props.connector.requestData( 260 requestItem.id, 261 "responseContent" 262 ); 263 const { encoding, text } = responseContent.content; 264 const src = formDataURI(mimeType, encoding, text); 265 const maxDim = REQUESTS_TOOLTIP_IMAGE_MAX_DIM; 266 const { naturalWidth, naturalHeight } = await getImageDimensions( 267 tooltip.doc, 268 src 269 ); 270 const options = { maxDim, naturalWidth, naturalHeight }; 271 setImageTooltip(tooltip, tooltip.doc, src, options); 272 273 return itemEl.querySelector(".requests-list-file"); 274 } 275 276 /** 277 * Scroll listener for the requests menu view. 278 */ 279 onScroll() { 280 this.tooltip.hide(); 281 } 282 283 onMouseDown(evt, id, request) { 284 if (evt.button === LEFT_MOUSE_BUTTON) { 285 this.props.selectRequest(id, request); 286 } else if (evt.button === RIGHT_MOUSE_BUTTON) { 287 this.props.onItemRightMouseButtonDown(id); 288 } else if (evt.button === MIDDLE_MOUSE_BUTTON) { 289 this.onMiddleMouseButtonDown(request); 290 } 291 } 292 293 /** 294 * Handler for keyboard events. For arrow up/down, page up/down, home/end, 295 * move the selection up or down. 296 */ 297 onKeyDown(evt) { 298 let delta; 299 300 switch (evt.key) { 301 case "ArrowUp": 302 delta = -1; 303 break; 304 case "ArrowDown": 305 delta = +1; 306 break; 307 case "PageUp": 308 delta = "PAGE_UP"; 309 break; 310 case "PageDown": 311 delta = "PAGE_DOWN"; 312 break; 313 case "Home": 314 delta = -Infinity; 315 break; 316 case "End": 317 delta = +Infinity; 318 break; 319 } 320 321 if (delta) { 322 // Prevent scrolling when pressing navigation keys. 323 evt.preventDefault(); 324 evt.stopPropagation(); 325 this.props.onSelectDelta(delta); 326 } 327 } 328 329 /** 330 * Opens selected item in a new tab. 331 */ 332 async openRequestInTab(id, url, requestHeaders, requestPostData) { 333 requestHeaders = 334 requestHeaders || 335 (await this.props.connector.requestData(id, "requestHeaders")); 336 337 requestPostData = 338 requestPostData || 339 (await this.props.connector.requestData(id, "requestPostData")); 340 341 openRequestInTab(url, requestHeaders, requestPostData); 342 } 343 344 onDoubleClick({ id, url, requestHeaders, requestPostData }) { 345 this.openRequestInTab(id, url, requestHeaders, requestPostData); 346 } 347 348 onMiddleMouseButtonDown({ id, url, requestHeaders, requestPostData }) { 349 this.openRequestInTab(id, url, requestHeaders, requestPostData); 350 } 351 352 onDragStart(evt, { url }) { 353 evt.dataTransfer.setData("text/plain", url); 354 } 355 356 onContextMenu(evt) { 357 evt.preventDefault(); 358 const { clickedRequest, displayedRequests, blockedUrls } = this.props; 359 360 if (!this.contextMenu) { 361 const { 362 connector, 363 cloneRequest, 364 openDetailsPanelTab, 365 openHTTPCustomRequestTab, 366 closeHTTPCustomRequestTab, 367 sendCustomRequest, 368 sendHTTPCustomRequest, 369 openStatistics, 370 openRequestBlockingAndAddUrl, 371 openRequestBlockingAndDisableUrls, 372 removeBlockedUrl, 373 } = this.props; 374 this.contextMenu = new RequestListContextMenu({ 375 connector, 376 cloneRequest, 377 openDetailsPanelTab, 378 openHTTPCustomRequestTab, 379 closeHTTPCustomRequestTab, 380 sendCustomRequest, 381 sendHTTPCustomRequest, 382 openStatistics, 383 openRequestBlockingAndAddUrl, 384 openRequestBlockingAndDisableUrls, 385 removeBlockedUrl, 386 }); 387 } 388 389 this.contextMenu.open(evt, clickedRequest, displayedRequests, blockedUrls); 390 } 391 392 render() { 393 const { 394 connector, 395 columns, 396 displayedRequests, 397 firstRequestStartedMs, 398 onInitiatorBadgeMouseDown, 399 onSecurityIconMouseDown, 400 onWaterfallMouseDown, 401 requestFilterTypes, 402 selectedRequest, 403 selectedActionBarTabId, 404 openRequestBlockingAndAddUrl, 405 openRequestBlockingAndDisableUrls, 406 networkActionOpen, 407 networkDetailsOpen, 408 slowLimit, 409 waterfallScale, 410 } = this.props; 411 412 return div( 413 { 414 ref: "scrollEl", 415 className: "requests-list-scroll", 416 }, 417 [ 418 dom.table( 419 { 420 className: "requests-list-table", 421 key: "table", 422 }, 423 RequestListHeader(), 424 dom.tbody( 425 { 426 ref: "rowGroupEl", 427 className: "requests-list-row-group", 428 tabIndex: 0, 429 onKeyDown: this.onKeyDown, 430 }, 431 displayedRequests.map((item, index) => { 432 return RequestListItem({ 433 blocked: !!item.blockedReason, 434 firstRequestStartedMs, 435 fromCache: item.status === "304" || item.fromCache, 436 networkDetailsOpen, 437 networkActionOpen, 438 selectedActionBarTabId, 439 connector, 440 columns, 441 item, 442 index, 443 isSelected: item.id === selectedRequest?.id, 444 isVisible: this.state.onscreenItems.has(item.id), 445 key: item.id, 446 intersectionObserver: this.intersectionObserver, 447 onContextMenu: this.onContextMenu, 448 onDoubleClick: () => this.onDoubleClick(item), 449 onDragStart: evt => this.onDragStart(evt, item), 450 onMouseDown: evt => this.onMouseDown(evt, item.id, item), 451 onInitiatorBadgeMouseDown: () => 452 onInitiatorBadgeMouseDown(item.cause), 453 onSecurityIconMouseDown: () => 454 onSecurityIconMouseDown(item.securityState), 455 onWaterfallMouseDown, 456 requestFilterTypes, 457 openRequestBlockingAndAddUrl, 458 openRequestBlockingAndDisableUrls, 459 slowLimit, 460 waterfallScale, 461 }); 462 }) 463 ) 464 ), // end of requests-list-row-group"> 465 dom.div({ 466 className: "requests-list-anchor", 467 key: "anchor", 468 }), 469 ] 470 ); 471 } 472 } 473 474 const RequestListContent = connect( 475 (state, props) => ({ 476 blockedUrls: state.requestBlocking.blockedUrls, 477 columns: getColumns(state, props.hasOverride), 478 networkActionOpen: state.ui.networkActionOpen, 479 networkDetailsOpen: state.ui.networkDetailsOpen, 480 networkDetailsWidth: state.ui.networkDetailsWidth, 481 networkDetailsHeight: state.ui.networkDetailsHeight, 482 waterfallScale: getWaterfallScale(state), 483 slowLimit: state.ui.slowLimit, 484 clickedRequest: getClickedRequest(state), 485 displayedRequests: getDisplayedRequests(state), 486 firstRequestStartedMs: state.requests.firstStartedMs, 487 selectedActionBarTabId: state.ui.selectedActionBarTabId, 488 selectedRequest: getSelectedRequest(state), 489 requestFilterTypes: state.filters.requestFilterTypes, 490 }), 491 (dispatch, props) => ({ 492 cloneRequest: id => dispatch(Actions.cloneRequest(id)), 493 openDetailsPanelTab: () => dispatch(Actions.openNetworkDetails(true)), 494 openHTTPCustomRequestTab: () => 495 dispatch(Actions.openHTTPCustomRequest(true)), 496 closeHTTPCustomRequestTab: () => 497 dispatch(Actions.openHTTPCustomRequest(false)), 498 sendCustomRequest: () => dispatch(Actions.sendCustomRequest()), 499 sendHTTPCustomRequest: request => 500 dispatch(Actions.sendHTTPCustomRequest(request)), 501 openStatistics: open => 502 dispatch(Actions.openStatistics(props.connector, open)), 503 openRequestBlockingAndAddUrl: url => 504 dispatch(Actions.openRequestBlockingAndAddUrl(url)), 505 removeBlockedUrl: url => dispatch(Actions.removeBlockedUrl(url)), 506 openRequestBlockingAndDisableUrls: url => 507 dispatch(Actions.openRequestBlockingAndDisableUrls(url)), 508 /** 509 * A handler that opens the stack trace tab when a stack trace is available 510 */ 511 onInitiatorBadgeMouseDown: cause => { 512 if (cause.lastFrame) { 513 dispatch(Actions.selectDetailsPanelTab("stack-trace")); 514 } 515 }, 516 selectRequest: (id, request) => 517 dispatch(Actions.selectRequest(id, request)), 518 onItemRightMouseButtonDown: id => dispatch(Actions.rightClickRequest(id)), 519 onItemMouseDown: id => dispatch(Actions.selectRequest(id)), 520 /** 521 * A handler that opens the security tab in the details view if secure or 522 * broken security indicator is clicked. 523 */ 524 onSecurityIconMouseDown: securityState => { 525 if (securityState && securityState !== "insecure") { 526 dispatch(Actions.selectDetailsPanelTab("security")); 527 } 528 }, 529 onSelectDelta: delta => dispatch(Actions.selectDelta(delta)), 530 /** 531 * A handler that opens the timing sidebar panel if the waterfall is clicked. 532 */ 533 onWaterfallMouseDown: () => { 534 dispatch(Actions.selectDetailsPanelTab("timings")); 535 }, 536 }) 537 )(RequestListContentComponent); 538 539 module.exports = connect( 540 state => { 541 return { 542 hasOverride: hasOverride(state), 543 }; 544 }, 545 {}, 546 undefined, 547 { storeKey: "toolbox-store" } 548 )(RequestListContent);