summaryrefslogtreecommitdiffstats
path: root/devtools/client/performance/modules/widgets
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /devtools/client/performance/modules/widgets
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/performance/modules/widgets')
-rw-r--r--devtools/client/performance/modules/widgets/graphs.js514
-rw-r--r--devtools/client/performance/modules/widgets/marker-details.js164
-rw-r--r--devtools/client/performance/modules/widgets/markers-overview.js243
-rw-r--r--devtools/client/performance/modules/widgets/moz.build11
-rw-r--r--devtools/client/performance/modules/widgets/tree-view.js406
5 files changed, 1338 insertions, 0 deletions
diff --git a/devtools/client/performance/modules/widgets/graphs.js b/devtools/client/performance/modules/widgets/graphs.js
new file mode 100644
index 000000000..9d9262027
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/graphs.js
@@ -0,0 +1,514 @@
+/* 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";
+
+/**
+ * This file contains the base line graph that all Performance line graphs use.
+ */
+
+const { Task } = require("devtools/shared/task");
+const { Heritage } = require("devtools/client/shared/widgets/view-helpers");
+const LineGraphWidget = require("devtools/client/shared/widgets/LineGraphWidget");
+const MountainGraphWidget = require("devtools/client/shared/widgets/MountainGraphWidget");
+const { CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs");
+
+const promise = require("promise");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+const { colorUtils } = require("devtools/shared/css/color");
+const { getColor } = require("devtools/client/shared/theme");
+const ProfilerGlobal = require("devtools/client/performance/modules/global");
+const { MarkersOverview } = require("devtools/client/performance/modules/widgets/markers-overview");
+const { createTierGraphDataFromFrameNode } = require("devtools/client/performance/modules/logic/jit");
+
+/**
+ * For line graphs
+ */
+// px
+const HEIGHT = 35;
+// px
+const STROKE_WIDTH = 1;
+const DAMPEN_VALUES = 0.95;
+const CLIPHEAD_LINE_COLOR = "#666";
+const SELECTION_LINE_COLOR = "#555";
+const SELECTION_BACKGROUND_COLOR_NAME = "graphs-blue";
+const FRAMERATE_GRAPH_COLOR_NAME = "graphs-green";
+const MEMORY_GRAPH_COLOR_NAME = "graphs-blue";
+
+/**
+ * For timeline overview
+ */
+// px
+const MARKERS_GRAPH_HEADER_HEIGHT = 14;
+// px
+const MARKERS_GRAPH_ROW_HEIGHT = 10;
+// px
+const MARKERS_GROUP_VERTICAL_PADDING = 4;
+
+/**
+ * For optimization graph
+ */
+const OPTIMIZATIONS_GRAPH_RESOLUTION = 100;
+
+/**
+ * A base class for performance graphs to inherit from.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the overview.
+ * @param string metric
+ * The unit of measurement for this graph.
+ */
+function PerformanceGraph(parent, metric) {
+ LineGraphWidget.call(this, parent, { metric });
+ this.setTheme();
+}
+
+PerformanceGraph.prototype = Heritage.extend(LineGraphWidget.prototype, {
+ strokeWidth: STROKE_WIDTH,
+ dampenValuesFactor: DAMPEN_VALUES,
+ fixedHeight: HEIGHT,
+ clipheadLineColor: CLIPHEAD_LINE_COLOR,
+ selectionLineColor: SELECTION_LINE_COLOR,
+ withTooltipArrows: false,
+ withFixedTooltipPositions: true,
+
+ /**
+ * Disables selection and empties this graph.
+ */
+ clearView: function () {
+ this.selectionEnabled = false;
+ this.dropSelection();
+ this.setData([]);
+ },
+
+ /**
+ * Sets the theme via `theme` to either "light" or "dark",
+ * and updates the internal styling to match. Requires a redraw
+ * to see the effects.
+ */
+ setTheme: function (theme) {
+ theme = theme || "light";
+ let mainColor = getColor(this.mainColor || "graphs-blue", theme);
+ this.backgroundColor = getColor("body-background", theme);
+ this.strokeColor = mainColor;
+ this.backgroundGradientStart = colorUtils.setAlpha(mainColor, 0.2);
+ this.backgroundGradientEnd = colorUtils.setAlpha(mainColor, 0.2);
+ this.selectionBackgroundColor = colorUtils.setAlpha(
+ getColor(SELECTION_BACKGROUND_COLOR_NAME, theme), 0.25);
+ this.selectionStripesColor = "rgba(255, 255, 255, 0.1)";
+ this.maximumLineColor = colorUtils.setAlpha(mainColor, 0.4);
+ this.averageLineColor = colorUtils.setAlpha(mainColor, 0.7);
+ this.minimumLineColor = colorUtils.setAlpha(mainColor, 0.9);
+ }
+});
+
+/**
+ * Constructor for the framerate graph. Inherits from PerformanceGraph.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the overview.
+ */
+function FramerateGraph(parent) {
+ PerformanceGraph.call(this, parent, ProfilerGlobal.L10N.getStr("graphs.fps"));
+}
+
+FramerateGraph.prototype = Heritage.extend(PerformanceGraph.prototype, {
+ mainColor: FRAMERATE_GRAPH_COLOR_NAME,
+ setPerformanceData: function ({ duration, ticks }, resolution) {
+ this.dataDuration = duration;
+ return this.setDataFromTimestamps(ticks, resolution, duration);
+ }
+});
+
+/**
+ * Constructor for the memory graph. Inherits from PerformanceGraph.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the overview.
+ */
+function MemoryGraph(parent) {
+ PerformanceGraph.call(this, parent, ProfilerGlobal.L10N.getStr("graphs.memory"));
+}
+
+MemoryGraph.prototype = Heritage.extend(PerformanceGraph.prototype, {
+ mainColor: MEMORY_GRAPH_COLOR_NAME,
+ setPerformanceData: function ({ duration, memory }) {
+ this.dataDuration = duration;
+ return this.setData(memory);
+ }
+});
+
+function TimelineGraph(parent, filter) {
+ MarkersOverview.call(this, parent, filter);
+}
+
+TimelineGraph.prototype = Heritage.extend(MarkersOverview.prototype, {
+ headerHeight: MARKERS_GRAPH_HEADER_HEIGHT,
+ rowHeight: MARKERS_GRAPH_ROW_HEIGHT,
+ groupPadding: MARKERS_GROUP_VERTICAL_PADDING,
+ setPerformanceData: MarkersOverview.prototype.setData
+});
+
+/**
+ * Definitions file for GraphsController, indicating the constructor,
+ * selector and other meta for each of the graphs controller by
+ * GraphsController.
+ */
+const GRAPH_DEFINITIONS = {
+ memory: {
+ constructor: MemoryGraph,
+ selector: "#memory-overview",
+ },
+ framerate: {
+ constructor: FramerateGraph,
+ selector: "#time-framerate",
+ },
+ timeline: {
+ constructor: TimelineGraph,
+ selector: "#markers-overview",
+ primaryLink: true
+ }
+};
+
+/**
+ * A controller for orchestrating the performance's tool overview graphs. Constructs,
+ * syncs, toggles displays and defines the memory, framerate and timeline view.
+ *
+ * @param {object} definition
+ * @param {DOMElement} root
+ * @param {function} getFilter
+ * @param {function} getTheme
+ */
+function GraphsController({ definition, root, getFilter, getTheme }) {
+ this._graphs = {};
+ this._enabled = new Set();
+ this._definition = definition || GRAPH_DEFINITIONS;
+ this._root = root;
+ this._getFilter = getFilter;
+ this._getTheme = getTheme;
+ this._primaryLink = Object.keys(this._definition)
+ .filter(name => this._definition[name].primaryLink)[0];
+ this.$ = root.ownerDocument.querySelector.bind(root.ownerDocument);
+
+ EventEmitter.decorate(this);
+ this._onSelecting = this._onSelecting.bind(this);
+}
+
+GraphsController.prototype = {
+
+ /**
+ * Returns the corresponding graph by `graphName`.
+ */
+ get: function (graphName) {
+ return this._graphs[graphName];
+ },
+
+ /**
+ * Iterates through all graphs and renders the data
+ * from a RecordingModel. Takes a resolution value used in
+ * some graphs.
+ * Saves rendering progress as a promise to be consumed by `destroy`,
+ * to wait for cleaning up rendering during destruction.
+ */
+ render: Task.async(function* (recordingData, resolution) {
+ // Get the previous render promise so we don't start rendering
+ // until the previous render cycle completes, which can occur
+ // especially when a recording is finished, and triggers a
+ // fresh rendering at a higher rate
+ yield (this._rendering && this._rendering.promise);
+
+ // Check after yielding to ensure we're not tearing down,
+ // as this can create a race condition in tests
+ if (this._destroyed) {
+ return;
+ }
+
+ this._rendering = promise.defer();
+ for (let graph of (yield this._getEnabled())) {
+ yield graph.setPerformanceData(recordingData, resolution);
+ this.emit("rendered", graph.graphName);
+ }
+ this._rendering.resolve();
+ }),
+
+ /**
+ * Destroys the underlying graphs.
+ */
+ destroy: Task.async(function* () {
+ let primary = this._getPrimaryLink();
+
+ this._destroyed = true;
+
+ if (primary) {
+ primary.off("selecting", this._onSelecting);
+ }
+
+ // If there was rendering, wait until the most recent render cycle
+ // has finished
+ if (this._rendering) {
+ yield this._rendering.promise;
+ }
+
+ for (let graph of this.getWidgets()) {
+ yield graph.destroy();
+ }
+ }),
+
+ /**
+ * Applies the theme to the underlying graphs. Optionally takes
+ * a `redraw` boolean in the options to force redraw.
+ */
+ setTheme: function (options = {}) {
+ let theme = options.theme || this._getTheme();
+ for (let graph of this.getWidgets()) {
+ graph.setTheme(theme);
+ graph.refresh({ force: options.redraw });
+ }
+ },
+
+ /**
+ * Sets up the graph, if needed. Returns a promise resolving
+ * to the graph if it is enabled once it's ready, or otherwise returns
+ * null if disabled.
+ */
+ isAvailable: Task.async(function* (graphName) {
+ if (!this._enabled.has(graphName)) {
+ return null;
+ }
+
+ let graph = this.get(graphName);
+
+ if (!graph) {
+ graph = yield this._construct(graphName);
+ }
+
+ yield graph.ready();
+ return graph;
+ }),
+
+ /**
+ * Enable or disable a subgraph controlled by GraphsController.
+ * This determines what graphs are visible and get rendered.
+ */
+ enable: function (graphName, isEnabled) {
+ let el = this.$(this._definition[graphName].selector);
+ el.classList[isEnabled ? "remove" : "add"]("hidden");
+
+ // If no status change, just return
+ if (this._enabled.has(graphName) === isEnabled) {
+ return;
+ }
+ if (isEnabled) {
+ this._enabled.add(graphName);
+ } else {
+ this._enabled.delete(graphName);
+ }
+
+ // Invalidate our cache of ready-to-go graphs
+ this._enabledGraphs = null;
+ },
+
+ /**
+ * Disables all graphs controller by the GraphsController, and
+ * also hides the root element. This is a one way switch, and used
+ * when older platforms do not have any timeline data.
+ */
+ disableAll: function () {
+ this._root.classList.add("hidden");
+ // Hide all the subelements
+ Object.keys(this._definition).forEach(graphName => this.enable(graphName, false));
+ },
+
+ /**
+ * Sets a mapped selection on the graph that is the main controller
+ * for keeping the graphs' selections in sync.
+ */
+ setMappedSelection: function (selection, { mapStart, mapEnd }) {
+ return this._getPrimaryLink().setMappedSelection(selection, { mapStart, mapEnd });
+ },
+
+ /**
+ * Fetches the currently mapped selection. If graphs are not yet rendered,
+ * (which throws in Graphs.js), return null.
+ */
+ getMappedSelection: function ({ mapStart, mapEnd }) {
+ let primary = this._getPrimaryLink();
+ if (primary && primary.hasData()) {
+ return primary.getMappedSelection({ mapStart, mapEnd });
+ }
+ return null;
+ },
+
+ /**
+ * Returns an array of graphs that have been created, not necessarily
+ * enabled currently.
+ */
+ getWidgets: function () {
+ return Object.keys(this._graphs).map(name => this._graphs[name]);
+ },
+
+ /**
+ * Drops the selection.
+ */
+ dropSelection: function () {
+ if (this._getPrimaryLink()) {
+ return this._getPrimaryLink().dropSelection();
+ }
+ return null;
+ },
+
+ /**
+ * Makes sure the selection is enabled or disabled in all the graphs.
+ */
+ selectionEnabled: Task.async(function* (enabled) {
+ for (let graph of (yield this._getEnabled())) {
+ graph.selectionEnabled = enabled;
+ }
+ }),
+
+ /**
+ * Creates the graph `graphName` and initializes it.
+ */
+ _construct: Task.async(function* (graphName) {
+ let def = this._definition[graphName];
+ let el = this.$(def.selector);
+ let filter = this._getFilter();
+ let graph = this._graphs[graphName] = new def.constructor(el, filter);
+ graph.graphName = graphName;
+
+ yield graph.ready();
+
+ // Sync the graphs' animations and selections together
+ if (def.primaryLink) {
+ graph.on("selecting", this._onSelecting);
+ } else {
+ CanvasGraphUtils.linkAnimation(this._getPrimaryLink(), graph);
+ CanvasGraphUtils.linkSelection(this._getPrimaryLink(), graph);
+ }
+
+ // Sets the container element's visibility based off of enabled status
+ el.classList[this._enabled.has(graphName) ? "remove" : "add"]("hidden");
+
+ this.setTheme();
+ return graph;
+ }),
+
+ /**
+ * Returns the main graph for this collection, that all graphs
+ * are bound to for syncing and selection.
+ */
+ _getPrimaryLink: function () {
+ return this.get(this._primaryLink);
+ },
+
+ /**
+ * Emitted when a selection occurs.
+ */
+ _onSelecting: function () {
+ this.emit("selecting");
+ },
+
+ /**
+ * Resolves to an array with all graphs that are enabled, and
+ * creates them if needed. Different than just iterating over `this._graphs`,
+ * as those could be enabled. Uses caching, as rendering happens many times per second,
+ * compared to how often which graphs/features are changed (rarely).
+ */
+ _getEnabled: Task.async(function* () {
+ if (this._enabledGraphs) {
+ return this._enabledGraphs;
+ }
+ let enabled = [];
+ for (let graphName of this._enabled) {
+ let graph = yield this.isAvailable(graphName);
+ if (graph) {
+ enabled.push(graph);
+ }
+ }
+ this._enabledGraphs = enabled;
+ return this._enabledGraphs;
+ }),
+};
+
+/**
+ * A base class for performance graphs to inherit from.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the overview.
+ * @param string metric
+ * The unit of measurement for this graph.
+ */
+function OptimizationsGraph(parent) {
+ MountainGraphWidget.call(this, parent);
+ this.setTheme();
+}
+
+OptimizationsGraph.prototype = Heritage.extend(MountainGraphWidget.prototype, {
+
+ render: Task.async(function* (threadNode, frameNode) {
+ // Regardless if we draw or clear the graph, wait
+ // until it's ready.
+ yield this.ready();
+
+ if (!threadNode || !frameNode) {
+ this.setData([]);
+ return;
+ }
+
+ let { sampleTimes } = threadNode;
+
+ if (!sampleTimes.length) {
+ this.setData([]);
+ return;
+ }
+
+ // Take startTime/endTime from samples recorded, rather than
+ // using duration directly from threadNode, as the first sample that
+ // equals the startTime does not get recorded.
+ let startTime = sampleTimes[0];
+ let endTime = sampleTimes[sampleTimes.length - 1];
+
+ let bucketSize = (endTime - startTime) / OPTIMIZATIONS_GRAPH_RESOLUTION;
+ let data = createTierGraphDataFromFrameNode(frameNode, sampleTimes, bucketSize);
+
+ // If for some reason we don't have data (like the frameNode doesn't
+ // have optimizations, but it shouldn't be at this point if it doesn't),
+ // log an error.
+ if (!data) {
+ console.error(
+ `FrameNode#${frameNode.location} does not have optimizations data to render.`);
+ return;
+ }
+
+ this.dataOffsetX = startTime;
+ yield this.setData(data);
+ }),
+
+ /**
+ * Sets the theme via `theme` to either "light" or "dark",
+ * and updates the internal styling to match. Requires a redraw
+ * to see the effects.
+ */
+ setTheme: function (theme) {
+ theme = theme || "light";
+
+ let interpreterColor = getColor("graphs-red", theme);
+ let baselineColor = getColor("graphs-blue", theme);
+ let ionColor = getColor("graphs-green", theme);
+
+ this.format = [
+ { color: interpreterColor },
+ { color: baselineColor },
+ { color: ionColor },
+ ];
+
+ this.backgroundColor = getColor("sidebar-background", theme);
+ }
+});
+
+exports.OptimizationsGraph = OptimizationsGraph;
+exports.FramerateGraph = FramerateGraph;
+exports.MemoryGraph = MemoryGraph;
+exports.TimelineGraph = TimelineGraph;
+exports.GraphsController = GraphsController;
diff --git a/devtools/client/performance/modules/widgets/marker-details.js b/devtools/client/performance/modules/widgets/marker-details.js
new file mode 100644
index 000000000..56494e7c2
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/marker-details.js
@@ -0,0 +1,164 @@
+/* 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";
+
+/**
+ * This file contains the rendering code for the marker sidebar.
+ */
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const { MarkerDOMUtils } = require("devtools/client/performance/modules/marker-dom-utils");
+
+/**
+ * A detailed view for one single marker.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the view.
+ * @param nsIDOMNode splitter
+ * The splitter node that the resize event is bound to.
+ */
+function MarkerDetails(parent, splitter) {
+ EventEmitter.decorate(this);
+
+ this._document = parent.ownerDocument;
+ this._parent = parent;
+ this._splitter = splitter;
+
+ this._onClick = this._onClick.bind(this);
+ this._onSplitterMouseUp = this._onSplitterMouseUp.bind(this);
+
+ this._parent.addEventListener("click", this._onClick);
+ this._splitter.addEventListener("mouseup", this._onSplitterMouseUp);
+
+ this.hidden = true;
+}
+
+MarkerDetails.prototype = {
+ /**
+ * Sets this view's width.
+ * @param number
+ */
+ set width(value) {
+ this._parent.setAttribute("width", value);
+ },
+
+ /**
+ * Sets this view's width.
+ * @return number
+ */
+ get width() {
+ return +this._parent.getAttribute("width");
+ },
+
+ /**
+ * Sets this view's visibility.
+ * @param boolean
+ */
+ set hidden(value) {
+ if (this._parent.hidden != value) {
+ this._parent.hidden = value;
+ this.emit("resize");
+ }
+ },
+
+ /**
+ * Gets this view's visibility.
+ * @param boolean
+ */
+ get hidden() {
+ return this._parent.hidden;
+ },
+
+ /**
+ * Clears the marker details from this view.
+ */
+ empty: function () {
+ this._parent.innerHTML = "";
+ },
+
+ /**
+ * Populates view with marker's details.
+ *
+ * @param object params
+ * An options object holding:
+ * - marker: The marker to display.
+ * - frames: Array of stack frame information; see stack.js.
+ * - allocations: Whether or not allocations were enabled for this
+ * recording. [optional]
+ */
+ render: function (options) {
+ let { marker, frames } = options;
+ this.empty();
+
+ let elements = [];
+ elements.push(MarkerDOMUtils.buildTitle(this._document, marker));
+ elements.push(MarkerDOMUtils.buildDuration(this._document, marker));
+ MarkerDOMUtils.buildFields(this._document, marker).forEach(f => elements.push(f));
+ MarkerDOMUtils.buildCustom(this._document, marker, options)
+ .forEach(f => elements.push(f));
+
+ // Build a stack element -- and use the "startStack" label if
+ // we have both a startStack and endStack.
+ if (marker.stack) {
+ let type = marker.endStack ? "startStack" : "stack";
+ elements.push(MarkerDOMUtils.buildStackTrace(this._document, {
+ frameIndex: marker.stack, frames, type
+ }));
+ }
+ if (marker.endStack) {
+ let type = "endStack";
+ elements.push(MarkerDOMUtils.buildStackTrace(this._document, {
+ frameIndex: marker.endStack, frames, type
+ }));
+ }
+
+ elements.forEach(el => this._parent.appendChild(el));
+ },
+
+ /**
+ * Handles click in the marker details view. Based on the target,
+ * can handle different actions -- only supporting view source links
+ * for the moment.
+ */
+ _onClick: function (e) {
+ let data = findActionFromEvent(e.target, this._parent);
+ if (!data) {
+ return;
+ }
+
+ this.emit(data.action, data);
+ },
+
+ /**
+ * Handles the "mouseup" event on the marker details view splitter.
+ */
+ _onSplitterMouseUp: function () {
+ this.emit("resize");
+ }
+};
+
+/**
+ * Take an element from an event `target`, and ascend through
+ * the DOM, looking for an element with a `data-action` attribute. Return
+ * the parsed `data-action` value found, or null if none found before
+ * reaching the parent `container`.
+ *
+ * @param {Element} target
+ * @param {Element} container
+ * @return {?object}
+ */
+function findActionFromEvent(target, container) {
+ let el = target;
+ let action;
+ while (el !== container) {
+ action = el.getAttribute("data-action");
+ if (action) {
+ return JSON.parse(action);
+ }
+ el = el.parentNode;
+ }
+ return null;
+}
+
+exports.MarkerDetails = MarkerDetails;
diff --git a/devtools/client/performance/modules/widgets/markers-overview.js b/devtools/client/performance/modules/widgets/markers-overview.js
new file mode 100644
index 000000000..89bc79a8d
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/markers-overview.js
@@ -0,0 +1,243 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * This file contains the "markers overview" graph, which is a minimap of all
+ * the timeline data. Regions inside it may be selected, determining which
+ * markers are visible in the "waterfall".
+ */
+
+const { Heritage } = require("devtools/client/shared/widgets/view-helpers");
+const { AbstractCanvasGraph } = require("devtools/client/shared/widgets/Graphs");
+
+const { colorUtils } = require("devtools/shared/css/color");
+const { getColor } = require("devtools/client/shared/theme");
+const ProfilerGlobal = require("devtools/client/performance/modules/global");
+const { MarkerBlueprintUtils } = require("devtools/client/performance/modules/marker-blueprint-utils");
+const { TickUtils } = require("devtools/client/performance/modules/waterfall-ticks");
+const { TIMELINE_BLUEPRINT } = require("devtools/client/performance/modules/markers");
+
+// px
+const OVERVIEW_HEADER_HEIGHT = 14;
+// px
+const OVERVIEW_ROW_HEIGHT = 11;
+
+const OVERVIEW_SELECTION_LINE_COLOR = "#666";
+const OVERVIEW_CLIPHEAD_LINE_COLOR = "#555";
+
+// ms
+const OVERVIEW_HEADER_TICKS_MULTIPLE = 100;
+// px
+const OVERVIEW_HEADER_TICKS_SPACING_MIN = 75;
+// px
+const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9;
+const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
+// px
+const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6;
+// px
+const OVERVIEW_HEADER_TEXT_PADDING_TOP = 1;
+// px
+const OVERVIEW_MARKER_WIDTH_MIN = 4;
+// px
+const OVERVIEW_GROUP_VERTICAL_PADDING = 5;
+
+/**
+ * An overview for the markers data.
+ *
+ * @param nsIDOMNode parent
+ * The parent node holding the overview.
+ * @param Array<String> filter
+ * List of names of marker types that should not be shown.
+ */
+function MarkersOverview(parent, filter = [], ...args) {
+ AbstractCanvasGraph.apply(this, [parent, "markers-overview", ...args]);
+ this.setTheme();
+ this.setFilter(filter);
+}
+
+MarkersOverview.prototype = Heritage.extend(AbstractCanvasGraph.prototype, {
+ clipheadLineColor: OVERVIEW_CLIPHEAD_LINE_COLOR,
+ selectionLineColor: OVERVIEW_SELECTION_LINE_COLOR,
+ headerHeight: OVERVIEW_HEADER_HEIGHT,
+ rowHeight: OVERVIEW_ROW_HEIGHT,
+ groupPadding: OVERVIEW_GROUP_VERTICAL_PADDING,
+
+ /**
+ * Compute the height of the overview.
+ */
+ get fixedHeight() {
+ return this.headerHeight + this.rowHeight * this._numberOfGroups;
+ },
+
+ /**
+ * List of marker types that should not be shown in the graph.
+ */
+ setFilter: function (filter) {
+ this._paintBatches = new Map();
+ this._filter = filter;
+ this._groupMap = Object.create(null);
+
+ let observedGroups = new Set();
+
+ for (let type in TIMELINE_BLUEPRINT) {
+ if (filter.indexOf(type) !== -1) {
+ continue;
+ }
+ this._paintBatches.set(type, { definition: TIMELINE_BLUEPRINT[type], batch: [] });
+ observedGroups.add(TIMELINE_BLUEPRINT[type].group);
+ }
+
+ // Take our set of observed groups and order them and map
+ // the group numbers to fill in the holes via `_groupMap`.
+ // This normalizes our rows by removing rows that aren't used
+ // if filters are enabled.
+ let actualPosition = 0;
+ for (let groupNumber of Array.from(observedGroups).sort()) {
+ this._groupMap[groupNumber] = actualPosition++;
+ }
+ this._numberOfGroups = Object.keys(this._groupMap).length;
+ },
+
+ /**
+ * Disables selection and empties this graph.
+ */
+ clearView: function () {
+ this.selectionEnabled = false;
+ this.dropSelection();
+ this.setData({ duration: 0, markers: [] });
+ },
+
+ /**
+ * Renders the graph's data source.
+ * @see AbstractCanvasGraph.prototype.buildGraphImage
+ */
+ buildGraphImage: function () {
+ let { markers, duration } = this._data;
+
+ let { canvas, ctx } = this._getNamedCanvas("markers-overview-data");
+ let canvasWidth = this._width;
+ let canvasHeight = this._height;
+
+ // Group markers into separate paint batches. This is necessary to
+ // draw all markers sharing the same style at once.
+ for (let marker of markers) {
+ // Again skip over markers that we're filtering -- we don't want them
+ // to be labeled as "Unknown"
+ if (!MarkerBlueprintUtils.shouldDisplayMarker(marker, this._filter)) {
+ continue;
+ }
+
+ let markerType = this._paintBatches.get(marker.name) ||
+ this._paintBatches.get("UNKNOWN");
+ markerType.batch.push(marker);
+ }
+
+ // Calculate each row's height, and the time-based scaling.
+
+ let groupHeight = this.rowHeight * this._pixelRatio;
+ let groupPadding = this.groupPadding * this._pixelRatio;
+ let headerHeight = this.headerHeight * this._pixelRatio;
+ let dataScale = this.dataScaleX = canvasWidth / duration;
+
+ // Draw the header and overview background.
+
+ ctx.fillStyle = this.headerBackgroundColor;
+ ctx.fillRect(0, 0, canvasWidth, headerHeight);
+
+ ctx.fillStyle = this.backgroundColor;
+ ctx.fillRect(0, headerHeight, canvasWidth, canvasHeight);
+
+ // Draw the alternating odd/even group backgrounds.
+
+ ctx.fillStyle = this.alternatingBackgroundColor;
+ ctx.beginPath();
+
+ for (let i = 0; i < this._numberOfGroups; i += 2) {
+ let top = headerHeight + i * groupHeight;
+ ctx.rect(0, top, canvasWidth, groupHeight);
+ }
+
+ ctx.fill();
+
+ // Draw the timeline header ticks.
+
+ let fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio;
+ let fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY;
+ let textPaddingLeft = OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio;
+ let textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio;
+
+ let tickInterval = TickUtils.findOptimalTickInterval({
+ ticksMultiple: OVERVIEW_HEADER_TICKS_MULTIPLE,
+ ticksSpacingMin: OVERVIEW_HEADER_TICKS_SPACING_MIN,
+ dataScale: dataScale
+ });
+
+ ctx.textBaseline = "middle";
+ ctx.font = fontSize + "px " + fontFamily;
+ ctx.fillStyle = this.headerTextColor;
+ ctx.strokeStyle = this.headerTimelineStrokeColor;
+ ctx.beginPath();
+
+ for (let x = 0; x < canvasWidth; x += tickInterval) {
+ let lineLeft = x;
+ let textLeft = lineLeft + textPaddingLeft;
+ let time = Math.round(x / dataScale);
+ let label = ProfilerGlobal.L10N.getFormatStr("timeline.tick", time);
+ ctx.fillText(label, textLeft, headerHeight / 2 + textPaddingTop);
+ ctx.moveTo(lineLeft, 0);
+ ctx.lineTo(lineLeft, canvasHeight);
+ }
+
+ ctx.stroke();
+
+ // Draw the timeline markers.
+
+ for (let [, { definition, batch }] of this._paintBatches) {
+ let group = this._groupMap[definition.group];
+ let top = headerHeight + group * groupHeight + groupPadding / 2;
+ let height = groupHeight - groupPadding;
+
+ let color = getColor(definition.colorName, this.theme);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+
+ for (let { start, end } of batch) {
+ let left = start * dataScale;
+ let width = Math.max((end - start) * dataScale, OVERVIEW_MARKER_WIDTH_MIN);
+ ctx.rect(left, top, width, height);
+ }
+
+ ctx.fill();
+
+ // Since all the markers in this batch (thus sharing the same style) have
+ // been drawn, empty it. The next time new markers will be available,
+ // they will be sorted and drawn again.
+ batch.length = 0;
+ }
+
+ return canvas;
+ },
+
+ /**
+ * Sets the theme via `theme` to either "light" or "dark",
+ * and updates the internal styling to match. Requires a redraw
+ * to see the effects.
+ */
+ setTheme: function (theme) {
+ this.theme = theme = theme || "light";
+ this.backgroundColor = getColor("body-background", theme);
+ this.selectionBackgroundColor = colorUtils.setAlpha(
+ getColor("selection-background", theme), 0.25);
+ this.selectionStripesColor = colorUtils.setAlpha("#fff", 0.1);
+ this.headerBackgroundColor = getColor("body-background", theme);
+ this.headerTextColor = getColor("body-color", theme);
+ this.headerTimelineStrokeColor = colorUtils.setAlpha(
+ getColor("body-color-alt", theme), 0.25);
+ this.alternatingBackgroundColor = colorUtils.setAlpha(
+ getColor("body-color", theme), 0.05);
+ }
+});
+
+exports.MarkersOverview = MarkersOverview;
diff --git a/devtools/client/performance/modules/widgets/moz.build b/devtools/client/performance/modules/widgets/moz.build
new file mode 100644
index 000000000..9f733838a
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/moz.build
@@ -0,0 +1,11 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ 'graphs.js',
+ 'marker-details.js',
+ 'markers-overview.js',
+ 'tree-view.js',
+)
diff --git a/devtools/client/performance/modules/widgets/tree-view.js b/devtools/client/performance/modules/widgets/tree-view.js
new file mode 100644
index 000000000..d3d81fe3b
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/tree-view.js
@@ -0,0 +1,406 @@
+/* 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";
+
+/**
+ * This file contains the tree view, displaying all the samples and frames
+ * received from the proviler in a tree-like structure.
+ */
+
+const { L10N } = require("devtools/client/performance/modules/global");
+const { Heritage } = require("devtools/client/shared/widgets/view-helpers");
+const { AbstractTreeItem } = require("resource://devtools/client/shared/widgets/AbstractTreeItem.jsm");
+
+const URL_LABEL_TOOLTIP = L10N.getStr("table.url.tooltiptext");
+const VIEW_OPTIMIZATIONS_TOOLTIP = L10N.getStr("table.view-optimizations.tooltiptext2");
+
+// px
+const CALL_TREE_INDENTATION = 16;
+
+// Used for rendering values in cells
+const FORMATTERS = {
+ TIME: (value) => L10N.getFormatStr("table.ms2", L10N.numberWithDecimals(value, 2)),
+ PERCENT: (value) => L10N.getFormatStr("table.percentage3",
+ L10N.numberWithDecimals(value, 2)),
+ NUMBER: (value) => value || 0,
+ BYTESIZE: (value) => L10N.getFormatStr("table.bytes", (value || 0))
+};
+
+/**
+ * Definitions for rendering cells. Triads of class name, property name from
+ * `frame.getInfo()`, and a formatter function.
+ */
+const CELLS = {
+ duration: ["duration", "totalDuration", FORMATTERS.TIME],
+ percentage: ["percentage", "totalPercentage", FORMATTERS.PERCENT],
+ selfDuration: ["self-duration", "selfDuration", FORMATTERS.TIME],
+ selfPercentage: ["self-percentage", "selfPercentage", FORMATTERS.PERCENT],
+ samples: ["samples", "samples", FORMATTERS.NUMBER],
+
+ selfSize: ["self-size", "selfSize", FORMATTERS.BYTESIZE],
+ selfSizePercentage: ["self-size-percentage", "selfSizePercentage", FORMATTERS.PERCENT],
+ selfCount: ["self-count", "selfCount", FORMATTERS.NUMBER],
+ selfCountPercentage: ["self-count-percentage", "selfCountPercentage",
+ FORMATTERS.PERCENT],
+ size: ["size", "totalSize", FORMATTERS.BYTESIZE],
+ sizePercentage: ["size-percentage", "totalSizePercentage", FORMATTERS.PERCENT],
+ count: ["count", "totalCount", FORMATTERS.NUMBER],
+ countPercentage: ["count-percentage", "totalCountPercentage", FORMATTERS.PERCENT],
+};
+const CELL_TYPES = Object.keys(CELLS);
+
+const DEFAULT_SORTING_PREDICATE = (frameA, frameB) => {
+ let dataA = frameA.getDisplayedData();
+ let dataB = frameB.getDisplayedData();
+ let isAllocations = "totalSize" in dataA;
+
+ if (isAllocations) {
+ if (this.inverted && dataA.selfSize !== dataB.selfSize) {
+ return dataA.selfSize < dataB.selfSize ? 1 : -1;
+ }
+ return dataA.totalSize < dataB.totalSize ? 1 : -1;
+ }
+
+ if (this.inverted && dataA.selfPercentage !== dataB.selfPercentage) {
+ return dataA.selfPercentage < dataB.selfPercentage ? 1 : -1;
+ }
+ return dataA.totalPercentage < dataB.totalPercentage ? 1 : -1;
+};
+
+// depth
+const DEFAULT_AUTO_EXPAND_DEPTH = 3;
+const DEFAULT_VISIBLE_CELLS = {
+ duration: true,
+ percentage: true,
+ selfDuration: true,
+ selfPercentage: true,
+ samples: true,
+ function: true,
+
+ // allocation columns
+ count: false,
+ selfCount: false,
+ size: false,
+ selfSize: false,
+ countPercentage: false,
+ selfCountPercentage: false,
+ sizePercentage: false,
+ selfSizePercentage: false,
+};
+
+/**
+ * An item in a call tree view, which looks like this:
+ *
+ * Time (ms) | Cost | Calls | Function
+ * ============================================================================
+ * 1,000.00 | 100.00% | | ▼ (root)
+ * 500.12 | 50.01% | 300 | ▼ foo Categ. 1
+ * 300.34 | 30.03% | 1500 | ▼ bar Categ. 2
+ * 10.56 | 0.01% | 42 | ▶ call_with_children Categ. 3
+ * 90.78 | 0.09% | 25 | call_without_children Categ. 4
+ *
+ * Every instance of a `CallView` represents a row in the call tree. The same
+ * parent node is used for all rows.
+ *
+ * @param CallView caller
+ * The CallView considered the "caller" frame. This newly created
+ * instance will be represent the "callee". Should be null for root nodes.
+ * @param ThreadNode | FrameNode frame
+ * Details about this function, like { samples, duration, calls } etc.
+ * @param number level [optional]
+ * The indentation level in the call tree. The root node is at level 0.
+ * @param boolean hidden [optional]
+ * Whether this node should be hidden and not contribute to depth/level
+ * calculations. Defaults to false.
+ * @param boolean inverted [optional]
+ * Whether the call tree has been inverted (bottom up, rather than
+ * top-down). Defaults to false.
+ * @param function sortingPredicate [optional]
+ * The predicate used to sort the tree items when created. Defaults to
+ * the caller's `sortingPredicate` if a caller exists, otherwise defaults
+ * to DEFAULT_SORTING_PREDICATE. The two passed arguments are FrameNodes.
+ * @param number autoExpandDepth [optional]
+ * The depth to which the tree should automatically expand. Defualts to
+ * the caller's `autoExpandDepth` if a caller exists, otherwise defaults
+ * to DEFAULT_AUTO_EXPAND_DEPTH.
+ * @param object visibleCells
+ * An object specifying which cells are visible in the tree. Defaults to
+ * the caller's `visibleCells` if a caller exists, otherwise defaults
+ * to DEFAULT_VISIBLE_CELLS.
+ * @param boolean showOptimizationHint [optional]
+ * Whether or not to show an icon indicating if the frame has optimization
+ * data.
+ */
+function CallView({
+ caller, frame, level, hidden, inverted,
+ sortingPredicate, autoExpandDepth, visibleCells,
+ showOptimizationHint
+}) {
+ AbstractTreeItem.call(this, {
+ parent: caller,
+ level: level | 0 - (hidden ? 1 : 0)
+ });
+
+ if (sortingPredicate != null) {
+ this.sortingPredicate = sortingPredicate;
+ } else if (caller) {
+ this.sortingPredicate = caller.sortingPredicate;
+ } else {
+ this.sortingPredicate = DEFAULT_SORTING_PREDICATE;
+ }
+
+ if (autoExpandDepth != null) {
+ this.autoExpandDepth = autoExpandDepth;
+ } else if (caller) {
+ this.autoExpandDepth = caller.autoExpandDepth;
+ } else {
+ this.autoExpandDepth = DEFAULT_AUTO_EXPAND_DEPTH;
+ }
+
+ if (visibleCells != null) {
+ this.visibleCells = visibleCells;
+ } else if (caller) {
+ this.visibleCells = caller.visibleCells;
+ } else {
+ this.visibleCells = Object.create(DEFAULT_VISIBLE_CELLS);
+ }
+
+ this.caller = caller;
+ this.frame = frame;
+ this.hidden = hidden;
+ this.inverted = inverted;
+ this.showOptimizationHint = showOptimizationHint;
+
+ this._onUrlClick = this._onUrlClick.bind(this);
+}
+
+CallView.prototype = Heritage.extend(AbstractTreeItem.prototype, {
+ /**
+ * Creates the view for this tree node.
+ * @param nsIDOMNode document
+ * @param nsIDOMNode arrowNode
+ * @return nsIDOMNode
+ */
+ _displaySelf: function (document, arrowNode) {
+ let frameInfo = this.getDisplayedData();
+ let cells = [];
+
+ for (let type of CELL_TYPES) {
+ if (this.visibleCells[type]) {
+ // Inline for speed, but pass in the formatted value via
+ // cell definition, as well as the element type.
+ cells.push(this._createCell(document, CELLS[type][2](frameInfo[CELLS[type][1]]),
+ CELLS[type][0]));
+ }
+ }
+
+ if (this.visibleCells.function) {
+ cells.push(this._createFunctionCell(document, arrowNode, frameInfo.name, frameInfo,
+ this.level));
+ }
+
+ let targetNode = document.createElement("hbox");
+ targetNode.className = "call-tree-item";
+ targetNode.setAttribute("origin", frameInfo.isContent ? "content" : "chrome");
+ targetNode.setAttribute("category", frameInfo.categoryData.abbrev || "");
+ targetNode.setAttribute("tooltiptext", frameInfo.tooltiptext);
+
+ if (this.hidden) {
+ targetNode.style.display = "none";
+ }
+
+ for (let i = 0; i < cells.length; i++) {
+ targetNode.appendChild(cells[i]);
+ }
+
+ return targetNode;
+ },
+
+ /**
+ * Populates this node in the call tree with the corresponding "callees".
+ * These are defined in the `frame` data source for this call view.
+ * @param array:AbstractTreeItem children
+ */
+ _populateSelf: function (children) {
+ let newLevel = this.level + 1;
+
+ for (let newFrame of this.frame.calls) {
+ children.push(new CallView({
+ caller: this,
+ frame: newFrame,
+ level: newLevel,
+ inverted: this.inverted
+ }));
+ }
+
+ // Sort the "callees" asc. by samples, before inserting them in the tree,
+ // if no other sorting predicate was specified on this on the root item.
+ children.sort(this.sortingPredicate.bind(this));
+ },
+
+ /**
+ * Functions creating each cell in this call view.
+ * Invoked by `_displaySelf`.
+ */
+ _createCell: function (doc, value, type) {
+ let cell = doc.createElement("description");
+ cell.className = "plain call-tree-cell";
+ cell.setAttribute("type", type);
+ cell.setAttribute("crop", "end");
+ // Add a tabulation to the cell text in case it's is selected and copied.
+ cell.textContent = value + "\t";
+ return cell;
+ },
+
+ _createFunctionCell: function (doc, arrowNode, frameName, frameInfo, frameLevel) {
+ let cell = doc.createElement("hbox");
+ cell.className = "call-tree-cell";
+ cell.style.marginInlineStart = (frameLevel * CALL_TREE_INDENTATION) + "px";
+ cell.setAttribute("type", "function");
+ cell.appendChild(arrowNode);
+
+ // Render optimization hint if this frame has opt data.
+ if (this.root.showOptimizationHint && frameInfo.hasOptimizations &&
+ !frameInfo.isMetaCategory) {
+ let icon = doc.createElement("description");
+ icon.setAttribute("tooltiptext", VIEW_OPTIMIZATIONS_TOOLTIP);
+ icon.className = "opt-icon";
+ cell.appendChild(icon);
+ }
+
+ // Don't render a name label node if there's no function name. A different
+ // location label node will be rendered instead.
+ if (frameName) {
+ let nameNode = doc.createElement("description");
+ nameNode.className = "plain call-tree-name";
+ nameNode.textContent = frameName;
+ cell.appendChild(nameNode);
+ }
+
+ // Don't render detailed labels for meta category frames
+ if (!frameInfo.isMetaCategory) {
+ this._appendFunctionDetailsCells(doc, cell, frameInfo);
+ }
+
+ // Don't render an expando-arrow for leaf nodes.
+ let hasDescendants = Object.keys(this.frame.calls).length > 0;
+ if (!hasDescendants) {
+ arrowNode.setAttribute("invisible", "");
+ }
+
+ // Add a line break to the last description of the row in case it's selected
+ // and copied.
+ let lastDescription = cell.querySelector("description:last-of-type");
+ lastDescription.textContent = lastDescription.textContent + "\n";
+
+ // Add spaces as frameLevel indicators in case the row is selected and
+ // copied. These spaces won't be displayed in the cell content.
+ let firstDescription = cell.querySelector("description:first-of-type");
+ let levelIndicator = frameLevel > 0 ? " ".repeat(frameLevel) : "";
+ firstDescription.textContent = levelIndicator + firstDescription.textContent;
+
+ return cell;
+ },
+
+ _appendFunctionDetailsCells: function (doc, cell, frameInfo) {
+ if (frameInfo.fileName) {
+ let urlNode = doc.createElement("description");
+ urlNode.className = "plain call-tree-url";
+ urlNode.textContent = frameInfo.fileName;
+ urlNode.setAttribute("tooltiptext", URL_LABEL_TOOLTIP + " → " + frameInfo.url);
+ urlNode.addEventListener("mousedown", this._onUrlClick);
+ cell.appendChild(urlNode);
+ }
+
+ if (frameInfo.line) {
+ let lineNode = doc.createElement("description");
+ lineNode.className = "plain call-tree-line";
+ lineNode.textContent = ":" + frameInfo.line;
+ cell.appendChild(lineNode);
+ }
+
+ if (frameInfo.column) {
+ let columnNode = doc.createElement("description");
+ columnNode.className = "plain call-tree-column";
+ columnNode.textContent = ":" + frameInfo.column;
+ cell.appendChild(columnNode);
+ }
+
+ if (frameInfo.host) {
+ let hostNode = doc.createElement("description");
+ hostNode.className = "plain call-tree-host";
+ hostNode.textContent = frameInfo.host;
+ cell.appendChild(hostNode);
+ }
+
+ if (frameInfo.categoryData.label) {
+ let categoryNode = doc.createElement("description");
+ categoryNode.className = "plain call-tree-category";
+ categoryNode.style.color = frameInfo.categoryData.color;
+ categoryNode.textContent = frameInfo.categoryData.label;
+ cell.appendChild(categoryNode);
+ }
+ },
+
+ /**
+ * Gets the data displayed about this tree item, based on the FrameNode
+ * model associated with this view.
+ *
+ * @return object
+ */
+ getDisplayedData: function () {
+ if (this._cachedDisplayedData) {
+ return this._cachedDisplayedData;
+ }
+
+ this._cachedDisplayedData = this.frame.getInfo({
+ root: this.root.frame,
+ allocations: (this.visibleCells.count || this.visibleCells.selfCount)
+ });
+
+ return this._cachedDisplayedData;
+
+ /**
+ * When inverting call tree, the costs and times are dependent on position
+ * in the tree. We must only count leaf nodes with self cost, and total costs
+ * dependent on how many times the leaf node was found with a full stack path.
+ *
+ * Total | Self | Calls | Function
+ * ============================================================================
+ * 100% | 100% | 100 | ▼ C
+ * 50% | 0% | 50 | ▼ B
+ * 50% | 0% | 50 | ▼ A
+ * 50% | 0% | 50 | ▼ B
+ *
+ * Every instance of a `CallView` represents a row in the call tree. The same
+ * container node is used for all rows.
+ */
+ },
+
+ /**
+ * Toggles the category information hidden or visible.
+ * @param boolean visible
+ */
+ toggleCategories: function (visible) {
+ if (!visible) {
+ this.container.setAttribute("categories-hidden", "");
+ } else {
+ this.container.removeAttribute("categories-hidden");
+ }
+ },
+
+ /**
+ * Handler for the "click" event on the url node of this call view.
+ */
+ _onUrlClick: function (e) {
+ e.preventDefault();
+ e.stopPropagation();
+ // Only emit for left click events
+ if (e.button === 0) {
+ this.root.emit("link", this);
+ }
+ },
+});
+
+exports.CallView = CallView;