summaryrefslogtreecommitdiffstats
path: root/devtools/client/performance/modules/widgets
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/client/performance/modules/widgets
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--devtools/client/performance/modules/widgets/graphs.js527
-rw-r--r--devtools/client/performance/modules/widgets/marker-details.js177
-rw-r--r--devtools/client/performance/modules/widgets/markers-overview.js256
-rw-r--r--devtools/client/performance/modules/widgets/moz.build11
-rw-r--r--devtools/client/performance/modules/widgets/tree-view.js461
5 files changed, 1432 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 0000000000..926fb43cf6
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/graphs.js
@@ -0,0 +1,527 @@
+/* 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 { extend } = require("devtools/shared/extend");
+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 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
+ */
+const HEIGHT = 35; // px
+const STROKE_WIDTH = 1; // px
+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
+ */
+const MARKERS_GRAPH_HEADER_HEIGHT = 14; // px
+const MARKERS_GRAPH_ROW_HEIGHT = 10; // px
+const MARKERS_GROUP_VERTICAL_PADDING = 4; // px
+
+/**
+ * For optimization graph
+ */
+const OPTIMIZATIONS_GRAPH_RESOLUTION = 100;
+
+/**
+ * A base class for performance graphs to inherit from.
+ *
+ * @param Node 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 = 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";
+ const 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 Node parent
+ * The parent node holding the overview.
+ */
+function FramerateGraph(parent) {
+ PerformanceGraph.call(this, parent, ProfilerGlobal.L10N.getStr("graphs.fps"));
+}
+
+FramerateGraph.prototype = 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 Node parent
+ * The parent node holding the overview.
+ */
+function MemoryGraph(parent) {
+ PerformanceGraph.call(
+ this,
+ parent,
+ ProfilerGlobal.L10N.getStr("graphs.memory")
+ );
+}
+
+MemoryGraph.prototype = 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 = 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.
+ */
+ async render(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
+ await this._rendering;
+
+ // 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 = (async () => {
+ for (const graph of await this._getEnabled()) {
+ await graph.setPerformanceData(recordingData, resolution);
+ this.emit("rendered", graph.graphName);
+ }
+ })();
+ await this._rendering;
+ },
+
+ /**
+ * Destroys the underlying graphs.
+ */
+ async destroy() {
+ const 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) {
+ await this._rendering;
+ }
+
+ for (const graph of this.getWidgets()) {
+ await graph.destroy();
+ }
+ },
+
+ /**
+ * Applies the theme to the underlying graphs. Optionally takes
+ * a `redraw` boolean in the options to force redraw.
+ */
+ setTheme: function(options = {}) {
+ const theme = options.theme || this._getTheme();
+ for (const 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.
+ */
+ async isAvailable(graphName) {
+ if (!this._enabled.has(graphName)) {
+ return null;
+ }
+
+ let graph = this.get(graphName);
+
+ if (!graph) {
+ graph = await this._construct(graphName);
+ }
+
+ await 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) {
+ const 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 }) {
+ const 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.
+ */
+ async selectionEnabled(enabled) {
+ for (const graph of await this._getEnabled()) {
+ graph.selectionEnabled = enabled;
+ }
+ },
+
+ /**
+ * Creates the graph `graphName` and initializes it.
+ */
+ async _construct(graphName) {
+ const def = this._definition[graphName];
+ const el = this.$(def.selector);
+ const filter = this._getFilter();
+ const graph = (this._graphs[graphName] = new def.constructor(el, filter));
+ graph.graphName = graphName;
+
+ await 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).
+ */
+ async _getEnabled() {
+ if (this._enabledGraphs) {
+ return this._enabledGraphs;
+ }
+ const enabled = [];
+ for (const graphName of this._enabled) {
+ const graph = await this.isAvailable(graphName);
+ if (graph) {
+ enabled.push(graph);
+ }
+ }
+ this._enabledGraphs = enabled;
+ return this._enabledGraphs;
+ },
+};
+
+/**
+ * A base class for performance graphs to inherit from.
+ *
+ * @param Node 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 = extend(MountainGraphWidget.prototype, {
+ async render(threadNode, frameNode) {
+ // Regardless if we draw or clear the graph, wait
+ // until it's ready.
+ await this.ready();
+
+ if (!threadNode || !frameNode) {
+ this.setData([]);
+ return;
+ }
+
+ const { 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.
+ const startTime = sampleTimes[0];
+ const endTime = sampleTimes[sampleTimes.length - 1];
+
+ const bucketSize = (endTime - startTime) / OPTIMIZATIONS_GRAPH_RESOLUTION;
+ const 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;
+ await 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";
+
+ const interpreterColor = getColor("graphs-red", theme);
+ const baselineColor = getColor("graphs-blue", theme);
+ const 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 0000000000..2a8ce17ce4
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/marker-details.js
@@ -0,0 +1,177 @@
+/* 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 Node parent
+ * The parent node holding the view.
+ * @param Node 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) {
+ const { marker, frames } = options;
+ this.empty();
+
+ const 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) {
+ const type = marker.endStack ? "startStack" : "stack";
+ elements.push(
+ MarkerDOMUtils.buildStackTrace(this._document, {
+ frameIndex: marker.stack,
+ frames,
+ type,
+ })
+ );
+ }
+ if (marker.endStack) {
+ const 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) {
+ const 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 0000000000..ea762a371d
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/markers-overview.js
@@ -0,0 +1,256 @@
+/* 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 { extend } = require("devtools/shared/extend");
+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");
+
+const OVERVIEW_HEADER_HEIGHT = 14; // px
+const OVERVIEW_ROW_HEIGHT = 11; // px
+
+const OVERVIEW_SELECTION_LINE_COLOR = "#666";
+const OVERVIEW_CLIPHEAD_LINE_COLOR = "#555";
+
+const OVERVIEW_HEADER_TICKS_MULTIPLE = 100; // ms
+const OVERVIEW_HEADER_TICKS_SPACING_MIN = 75; // px
+const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px
+const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif";
+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; // px
+
+/**
+ * An overview for the markers data.
+ *
+ * @param Node 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 = 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);
+
+ const observedGroups = new Set();
+
+ for (const type in TIMELINE_BLUEPRINT) {
+ if (filter.includes(type)) {
+ 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 (const 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() {
+ const { markers, duration } = this._data;
+
+ const { canvas, ctx } = this._getNamedCanvas("markers-overview-data");
+ const canvasWidth = this._width;
+ const canvasHeight = this._height;
+
+ // Group markers into separate paint batches. This is necessary to
+ // draw all markers sharing the same style at once.
+ for (const 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;
+ }
+
+ const markerType =
+ this._paintBatches.get(marker.name) ||
+ this._paintBatches.get("UNKNOWN");
+ markerType.batch.push(marker);
+ }
+
+ // Calculate each row's height, and the time-based scaling.
+
+ const groupHeight = this.rowHeight * this._pixelRatio;
+ const groupPadding = this.groupPadding * this._pixelRatio;
+ const headerHeight = this.headerHeight * this._pixelRatio;
+ const 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) {
+ const top = headerHeight + i * groupHeight;
+ ctx.rect(0, top, canvasWidth, groupHeight);
+ }
+
+ ctx.fill();
+
+ // Draw the timeline header ticks.
+
+ const fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio;
+ const fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY;
+ const textPaddingLeft =
+ OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio;
+ const textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio;
+
+ const 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) {
+ const lineLeft = x;
+ const textLeft = lineLeft + textPaddingLeft;
+ const time = Math.round(x / dataScale);
+ const 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 (const [, { definition, batch }] of this._paintBatches) {
+ const group = this._groupMap[definition.group];
+ const top = headerHeight + group * groupHeight + groupPadding / 2;
+ const height = groupHeight - groupPadding;
+
+ const color = getColor(definition.colorName, this.theme);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+
+ for (const { start, end } of batch) {
+ const left = start * dataScale;
+ const 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("text-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 0000000000..d04890425c
--- /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 0000000000..79ad8229ff
--- /dev/null
+++ b/devtools/client/performance/modules/widgets/tree-view.js
@@ -0,0 +1,461 @@
+/* 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 { extend } = require("devtools/shared/extend");
+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"
+);
+
+const CALL_TREE_INDENTATION = 16; // px
+
+// 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) => {
+ const dataA = frameA.getDisplayedData();
+ const dataB = frameB.getDisplayedData();
+ const 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 = extend(AbstractTreeItem.prototype, {
+ /**
+ * Creates the view for this tree node.
+ * @param Node document
+ * @param Node arrowNode
+ * @return Node
+ */
+ _displaySelf: function(document, arrowNode) {
+ const frameInfo = this.getDisplayedData();
+ const cells = [];
+
+ for (const 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
+ )
+ );
+ }
+
+ const targetNode = document.createXULElement("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) {
+ const newLevel = this.level + 1;
+
+ for (const 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) {
+ const cell = doc.createXULElement("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
+ ) {
+ const cell = doc.createXULElement("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
+ ) {
+ const icon = doc.createXULElement("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) {
+ const nameNode = doc.createXULElement("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.
+ const 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.
+ const 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.
+ const firstDescription = cell.querySelector("description:first-of-type");
+ const levelIndicator = frameLevel > 0 ? " ".repeat(frameLevel) : "";
+ firstDescription.textContent =
+ levelIndicator + firstDescription.textContent;
+
+ return cell;
+ },
+
+ _appendFunctionDetailsCells: function(doc, cell, frameInfo) {
+ if (frameInfo.fileName) {
+ const urlNode = doc.createXULElement("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) {
+ const lineNode = doc.createXULElement("description");
+ lineNode.className = "plain call-tree-line";
+ lineNode.textContent = ":" + frameInfo.line;
+ cell.appendChild(lineNode);
+ }
+
+ if (frameInfo.column) {
+ const columnNode = doc.createXULElement("description");
+ columnNode.className = "plain call-tree-column";
+ columnNode.textContent = ":" + frameInfo.column;
+ cell.appendChild(columnNode);
+ }
+
+ if (frameInfo.host) {
+ const hostNode = doc.createXULElement("description");
+ hostNode.className = "plain call-tree-host";
+ hostNode.textContent = frameInfo.host;
+ cell.appendChild(hostNode);
+ }
+
+ if (frameInfo.categoryData.label) {
+ const categoryNode = doc.createXULElement("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;