diff options
Diffstat (limited to 'devtools/client/netmonitor/requests-menu-view.js')
-rw-r--r-- | devtools/client/netmonitor/requests-menu-view.js | 1649 |
1 files changed, 1649 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/requests-menu-view.js b/devtools/client/netmonitor/requests-menu-view.js new file mode 100644 index 000000000..6ea6381ec --- /dev/null +++ b/devtools/client/netmonitor/requests-menu-view.js @@ -0,0 +1,1649 @@ +/* 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/. */ + +/* globals document, window, dumpn, $, gNetwork, EVENTS, Prefs, + NetMonitorController, NetMonitorView */ + +"use strict"; + +/* eslint-disable mozilla/reject-some-requires */ +const { Cu } = require("chrome"); +const {Task} = require("devtools/shared/task"); +const {DeferredTask} = Cu.import("resource://gre/modules/DeferredTask.jsm", {}); +/* eslint-disable mozilla/reject-some-requires */ +const {SideMenuWidget} = require("resource://devtools/client/shared/widgets/SideMenuWidget.jsm"); +const {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip"); +const {setImageTooltip, getImageDimensions} = + require("devtools/client/shared/widgets/tooltip/ImageTooltipHelper"); +const {Heritage, WidgetMethods, setNamedTimeout} = + require("devtools/client/shared/widgets/view-helpers"); +const {CurlUtils} = require("devtools/client/shared/curl"); +const {PluralForm} = require("devtools/shared/plural-form"); +const {Filters, isFreetextMatch} = require("./filter-predicates"); +const {Sorters} = require("./sort-predicates"); +const {L10N, WEBCONSOLE_L10N} = require("./l10n"); +const {formDataURI, + writeHeaderText, + getKeyWithEvent, + getAbbreviatedMimeType, + getUriNameWithQuery, + getUriHostPort, + getUriHost, + loadCauseString} = require("./request-utils"); +const Actions = require("./actions/index"); +const RequestListContextMenu = require("./request-list-context-menu"); + +loader.lazyRequireGetter(this, "NetworkHelper", + "devtools/shared/webconsole/network-helper"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const EPSILON = 0.001; +// ms +const RESIZE_REFRESH_RATE = 50; +// ms +const REQUESTS_REFRESH_RATE = 50; +// tooltip show/hide delay in ms +const REQUESTS_TOOLTIP_TOGGLE_DELAY = 500; +// px +const REQUESTS_TOOLTIP_IMAGE_MAX_DIM = 400; +// px +const REQUESTS_TOOLTIP_STACK_TRACE_WIDTH = 600; +// px +const REQUESTS_WATERFALL_SAFE_BOUNDS = 90; +// ms +const REQUESTS_WATERFALL_HEADER_TICKS_MULTIPLE = 5; +// px +const REQUESTS_WATERFALL_HEADER_TICKS_SPACING_MIN = 60; +// ms +const REQUESTS_WATERFALL_BACKGROUND_TICKS_MULTIPLE = 5; +const REQUESTS_WATERFALL_BACKGROUND_TICKS_SCALES = 3; +// px +const REQUESTS_WATERFALL_BACKGROUND_TICKS_SPACING_MIN = 10; +const REQUESTS_WATERFALL_BACKGROUND_TICKS_COLOR_RGB = [128, 136, 144]; +const REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_MIN = 32; +// byte +const REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_ADD = 32; +const REQUESTS_WATERFALL_DOMCONTENTLOADED_TICKS_COLOR_RGBA = [255, 0, 0, 128]; +const REQUESTS_WATERFALL_LOAD_TICKS_COLOR_RGBA = [0, 0, 255, 128]; + +// Constants for formatting bytes. +const BYTES_IN_KB = 1024; +const BYTES_IN_MB = Math.pow(BYTES_IN_KB, 2); +const BYTES_IN_GB = Math.pow(BYTES_IN_KB, 3); +const MAX_BYTES_SIZE = 1000; +const MAX_KB_SIZE = 1000 * BYTES_IN_KB; +const MAX_MB_SIZE = 1000 * BYTES_IN_MB; + +// TODO: duplicated from netmonitor-view.js. Move to a format-utils.js module. +const REQUEST_TIME_DECIMALS = 2; +const CONTENT_SIZE_DECIMALS = 2; + +const CONTENT_MIME_TYPE_ABBREVIATIONS = { + "ecmascript": "js", + "javascript": "js", + "x-javascript": "js" +}; + +// A smart store watcher to notify store changes as necessary +function storeWatcher(initialValue, reduceValue, onChange) { + let currentValue = initialValue; + + return () => { + const newValue = reduceValue(currentValue); + if (newValue !== currentValue) { + onChange(newValue, currentValue); + currentValue = newValue; + } + }; +} + +/** + * Functions handling the requests menu (containing details about each request, + * like status, method, file, domain, as well as a waterfall representing + * timing imformation). + */ +function RequestsMenuView() { + dumpn("RequestsMenuView was instantiated"); + + this._flushRequests = this._flushRequests.bind(this); + this._onHover = this._onHover.bind(this); + this._onSelect = this._onSelect.bind(this); + this._onSwap = this._onSwap.bind(this); + this._onResize = this._onResize.bind(this); + this._onScroll = this._onScroll.bind(this); + this._onSecurityIconClick = this._onSecurityIconClick.bind(this); +} + +RequestsMenuView.prototype = Heritage.extend(WidgetMethods, { + /** + * Initialization function, called when the network monitor is started. + */ + initialize: function (store) { + dumpn("Initializing the RequestsMenuView"); + + this.store = store; + + this.contextMenu = new RequestListContextMenu(); + + let widgetParentEl = $("#requests-menu-contents"); + this.widget = new SideMenuWidget(widgetParentEl); + this._splitter = $("#network-inspector-view-splitter"); + this._summary = $("#requests-menu-network-summary-button"); + this._summary.setAttribute("label", L10N.getStr("networkMenu.empty")); + + // Create a tooltip for the newly appended network request item. + this.tooltip = new HTMLTooltip(NetMonitorController._toolbox.doc, { type: "arrow" }); + this.tooltip.startTogglingOnHover(widgetParentEl, this._onHover, { + toggleDelay: REQUESTS_TOOLTIP_TOGGLE_DELAY, + interactive: true + }); + + this.sortContents((a, b) => Sorters.waterfall(a.attachment, b.attachment)); + + this.allowFocusOnRightClick = true; + this.maintainSelectionVisible = true; + + this.widget.addEventListener("select", this._onSelect, false); + this.widget.addEventListener("swap", this._onSwap, false); + this._splitter.addEventListener("mousemove", this._onResize, false); + window.addEventListener("resize", this._onResize, false); + + this.requestsMenuSortEvent = getKeyWithEvent(this.sortBy.bind(this)); + this.requestsMenuSortKeyboardEvent = getKeyWithEvent(this.sortBy.bind(this), true); + this._onContextMenu = this._onContextMenu.bind(this); + this._onContextPerfCommand = () => NetMonitorView.toggleFrontendMode(); + this._onReloadCommand = () => NetMonitorView.reloadPage(); + this._flushRequestsTask = new DeferredTask(this._flushRequests, + REQUESTS_REFRESH_RATE); + + this.sendCustomRequestEvent = this.sendCustomRequest.bind(this); + this.closeCustomRequestEvent = this.closeCustomRequest.bind(this); + this.cloneSelectedRequestEvent = this.cloneSelectedRequest.bind(this); + this.toggleRawHeadersEvent = this.toggleRawHeaders.bind(this); + + this.reFilterRequests = this.reFilterRequests.bind(this); + + $("#toolbar-labels").addEventListener("click", + this.requestsMenuSortEvent, false); + $("#toolbar-labels").addEventListener("keydown", + this.requestsMenuSortKeyboardEvent, false); + $("#toggle-raw-headers").addEventListener("click", + this.toggleRawHeadersEvent, false); + $("#requests-menu-contents").addEventListener("scroll", this._onScroll, true); + $("#requests-menu-contents").addEventListener("contextmenu", this._onContextMenu); + + this.unsubscribeStore = store.subscribe(storeWatcher( + null, + () => store.getState().filters, + (newFilters) => { + this._activeFilters = newFilters.types + .toSeq() + .filter((checked, key) => checked) + .keySeq() + .toArray(); + this._currentFreetextFilter = newFilters.url; + this.reFilterRequests(); + } + )); + + Prefs.filters.forEach(type => + store.dispatch(Actions.toggleFilterType(type))); + + window.once("connected", this._onConnect.bind(this)); + }, + + _onConnect: function () { + $("#requests-menu-reload-notice-button").addEventListener("command", + this._onReloadCommand, false); + + if (NetMonitorController.supportsCustomRequest) { + $("#custom-request-send-button").addEventListener("click", + this.sendCustomRequestEvent, false); + $("#custom-request-close-button").addEventListener("click", + this.closeCustomRequestEvent, false); + $("#headers-summary-resend").addEventListener("click", + this.cloneSelectedRequestEvent, false); + } else { + $("#headers-summary-resend").hidden = true; + } + + if (NetMonitorController.supportsPerfStats) { + $("#requests-menu-perf-notice-button").addEventListener("command", + this._onContextPerfCommand, false); + $("#requests-menu-network-summary-button").addEventListener("command", + this._onContextPerfCommand, false); + $("#network-statistics-back-button").addEventListener("command", + this._onContextPerfCommand, false); + } else { + $("#notice-perf-message").hidden = true; + $("#requests-menu-network-summary-button").hidden = true; + } + + if (!NetMonitorController.supportsTransferredResponseSize) { + $("#requests-menu-transferred-header-box").hidden = true; + $("#requests-menu-item-template .requests-menu-transferred") + .hidden = true; + } + }, + + /** + * Destruction function, called when the network monitor is closed. + */ + destroy: function () { + dumpn("Destroying the RequestsMenuView"); + + Prefs.filters = this._activeFilters; + + /* Destroy the tooltip */ + this.tooltip.stopTogglingOnHover(); + this.tooltip.destroy(); + $("#requests-menu-contents").removeEventListener("scroll", this._onScroll, true); + $("#requests-menu-contents").removeEventListener("contextmenu", this._onContextMenu); + + this.widget.removeEventListener("select", this._onSelect, false); + this.widget.removeEventListener("swap", this._onSwap, false); + this._splitter.removeEventListener("mousemove", this._onResize, false); + window.removeEventListener("resize", this._onResize, false); + + $("#toolbar-labels").removeEventListener("click", + this.requestsMenuSortEvent, false); + $("#toolbar-labels").removeEventListener("keydown", + this.requestsMenuSortKeyboardEvent, false); + + this._flushRequestsTask.disarm(); + + $("#requests-menu-reload-notice-button").removeEventListener("command", + this._onReloadCommand, false); + $("#requests-menu-perf-notice-button").removeEventListener("command", + this._onContextPerfCommand, false); + $("#requests-menu-network-summary-button").removeEventListener("command", + this._onContextPerfCommand, false); + $("#network-statistics-back-button").removeEventListener("command", + this._onContextPerfCommand, false); + + $("#custom-request-send-button").removeEventListener("click", + this.sendCustomRequestEvent, false); + $("#custom-request-close-button").removeEventListener("click", + this.closeCustomRequestEvent, false); + $("#headers-summary-resend").removeEventListener("click", + this.cloneSelectedRequestEvent, false); + $("#toggle-raw-headers").removeEventListener("click", + this.toggleRawHeadersEvent, false); + + this.unsubscribeStore(); + }, + + /** + * Resets this container (removes all the networking information). + */ + reset: function () { + this.empty(); + this._addQueue = []; + this._updateQueue = []; + this._firstRequestStartedMillis = -1; + this._lastRequestEndedMillis = -1; + }, + + /** + * Specifies if this view may be updated lazily. + */ + _lazyUpdate: true, + + get lazyUpdate() { + return this._lazyUpdate; + }, + + set lazyUpdate(value) { + this._lazyUpdate = value; + if (!value) { + this._flushRequests(); + } + }, + + /** + * Adds a network request to this container. + * + * @param string id + * An identifier coming from the network monitor controller. + * @param string startedDateTime + * A string representation of when the request was started, which + * can be parsed by Date (for example "2012-09-17T19:50:03.699Z"). + * @param string method + * Specifies the request method (e.g. "GET", "POST", etc.) + * @param string url + * Specifies the request's url. + * @param boolean isXHR + * True if this request was initiated via XHR. + * @param object cause + * Specifies the request's cause. Has the following properties: + * - type: nsContentPolicyType constant + * - loadingDocumentUri: URI of the request origin + * - stacktrace: JS stacktrace of the request + * @param boolean fromCache + * Indicates if the result came from the browser cache + * @param boolean fromServiceWorker + * Indicates if the request has been intercepted by a Service Worker + */ + addRequest: function (id, startedDateTime, method, url, isXHR, cause, + fromCache, fromServiceWorker) { + this._addQueue.push([id, startedDateTime, method, url, isXHR, cause, + fromCache, fromServiceWorker]); + + // Lazy updating is disabled in some tests. + if (!this.lazyUpdate) { + return void this._flushRequests(); + } + + this._flushRequestsTask.arm(); + return undefined; + }, + + /** + * Create a new custom request form populated with the data from + * the currently selected request. + */ + cloneSelectedRequest: function () { + let selected = this.selectedItem.attachment; + + // Create the element node for the network request item. + let menuView = this._createMenuView(selected.method, selected.url, + selected.cause); + + // Append a network request item to this container. + let newItem = this.push([menuView], { + attachment: Object.create(selected, { + isCustom: { value: true } + }) + }); + + // Immediately switch to new request pane. + this.selectedItem = newItem; + }, + + /** + * Send a new HTTP request using the data in the custom request form. + */ + sendCustomRequest: function () { + let selected = this.selectedItem.attachment; + + let data = { + url: selected.url, + method: selected.method, + httpVersion: selected.httpVersion, + }; + if (selected.requestHeaders) { + data.headers = selected.requestHeaders.headers; + } + if (selected.requestPostData) { + data.body = selected.requestPostData.postData.text; + } + + NetMonitorController.webConsoleClient.sendHTTPRequest(data, response => { + let id = response.eventActor.actor; + this._preferredItemId = id; + }); + + this.closeCustomRequest(); + }, + + /** + * Remove the currently selected custom request. + */ + closeCustomRequest: function () { + this.remove(this.selectedItem); + NetMonitorView.Sidebar.toggle(false); + }, + + /** + * Shows raw request/response headers in textboxes. + */ + toggleRawHeaders: function () { + let requestTextarea = $("#raw-request-headers-textarea"); + let responseTextare = $("#raw-response-headers-textarea"); + let rawHeadersHidden = $("#raw-headers").getAttribute("hidden"); + + if (rawHeadersHidden) { + let selected = this.selectedItem.attachment; + let selectedRequestHeaders = selected.requestHeaders.headers; + let selectedResponseHeaders = selected.responseHeaders.headers; + requestTextarea.value = writeHeaderText(selectedRequestHeaders); + responseTextare.value = writeHeaderText(selectedResponseHeaders); + $("#raw-headers").hidden = false; + } else { + requestTextarea.value = null; + responseTextare.value = null; + $("#raw-headers").hidden = true; + } + }, + + /** + * Refreshes the view contents with the newly selected filters + */ + reFilterRequests: function () { + this.filterContents(this._filterPredicate); + this.refreshSummary(); + this.refreshZebra(); + }, + + /** + * Returns a predicate that can be used to test if a request matches any of + * the active filters. + */ + get _filterPredicate() { + let currentFreetextFilter = this._currentFreetextFilter; + + return requestItem => { + const { attachment } = requestItem; + return this._activeFilters.some(filterName => Filters[filterName](attachment)) && + isFreetextMatch(attachment, currentFreetextFilter); + }; + }, + + /** + * Sorts all network requests in this container by a specified detail. + * + * @param string type + * Either "status", "method", "file", "domain", "type", "transferred", + * "size" or "waterfall". + */ + sortBy: function (type = "waterfall") { + let target = $("#requests-menu-" + type + "-button"); + let headers = document.querySelectorAll(".requests-menu-header-button"); + + for (let header of headers) { + if (header != target) { + header.removeAttribute("sorted"); + header.removeAttribute("tooltiptext"); + header.parentNode.removeAttribute("active"); + } + } + + let direction = ""; + if (target) { + if (target.getAttribute("sorted") == "ascending") { + target.setAttribute("sorted", direction = "descending"); + target.setAttribute("tooltiptext", + L10N.getStr("networkMenu.sortedDesc")); + } else { + target.setAttribute("sorted", direction = "ascending"); + target.setAttribute("tooltiptext", + L10N.getStr("networkMenu.sortedAsc")); + } + // Used to style the next column. + target.parentNode.setAttribute("active", "true"); + } + + // Sort by whatever was requested. + switch (type) { + case "status": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.status(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.status(a.attachment, b.attachment)); + } + break; + case "method": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.method(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.method(a.attachment, b.attachment)); + } + break; + case "file": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.file(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.file(a.attachment, b.attachment)); + } + break; + case "domain": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.domain(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.domain(a.attachment, b.attachment)); + } + break; + case "cause": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.cause(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.cause(a.attachment, b.attachment)); + } + break; + case "type": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.type(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.type(a.attachment, b.attachment)); + } + break; + case "transferred": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.transferred(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.transferred(a.attachment, b.attachment)); + } + break; + case "size": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.size(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.size(a.attachment, b.attachment)); + } + break; + case "waterfall": + if (direction == "ascending") { + this.sortContents((a, b) => Sorters.waterfall(a.attachment, b.attachment)); + } else { + this.sortContents((a, b) => -Sorters.waterfall(a.attachment, b.attachment)); + } + break; + } + + this.refreshSummary(); + this.refreshZebra(); + }, + + /** + * Removes all network requests and closes the sidebar if open. + */ + clear: function () { + NetMonitorController.NetworkEventsHandler.clearMarkers(); + NetMonitorView.Sidebar.toggle(false); + + this.store.dispatch(Actions.disableToggleButton(true)); + $("#requests-menu-empty-notice").hidden = false; + + this.empty(); + this.refreshSummary(); + }, + + /** + * Refreshes the status displayed in this container's footer, providing + * concise information about all requests. + */ + refreshSummary: function () { + let visibleItems = this.visibleItems; + let visibleRequestsCount = visibleItems.length; + if (!visibleRequestsCount) { + this._summary.setAttribute("label", L10N.getStr("networkMenu.empty")); + return; + } + + let totalBytes = this._getTotalBytesOfRequests(visibleItems); + let totalMillis = + this._getNewestRequest(visibleItems).attachment.endedMillis - + this._getOldestRequest(visibleItems).attachment.startedMillis; + + // https://developer.mozilla.org/en-US/docs/Localization_and_Plurals + let str = PluralForm.get(visibleRequestsCount, + L10N.getStr("networkMenu.summary")); + + this._summary.setAttribute("label", str + .replace("#1", visibleRequestsCount) + .replace("#2", L10N.numberWithDecimals((totalBytes || 0) / 1024, + CONTENT_SIZE_DECIMALS)) + .replace("#3", L10N.numberWithDecimals((totalMillis || 0) / 1000, + REQUEST_TIME_DECIMALS)) + ); + }, + + /** + * Adds odd/even attributes to all the visible items in this container. + */ + refreshZebra: function () { + let visibleItems = this.visibleItems; + + for (let i = 0, len = visibleItems.length; i < len; i++) { + let requestItem = visibleItems[i]; + let requestTarget = requestItem.target; + + if (i % 2 == 0) { + requestTarget.setAttribute("even", ""); + requestTarget.removeAttribute("odd"); + } else { + requestTarget.setAttribute("odd", ""); + requestTarget.removeAttribute("even"); + } + } + }, + + /** + * Attaches security icon click listener for the given request menu item. + * + * @param object item + * The network request item to attach the listener to. + */ + attachSecurityIconClickListener: function ({ target }) { + let icon = $(".requests-security-state-icon", target); + icon.addEventListener("click", this._onSecurityIconClick); + }, + + /** + * Schedules adding additional information to a network request. + * + * @param string id + * An identifier coming from the network monitor controller. + * @param object data + * An object containing several { key: value } tuples of network info. + * Supported keys are "httpVersion", "status", "statusText" etc. + * @param function callback + * A function to call once the request has been updated in the view. + */ + updateRequest: function (id, data, callback) { + this._updateQueue.push([id, data, callback]); + + // Lazy updating is disabled in some tests. + if (!this.lazyUpdate) { + return void this._flushRequests(); + } + + this._flushRequestsTask.arm(); + return undefined; + }, + + /** + * Starts adding all queued additional information about network requests. + */ + _flushRequests: function () { + // Prevent displaying any updates received after the target closed. + if (NetMonitorView._isDestroyed) { + return; + } + + let widget = NetMonitorView.RequestsMenu.widget; + let isScrolledToBottom = widget.isScrolledToBottom(); + + for (let [id, startedDateTime, method, url, isXHR, cause, fromCache, + fromServiceWorker] of this._addQueue) { + // Convert the received date/time string to a unix timestamp. + let unixTime = Date.parse(startedDateTime); + + // Create the element node for the network request item. + let menuView = this._createMenuView(method, url, cause); + + // Remember the first and last event boundaries. + this._registerFirstRequestStart(unixTime); + this._registerLastRequestEnd(unixTime); + + // Append a network request item to this container. + let requestItem = this.push([menuView, id], { + attachment: { + startedDeltaMillis: unixTime - this._firstRequestStartedMillis, + startedMillis: unixTime, + method: method, + url: url, + isXHR: isXHR, + cause: cause, + fromCache: fromCache, + fromServiceWorker: fromServiceWorker + } + }); + + if (id == this._preferredItemId) { + this.selectedItem = requestItem; + } + + window.emit(EVENTS.REQUEST_ADDED, id); + } + + if (isScrolledToBottom && this._addQueue.length) { + widget.scrollToBottom(); + } + + // For each queued additional information packet, get the corresponding + // request item in the view and update it based on the specified data. + for (let [id, data, callback] of this._updateQueue) { + let requestItem = this.getItemByValue(id); + if (!requestItem) { + // Packet corresponds to a dead request item, target navigated. + continue; + } + + // Each information packet may contain several { key: value } tuples of + // network info, so update the view based on each one. + for (let key in data) { + let val = data[key]; + if (val === undefined) { + // The information in the packet is empty, it can be safely ignored. + continue; + } + + switch (key) { + case "requestHeaders": + requestItem.attachment.requestHeaders = val; + break; + case "requestCookies": + requestItem.attachment.requestCookies = val; + break; + case "requestPostData": + // Search the POST data upload stream for request headers and add + // them to a separate store, different from the classic headers. + // XXX: Be really careful here! We're creating a function inside + // a loop, so remember the actual request item we want to modify. + let currentItem = requestItem; + let currentStore = { headers: [], headersSize: 0 }; + + Task.spawn(function* () { + let postData = yield gNetwork.getString(val.postData.text); + let payloadHeaders = CurlUtils.getHeadersFromMultipartText( + postData); + + currentStore.headers = payloadHeaders; + currentStore.headersSize = payloadHeaders.reduce( + (acc, { name, value }) => + acc + name.length + value.length + 2, 0); + + // The `getString` promise is async, so we need to refresh the + // information displayed in the network details pane again here. + refreshNetworkDetailsPaneIfNecessary(currentItem); + }); + + requestItem.attachment.requestPostData = val; + requestItem.attachment.requestHeadersFromUploadStream = + currentStore; + break; + case "securityState": + requestItem.attachment.securityState = val; + this.updateMenuView(requestItem, key, val); + break; + case "securityInfo": + requestItem.attachment.securityInfo = val; + break; + case "responseHeaders": + requestItem.attachment.responseHeaders = val; + break; + case "responseCookies": + requestItem.attachment.responseCookies = val; + break; + case "httpVersion": + requestItem.attachment.httpVersion = val; + break; + case "remoteAddress": + requestItem.attachment.remoteAddress = val; + this.updateMenuView(requestItem, key, val); + break; + case "remotePort": + requestItem.attachment.remotePort = val; + break; + case "status": + requestItem.attachment.status = val; + this.updateMenuView(requestItem, key, { + status: val, + cached: requestItem.attachment.fromCache, + serviceWorker: requestItem.attachment.fromServiceWorker + }); + break; + case "statusText": + requestItem.attachment.statusText = val; + let text = (requestItem.attachment.status + " " + + requestItem.attachment.statusText); + if (requestItem.attachment.fromCache) { + text += " (cached)"; + } else if (requestItem.attachment.fromServiceWorker) { + text += " (service worker)"; + } + + this.updateMenuView(requestItem, key, text); + break; + case "headersSize": + requestItem.attachment.headersSize = val; + break; + case "contentSize": + requestItem.attachment.contentSize = val; + this.updateMenuView(requestItem, key, val); + break; + case "transferredSize": + if (requestItem.attachment.fromCache) { + requestItem.attachment.transferredSize = 0; + this.updateMenuView(requestItem, key, "cached"); + } else if (requestItem.attachment.fromServiceWorker) { + requestItem.attachment.transferredSize = 0; + this.updateMenuView(requestItem, key, "service worker"); + } else { + requestItem.attachment.transferredSize = val; + this.updateMenuView(requestItem, key, val); + } + break; + case "mimeType": + requestItem.attachment.mimeType = val; + this.updateMenuView(requestItem, key, val); + break; + case "responseContent": + // If there's no mime type available when the response content + // is received, assume text/plain as a fallback. + if (!requestItem.attachment.mimeType) { + requestItem.attachment.mimeType = "text/plain"; + this.updateMenuView(requestItem, "mimeType", "text/plain"); + } + requestItem.attachment.responseContent = val; + this.updateMenuView(requestItem, key, val); + break; + case "totalTime": + requestItem.attachment.totalTime = val; + requestItem.attachment.endedMillis = + requestItem.attachment.startedMillis + val; + + this.updateMenuView(requestItem, key, val); + this._registerLastRequestEnd(requestItem.attachment.endedMillis); + break; + case "eventTimings": + requestItem.attachment.eventTimings = val; + this._createWaterfallView( + requestItem, val.timings, + requestItem.attachment.fromCache || + requestItem.attachment.fromServiceWorker + ); + break; + } + } + refreshNetworkDetailsPaneIfNecessary(requestItem); + + if (callback) { + callback(); + } + } + + /** + * Refreshes the information displayed in the sidebar, in case this update + * may have additional information about a request which isn't shown yet + * in the network details pane. + * + * @param object requestItem + * The item to repopulate the sidebar with in case it's selected in + * this requests menu. + */ + function refreshNetworkDetailsPaneIfNecessary(requestItem) { + let selectedItem = NetMonitorView.RequestsMenu.selectedItem; + if (selectedItem == requestItem) { + NetMonitorView.NetworkDetails.populate(selectedItem.attachment); + } + } + + // We're done flushing all the requests, clear the update queue. + this._updateQueue = []; + this._addQueue = []; + + this.store.dispatch(Actions.disableToggleButton(!this.itemCount)); + $("#requests-menu-empty-notice").hidden = !!this.itemCount; + + // Make sure all the requests are sorted and filtered. + // Freshly added requests may not yet contain all the information required + // for sorting and filtering predicates, so this is done each time the + // network requests table is flushed (don't worry, events are drained first + // so this doesn't happen once per network event update). + this.sortContents(); + this.filterContents(); + this.refreshSummary(); + this.refreshZebra(); + + // Rescale all the waterfalls so that everything is visible at once. + this._flushWaterfallViews(); + }, + + /** + * Customization function for creating an item's UI. + * + * @param string method + * Specifies the request method (e.g. "GET", "POST", etc.) + * @param string url + * Specifies the request's url. + * @param object cause + * Specifies the request's cause. Has two properties: + * - type: nsContentPolicyType constant + * - uri: URI of the request origin + * @return nsIDOMNode + * The network request view. + */ + _createMenuView: function (method, url, cause) { + let template = $("#requests-menu-item-template"); + let fragment = document.createDocumentFragment(); + + // Flatten the DOM by removing one redundant box (the template container). + for (let node of template.childNodes) { + fragment.appendChild(node.cloneNode(true)); + } + + this.updateMenuView(fragment, "method", method); + this.updateMenuView(fragment, "url", url); + this.updateMenuView(fragment, "cause", cause); + + return fragment; + }, + + /** + * Get a human-readable string from a number of bytes, with the B, KB, MB, or + * GB value. Note that the transition between abbreviations is by 1000 rather + * than 1024 in order to keep the displayed digits smaller as "1016 KB" is + * more awkward than 0.99 MB" + */ + getFormattedSize(bytes) { + if (bytes < MAX_BYTES_SIZE) { + return L10N.getFormatStr("networkMenu.sizeB", bytes); + } else if (bytes < MAX_KB_SIZE) { + let kb = bytes / BYTES_IN_KB; + let size = L10N.numberWithDecimals(kb, CONTENT_SIZE_DECIMALS); + return L10N.getFormatStr("networkMenu.sizeKB", size); + } else if (bytes < MAX_MB_SIZE) { + let mb = bytes / BYTES_IN_MB; + let size = L10N.numberWithDecimals(mb, CONTENT_SIZE_DECIMALS); + return L10N.getFormatStr("networkMenu.sizeMB", size); + } + let gb = bytes / BYTES_IN_GB; + let size = L10N.numberWithDecimals(gb, CONTENT_SIZE_DECIMALS); + return L10N.getFormatStr("networkMenu.sizeGB", size); + }, + + /** + * Updates the information displayed in a network request item view. + * + * @param object item + * The network request item in this container. + * @param string key + * The type of information that is to be updated. + * @param any value + * The new value to be shown. + * @return object + * A promise that is resolved once the information is displayed. + */ + updateMenuView: Task.async(function* (item, key, value) { + let target = item.target || item; + + switch (key) { + case "method": { + let node = $(".requests-menu-method", target); + node.setAttribute("value", value); + break; + } + case "url": { + let uri; + try { + uri = NetworkHelper.nsIURL(value); + } catch (e) { + // User input may not make a well-formed url yet. + break; + } + let nameWithQuery = getUriNameWithQuery(uri); + let hostPort = getUriHostPort(uri); + let host = getUriHost(uri); + let unicodeUrl = NetworkHelper.convertToUnicode(unescape(uri.spec)); + + let file = $(".requests-menu-file", target); + file.setAttribute("value", nameWithQuery); + file.setAttribute("tooltiptext", unicodeUrl); + + let domain = $(".requests-menu-domain", target); + domain.setAttribute("value", hostPort); + domain.setAttribute("tooltiptext", hostPort); + + // Mark local hosts specially, where "local" is as defined in the W3C + // spec for secure contexts. + // http://www.w3.org/TR/powerful-features/ + // + // * If the name falls under 'localhost' + // * If the name is an IPv4 address within 127.0.0.0/8 + // * If the name is an IPv6 address within ::1/128 + // + // IPv6 parsing is a little sloppy; it assumes that the address has + // been validated before it gets here. + let icon = $(".requests-security-state-icon", target); + icon.classList.remove("security-state-local"); + if (host.match(/(.+\.)?localhost$/) || + host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}/) || + host.match(/\[[0:]+1\]/)) { + let tooltip = L10N.getStr("netmonitor.security.state.secure"); + icon.classList.add("security-state-local"); + icon.setAttribute("tooltiptext", tooltip); + } + + break; + } + case "remoteAddress": + let domain = $(".requests-menu-domain", target); + let tooltip = (domain.getAttribute("value") + + (value ? " (" + value + ")" : "")); + domain.setAttribute("tooltiptext", tooltip); + break; + case "securityState": { + let icon = $(".requests-security-state-icon", target); + this.attachSecurityIconClickListener(item); + + // Security icon for local hosts is set in the "url" branch + if (icon.classList.contains("security-state-local")) { + break; + } + + let tooltip2 = L10N.getStr("netmonitor.security.state." + value); + icon.classList.add("security-state-" + value); + icon.setAttribute("tooltiptext", tooltip2); + break; + } + case "status": { + let node = $(".requests-menu-status-icon", target); + // "code" attribute is only used by css to determine the icon color + let code; + if (value.cached) { + code = "cached"; + } else if (value.serviceWorker) { + code = "service worker"; + } else { + code = value.status; + } + node.setAttribute("code", code); + let codeNode = $(".requests-menu-status-code", target); + codeNode.setAttribute("value", value.status); + break; + } + case "statusText": { + let node = $(".requests-menu-status", target); + node.setAttribute("tooltiptext", value); + break; + } + case "cause": { + let labelNode = $(".requests-menu-cause-label", target); + labelNode.setAttribute("value", loadCauseString(value.type)); + if (value.loadingDocumentUri) { + labelNode.setAttribute("tooltiptext", value.loadingDocumentUri); + } + + let stackNode = $(".requests-menu-cause-stack", target); + if (value.stacktrace && value.stacktrace.length > 0) { + stackNode.removeAttribute("hidden"); + } + break; + } + case "contentSize": { + let node = $(".requests-menu-size", target); + + let text = this.getFormattedSize(value); + + node.setAttribute("value", text); + node.setAttribute("tooltiptext", text); + break; + } + case "transferredSize": { + let node = $(".requests-menu-transferred", target); + + let text; + if (value === null) { + text = L10N.getStr("networkMenu.sizeUnavailable"); + } else if (value === "cached") { + text = L10N.getStr("networkMenu.sizeCached"); + node.classList.add("theme-comment"); + } else if (value === "service worker") { + text = L10N.getStr("networkMenu.sizeServiceWorker"); + node.classList.add("theme-comment"); + } else { + text = this.getFormattedSize(value); + } + + node.setAttribute("value", text); + node.setAttribute("tooltiptext", text); + break; + } + case "mimeType": { + let type = getAbbreviatedMimeType(value); + let node = $(".requests-menu-type", target); + let text = CONTENT_MIME_TYPE_ABBREVIATIONS[type] || type; + node.setAttribute("value", text); + node.setAttribute("tooltiptext", value); + break; + } + case "responseContent": { + let { mimeType } = item.attachment; + + if (mimeType.includes("image/")) { + let { text, encoding } = value.content; + let responseBody = yield gNetwork.getString(text); + let node = $(".requests-menu-icon", item.target); + node.src = formDataURI(mimeType, encoding, responseBody); + node.setAttribute("type", "thumbnail"); + node.removeAttribute("hidden"); + + window.emit(EVENTS.RESPONSE_IMAGE_THUMBNAIL_DISPLAYED); + } + break; + } + case "totalTime": { + let node = $(".requests-menu-timings-total", target); + + // integer + let text = L10N.getFormatStr("networkMenu.totalMS", value); + node.setAttribute("value", text); + node.setAttribute("tooltiptext", text); + break; + } + } + }), + + /** + * Creates a waterfall representing timing information in a network + * request item view. + * + * @param object item + * The network request item in this container. + * @param object timings + * An object containing timing information. + * @param boolean fromCache + * Indicates if the result came from the browser cache or + * a service worker + */ + _createWaterfallView: function (item, timings, fromCache) { + let { target } = item; + let sections = ["blocked", "dns", "connect", "send", "wait", "receive"]; + // Skipping "blocked" because it doesn't work yet. + + let timingsNode = $(".requests-menu-timings", target); + let timingsTotal = $(".requests-menu-timings-total", timingsNode); + + if (fromCache) { + timingsTotal.style.display = "none"; + return; + } + + // Add a set of boxes representing timing information. + for (let key of sections) { + let width = timings[key]; + + // Don't render anything if it surely won't be visible. + // One millisecond == one unscaled pixel. + if (width > 0) { + let timingBox = document.createElement("hbox"); + timingBox.className = "requests-menu-timings-box " + key; + timingBox.setAttribute("width", width); + timingsNode.insertBefore(timingBox, timingsTotal); + } + } + }, + + /** + * Rescales and redraws all the waterfall views in this container. + * + * @param boolean reset + * True if this container's width was changed. + */ + _flushWaterfallViews: function (reset) { + // Don't paint things while the waterfall view isn't even visible, + // or there are no items added to this container. + if (NetMonitorView.currentFrontendMode != + "network-inspector-view" || !this.itemCount) { + return; + } + + // To avoid expensive operations like getBoundingClientRect() and + // rebuilding the waterfall background each time a new request comes in, + // stuff is cached. However, in certain scenarios like when the window + // is resized, this needs to be invalidated. + if (reset) { + this._cachedWaterfallWidth = 0; + } + + // Determine the scaling to be applied to all the waterfalls so that + // everything is visible at once. One millisecond == one unscaled pixel. + let availableWidth = this._waterfallWidth - REQUESTS_WATERFALL_SAFE_BOUNDS; + let longestWidth = this._lastRequestEndedMillis - + this._firstRequestStartedMillis; + let scale = Math.min(Math.max(availableWidth / longestWidth, EPSILON), 1); + + // Redraw and set the canvas background for each waterfall view. + this._showWaterfallDivisionLabels(scale); + this._drawWaterfallBackground(scale); + + // Apply CSS transforms to each waterfall in this container totalTime + // accurately translate and resize as needed. + for (let { target, attachment } of this) { + let timingsNode = $(".requests-menu-timings", target); + let totalNode = $(".requests-menu-timings-total", target); + let direction = window.isRTL ? -1 : 1; + + // Render the timing information at a specific horizontal translation + // based on the delta to the first monitored event network. + let translateX = "translateX(" + (direction * + attachment.startedDeltaMillis) + "px)"; + + // Based on the total time passed until the last request, rescale + // all the waterfalls to a reasonable size. + let scaleX = "scaleX(" + scale + ")"; + + // Certain nodes should not be scaled, even if they're children of + // another scaled node. In this case, apply a reversed transformation. + let revScaleX = "scaleX(" + (1 / scale) + ")"; + + timingsNode.style.transform = scaleX + " " + translateX; + totalNode.style.transform = revScaleX; + } + }, + + /** + * Creates the labels displayed on the waterfall header in this container. + * + * @param number scale + * The current waterfall scale. + */ + _showWaterfallDivisionLabels: function (scale) { + let container = $("#requests-menu-waterfall-label-wrapper"); + let availableWidth = this._waterfallWidth - REQUESTS_WATERFALL_SAFE_BOUNDS; + + // Nuke all existing labels. + while (container.hasChildNodes()) { + container.firstChild.remove(); + } + + // Build new millisecond tick labels... + let timingStep = REQUESTS_WATERFALL_HEADER_TICKS_MULTIPLE; + let optimalTickIntervalFound = false; + + while (!optimalTickIntervalFound) { + // Ignore any divisions that would end up being too close to each other. + let scaledStep = scale * timingStep; + if (scaledStep < REQUESTS_WATERFALL_HEADER_TICKS_SPACING_MIN) { + timingStep <<= 1; + continue; + } + optimalTickIntervalFound = true; + + // Insert one label for each division on the current scale. + let fragment = document.createDocumentFragment(); + let direction = window.isRTL ? -1 : 1; + + for (let x = 0; x < availableWidth; x += scaledStep) { + let translateX = "translateX(" + ((direction * x) | 0) + "px)"; + let millisecondTime = x / scale; + + let normalizedTime = millisecondTime; + let divisionScale = "millisecond"; + + // If the division is greater than 1 minute. + if (normalizedTime > 60000) { + normalizedTime /= 60000; + divisionScale = "minute"; + } else if (normalizedTime > 1000) { + // If the division is greater than 1 second. + normalizedTime /= 1000; + divisionScale = "second"; + } + + // Showing too many decimals is bad UX. + if (divisionScale == "millisecond") { + normalizedTime |= 0; + } else { + normalizedTime = L10N.numberWithDecimals(normalizedTime, + REQUEST_TIME_DECIMALS); + } + + let node = document.createElement("label"); + let text = L10N.getFormatStr("networkMenu." + + divisionScale, normalizedTime); + node.className = "plain requests-menu-timings-division"; + node.setAttribute("division-scale", divisionScale); + node.style.transform = translateX; + + node.setAttribute("value", text); + fragment.appendChild(node); + } + container.appendChild(fragment); + + container.className = "requests-menu-waterfall-visible"; + } + }, + + /** + * Creates the background displayed on each waterfall view in this container. + * + * @param number scale + * The current waterfall scale. + */ + _drawWaterfallBackground: function (scale) { + if (!this._canvas || !this._ctx) { + this._canvas = document.createElementNS(HTML_NS, "canvas"); + this._ctx = this._canvas.getContext("2d"); + } + let canvas = this._canvas; + let ctx = this._ctx; + + // Nuke the context. + let canvasWidth = canvas.width = this._waterfallWidth; + // Awww yeah, 1px, repeats on Y axis. + let canvasHeight = canvas.height = 1; + + // Start over. + let imageData = ctx.createImageData(canvasWidth, canvasHeight); + let pixelArray = imageData.data; + + let buf = new ArrayBuffer(pixelArray.length); + let view8bit = new Uint8ClampedArray(buf); + let view32bit = new Uint32Array(buf); + + // Build new millisecond tick lines... + let timingStep = REQUESTS_WATERFALL_BACKGROUND_TICKS_MULTIPLE; + let [r, g, b] = REQUESTS_WATERFALL_BACKGROUND_TICKS_COLOR_RGB; + let alphaComponent = REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_MIN; + let optimalTickIntervalFound = false; + + while (!optimalTickIntervalFound) { + // Ignore any divisions that would end up being too close to each other. + let scaledStep = scale * timingStep; + if (scaledStep < REQUESTS_WATERFALL_BACKGROUND_TICKS_SPACING_MIN) { + timingStep <<= 1; + continue; + } + optimalTickIntervalFound = true; + + // Insert one pixel for each division on each scale. + for (let i = 1; i <= REQUESTS_WATERFALL_BACKGROUND_TICKS_SCALES; i++) { + let increment = scaledStep * Math.pow(2, i); + for (let x = 0; x < canvasWidth; x += increment) { + let position = (window.isRTL ? canvasWidth - x : x) | 0; + view32bit[position] = + (alphaComponent << 24) | (b << 16) | (g << 8) | r; + } + alphaComponent += REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_ADD; + } + } + + { + let t = NetMonitorController.NetworkEventsHandler + .firstDocumentDOMContentLoadedTimestamp; + + let delta = Math.floor((t - this._firstRequestStartedMillis) * scale); + let [r1, g1, b1, a1] = + REQUESTS_WATERFALL_DOMCONTENTLOADED_TICKS_COLOR_RGBA; + view32bit[delta] = (a1 << 24) | (r1 << 16) | (g1 << 8) | b1; + } + { + let t = NetMonitorController.NetworkEventsHandler + .firstDocumentLoadTimestamp; + + let delta = Math.floor((t - this._firstRequestStartedMillis) * scale); + let [r2, g2, b2, a2] = REQUESTS_WATERFALL_LOAD_TICKS_COLOR_RGBA; + view32bit[delta] = (a2 << 24) | (r2 << 16) | (g2 << 8) | b2; + } + + // Flush the image data and cache the waterfall background. + pixelArray.set(view8bit); + ctx.putImageData(imageData, 0, 0); + document.mozSetImageElement("waterfall-background", canvas); + }, + + /** + * The selection listener for this container. + */ + _onSelect: function ({ detail: item }) { + if (item) { + NetMonitorView.Sidebar.populate(item.attachment); + NetMonitorView.Sidebar.toggle(true); + } else { + NetMonitorView.Sidebar.toggle(false); + } + }, + + /** + * The swap listener for this container. + * Called when two items switch places, when the contents are sorted. + */ + _onSwap: function ({ detail: [firstItem, secondItem] }) { + // Reattach click listener to the security icons + this.attachSecurityIconClickListener(firstItem); + this.attachSecurityIconClickListener(secondItem); + }, + + /** + * The predicate used when deciding whether a popup should be shown + * over a request item or not. + * + * @param nsIDOMNode target + * The element node currently being hovered. + * @param object tooltip + * The current tooltip instance. + * @return {Promise} + */ + _onHover: Task.async(function* (target, tooltip) { + let requestItem = this.getItemForElement(target); + if (!requestItem) { + return false; + } + + let hovered = requestItem.attachment; + if (hovered.responseContent && target.closest(".requests-menu-icon-and-file")) { + return this._setTooltipImageContent(tooltip, requestItem); + } else if (hovered.cause && target.closest(".requests-menu-cause-stack")) { + return this._setTooltipStackTraceContent(tooltip, requestItem); + } + + return false; + }), + + _setTooltipImageContent: Task.async(function* (tooltip, requestItem) { + let { mimeType, text, encoding } = requestItem.attachment.responseContent.content; + + if (!mimeType || !mimeType.includes("image/")) { + return false; + } + + let string = yield gNetwork.getString(text); + let src = formDataURI(mimeType, encoding, string); + let maxDim = REQUESTS_TOOLTIP_IMAGE_MAX_DIM; + let { naturalWidth, naturalHeight } = yield getImageDimensions(tooltip.doc, src); + let options = { maxDim, naturalWidth, naturalHeight }; + setImageTooltip(tooltip, tooltip.doc, src, options); + + return $(".requests-menu-icon", requestItem.target); + }), + + _setTooltipStackTraceContent: Task.async(function* (tooltip, requestItem) { + let {stacktrace} = requestItem.attachment.cause; + + if (!stacktrace || stacktrace.length == 0) { + return false; + } + + let doc = tooltip.doc; + let el = doc.createElementNS(HTML_NS, "div"); + el.className = "stack-trace-tooltip devtools-monospace"; + + for (let f of stacktrace) { + let { functionName, filename, lineNumber, columnNumber, asyncCause } = f; + + if (asyncCause) { + // if there is asyncCause, append a "divider" row into the trace + let asyncFrameEl = doc.createElementNS(HTML_NS, "div"); + asyncFrameEl.className = "stack-frame stack-frame-async"; + asyncFrameEl.textContent = + WEBCONSOLE_L10N.getFormatStr("stacktrace.asyncStack", asyncCause); + el.appendChild(asyncFrameEl); + } + + // Parse a source name in format "url -> url" + let sourceUrl = filename.split(" -> ").pop(); + + let frameEl = doc.createElementNS(HTML_NS, "div"); + frameEl.className = "stack-frame stack-frame-call"; + + let funcEl = doc.createElementNS(HTML_NS, "span"); + funcEl.className = "stack-frame-function-name"; + funcEl.textContent = + functionName || WEBCONSOLE_L10N.getStr("stacktrace.anonymousFunction"); + frameEl.appendChild(funcEl); + + let sourceEl = doc.createElementNS(HTML_NS, "span"); + sourceEl.className = "stack-frame-source-name"; + frameEl.appendChild(sourceEl); + + let sourceInnerEl = doc.createElementNS(HTML_NS, "span"); + sourceInnerEl.className = "stack-frame-source-name-inner"; + sourceEl.appendChild(sourceInnerEl); + + sourceInnerEl.textContent = sourceUrl; + sourceInnerEl.title = sourceUrl; + + let lineEl = doc.createElementNS(HTML_NS, "span"); + lineEl.className = "stack-frame-line"; + lineEl.textContent = `:${lineNumber}:${columnNumber}`; + sourceInnerEl.appendChild(lineEl); + + frameEl.addEventListener("click", () => { + // hide the tooltip immediately, not after delay + tooltip.hide(); + NetMonitorController.viewSourceInDebugger(filename, lineNumber); + }, false); + + el.appendChild(frameEl); + } + + tooltip.setContent(el, {width: REQUESTS_TOOLTIP_STACK_TRACE_WIDTH}); + + return true; + }), + + /** + * A handler that opens the security tab in the details view if secure or + * broken security indicator is clicked. + */ + _onSecurityIconClick: function (e) { + let state = this.selectedItem.attachment.securityState; + if (state !== "insecure") { + // Choose the security tab. + NetMonitorView.NetworkDetails.widget.selectedIndex = 5; + } + }, + + /** + * The resize listener for this container's window. + */ + _onResize: function (e) { + // Allow requests to settle down first. + setNamedTimeout("resize-events", + RESIZE_REFRESH_RATE, () => this._flushWaterfallViews(true)); + }, + + /** + * Scroll listener for the requests menu view. + */ + _onScroll: function () { + this.tooltip.hide(); + }, + + /** + * Open context menu + */ + _onContextMenu: function (e) { + e.preventDefault(); + this.contextMenu.open(e); + }, + + /** + * Checks if the specified unix time is the first one to be known of, + * and saves it if so. + * + * @param number unixTime + * The milliseconds to check and save. + */ + _registerFirstRequestStart: function (unixTime) { + if (this._firstRequestStartedMillis == -1) { + this._firstRequestStartedMillis = unixTime; + } + }, + + /** + * Checks if the specified unix time is the last one to be known of, + * and saves it if so. + * + * @param number unixTime + * The milliseconds to check and save. + */ + _registerLastRequestEnd: function (unixTime) { + if (this._lastRequestEndedMillis < unixTime) { + this._lastRequestEndedMillis = unixTime; + } + }, + + /** + * Gets the total number of bytes representing the cumulated content size of + * a set of requests. Returns 0 for an empty set. + * + * @param array itemsArray + * @return number + */ + _getTotalBytesOfRequests: function (itemsArray) { + if (!itemsArray.length) { + return 0; + } + + let result = 0; + itemsArray.forEach(item => { + let size = item.attachment.contentSize; + result += (typeof size == "number") ? size : 0; + }); + + return result; + }, + + /** + * Gets the oldest (first performed) request in a set. Returns null for an + * empty set. + * + * @param array itemsArray + * @return object + */ + _getOldestRequest: function (itemsArray) { + if (!itemsArray.length) { + return null; + } + return itemsArray.reduce((prev, curr) => + prev.attachment.startedMillis < curr.attachment.startedMillis ? + prev : curr); + }, + + /** + * Gets the newest (latest performed) request in a set. Returns null for an + * empty set. + * + * @param array itemsArray + * @return object + */ + _getNewestRequest: function (itemsArray) { + if (!itemsArray.length) { + return null; + } + return itemsArray.reduce((prev, curr) => + prev.attachment.startedMillis > curr.attachment.startedMillis ? + prev : curr); + }, + + /** + * Gets the available waterfall width in this container. + * @return number + */ + get _waterfallWidth() { + if (this._cachedWaterfallWidth == 0) { + let container = $("#requests-menu-toolbar"); + let waterfall = $("#requests-menu-waterfall-header-box"); + let containerBounds = container.getBoundingClientRect(); + let waterfallBounds = waterfall.getBoundingClientRect(); + if (!window.isRTL) { + this._cachedWaterfallWidth = containerBounds.width - + waterfallBounds.left; + } else { + this._cachedWaterfallWidth = waterfallBounds.right; + } + } + return this._cachedWaterfallWidth; + }, + + _splitter: null, + _summary: null, + _canvas: null, + _ctx: null, + _cachedWaterfallWidth: 0, + _firstRequestStartedMillis: -1, + _lastRequestEndedMillis: -1, + _updateQueue: [], + _addQueue: [], + _updateTimeout: null, + _resizeTimeout: null, + _activeFilters: ["all"], + _currentFreetextFilter: "" +}); + +exports.RequestsMenuView = RequestsMenuView; |