summaryrefslogtreecommitdiffstats
path: root/devtools/client/performance/views
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/views
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/performance/views')
-rw-r--r--devtools/client/performance/views/details-abstract-subview.js232
-rw-r--r--devtools/client/performance/views/details-js-call-tree.js234
-rw-r--r--devtools/client/performance/views/details-js-flamegraph.js143
-rw-r--r--devtools/client/performance/views/details-memory-call-tree.js145
-rw-r--r--devtools/client/performance/views/details-memory-flamegraph.js138
-rw-r--r--devtools/client/performance/views/details-waterfall.js282
-rw-r--r--devtools/client/performance/views/details.js295
-rw-r--r--devtools/client/performance/views/moz.build17
-rw-r--r--devtools/client/performance/views/overview.js457
-rw-r--r--devtools/client/performance/views/recordings.js249
-rw-r--r--devtools/client/performance/views/toolbar.js188
11 files changed, 2380 insertions, 0 deletions
diff --git a/devtools/client/performance/views/details-abstract-subview.js b/devtools/client/performance/views/details-abstract-subview.js
new file mode 100644
index 0000000000..074d003229
--- /dev/null
+++ b/devtools/client/performance/views/details-abstract-subview.js
@@ -0,0 +1,232 @@
+/* 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 PerformanceController, OverviewView, DetailsView */
+"use strict";
+
+const {
+ setNamedTimeout,
+ clearNamedTimeout,
+} = require("devtools/client/shared/widgets/view-helpers");
+const EVENTS = require("devtools/client/performance/events");
+
+/**
+ * A base class from which all detail views inherit.
+ */
+const DetailsSubview = {
+ /**
+ * Sets up the view with event binding.
+ */
+ initialize: function() {
+ this._onRecordingStoppedOrSelected = this._onRecordingStoppedOrSelected.bind(
+ this
+ );
+ this._onOverviewRangeChange = this._onOverviewRangeChange.bind(this);
+ this._onDetailsViewSelected = this._onDetailsViewSelected.bind(this);
+ this._onPrefChanged = this._onPrefChanged.bind(this);
+
+ PerformanceController.on(
+ EVENTS.RECORDING_STATE_CHANGE,
+ this._onRecordingStoppedOrSelected
+ );
+ PerformanceController.on(
+ EVENTS.RECORDING_SELECTED,
+ this._onRecordingStoppedOrSelected
+ );
+ PerformanceController.on(EVENTS.PREF_CHANGED, this._onPrefChanged);
+ OverviewView.on(
+ EVENTS.UI_OVERVIEW_RANGE_SELECTED,
+ this._onOverviewRangeChange
+ );
+ DetailsView.on(
+ EVENTS.UI_DETAILS_VIEW_SELECTED,
+ this._onDetailsViewSelected
+ );
+
+ const self = this;
+ const originalRenderFn = this.render;
+ const afterRenderFn = () => {
+ this._wasRendered = true;
+ };
+
+ this.render = async function(...args) {
+ const maybeRetval = await originalRenderFn.apply(self, args);
+ afterRenderFn();
+ return maybeRetval;
+ };
+ },
+
+ /**
+ * Unbinds events.
+ */
+ destroy: function() {
+ clearNamedTimeout("range-change-debounce");
+
+ PerformanceController.off(
+ EVENTS.RECORDING_STATE_CHANGE,
+ this._onRecordingStoppedOrSelected
+ );
+ PerformanceController.off(
+ EVENTS.RECORDING_SELECTED,
+ this._onRecordingStoppedOrSelected
+ );
+ PerformanceController.off(EVENTS.PREF_CHANGED, this._onPrefChanged);
+ OverviewView.off(
+ EVENTS.UI_OVERVIEW_RANGE_SELECTED,
+ this._onOverviewRangeChange
+ );
+ DetailsView.off(
+ EVENTS.UI_DETAILS_VIEW_SELECTED,
+ this._onDetailsViewSelected
+ );
+ },
+
+ /**
+ * Returns true if this view was rendered at least once.
+ */
+ get wasRenderedAtLeastOnce() {
+ return !!this._wasRendered;
+ },
+
+ /**
+ * Amount of time (in milliseconds) to wait until this view gets updated,
+ * when the range is changed in the overview.
+ */
+ rangeChangeDebounceTime: 0,
+
+ /**
+ * When the overview range changes, all details views will require a
+ * rerendering at a later point, determined by `shouldUpdateWhenShown` and
+ * `canUpdateWhileHidden` and whether or not its the current view.
+ * Set `requiresUpdateOnRangeChange` to false to not invalidate the view
+ * when the range changes.
+ */
+ requiresUpdateOnRangeChange: true,
+
+ /**
+ * Flag specifying if this view should be updated when selected. This will
+ * be set to true, for example, when the range changes in the overview and
+ * this view is not currently visible.
+ */
+ shouldUpdateWhenShown: false,
+
+ /**
+ * Flag specifying if this view may get updated even when it's not selected.
+ * Should only be used in tests.
+ */
+ canUpdateWhileHidden: false,
+
+ /**
+ * An array of preferences under `devtools.performance.ui.` that the view should
+ * rerender and callback `this._onRerenderPrefChanged` upon change.
+ */
+ rerenderPrefs: [],
+
+ /**
+ * An array of preferences under `devtools.performance.` that the view should
+ * observe and callback `this._onObservedPrefChange` upon change.
+ */
+ observedPrefs: [],
+
+ /**
+ * Flag specifying if this view should update while the overview selection
+ * area is actively being dragged by the mouse.
+ */
+ shouldUpdateWhileMouseIsActive: false,
+
+ /**
+ * Called when recording stops or is selected.
+ */
+ _onRecordingStoppedOrSelected: function(state, recording) {
+ if (typeof state !== "string") {
+ recording = state;
+ }
+ if (arguments.length === 3 && state !== "recording-stopped") {
+ return;
+ }
+
+ if (!recording || !recording.isCompleted()) {
+ return;
+ }
+ if (DetailsView.isViewSelected(this) || this.canUpdateWhileHidden) {
+ this.render(OverviewView.getTimeInterval());
+ } else {
+ this.shouldUpdateWhenShown = true;
+ }
+ },
+
+ /**
+ * Fired when a range is selected or cleared in the OverviewView.
+ */
+ _onOverviewRangeChange: function(interval) {
+ if (!this.requiresUpdateOnRangeChange) {
+ return;
+ }
+ if (DetailsView.isViewSelected(this)) {
+ const debounced = () => {
+ if (
+ !this.shouldUpdateWhileMouseIsActive &&
+ OverviewView.isMouseActive
+ ) {
+ // Don't render yet, while the selection is still being dragged.
+ setNamedTimeout(
+ "range-change-debounce",
+ this.rangeChangeDebounceTime,
+ debounced
+ );
+ } else {
+ this.render(interval);
+ }
+ };
+ setNamedTimeout(
+ "range-change-debounce",
+ this.rangeChangeDebounceTime,
+ debounced
+ );
+ } else {
+ this.shouldUpdateWhenShown = true;
+ }
+ },
+
+ /**
+ * Fired when a view is selected in the DetailsView.
+ */
+ _onDetailsViewSelected: function() {
+ if (DetailsView.isViewSelected(this) && this.shouldUpdateWhenShown) {
+ this.render(OverviewView.getTimeInterval());
+ this.shouldUpdateWhenShown = false;
+ }
+ },
+
+ /**
+ * Fired when a preference in `devtools.performance.ui.` is changed.
+ */
+ _onPrefChanged: function(prefName, prefValue) {
+ if (~this.observedPrefs.indexOf(prefName) && this._onObservedPrefChange) {
+ this._onObservedPrefChange(prefName);
+ }
+
+ // All detail views require a recording to be complete, so do not
+ // attempt to render if recording is in progress or does not exist.
+ const recording = PerformanceController.getCurrentRecording();
+ if (!recording || !recording.isCompleted()) {
+ return;
+ }
+
+ if (!~this.rerenderPrefs.indexOf(prefName)) {
+ return;
+ }
+
+ if (this._onRerenderPrefChanged) {
+ this._onRerenderPrefChanged(prefName);
+ }
+
+ if (DetailsView.isViewSelected(this) || this.canUpdateWhileHidden) {
+ this.render(OverviewView.getTimeInterval());
+ } else {
+ this.shouldUpdateWhenShown = true;
+ }
+ },
+};
+
+exports.DetailsSubview = DetailsSubview;
diff --git a/devtools/client/performance/views/details-js-call-tree.js b/devtools/client/performance/views/details-js-call-tree.js
new file mode 100644
index 0000000000..7ef2d05483
--- /dev/null
+++ b/devtools/client/performance/views/details-js-call-tree.js
@@ -0,0 +1,234 @@
+/* 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 $, PerformanceController */
+"use strict";
+
+const { extend } = require("devtools/shared/extend");
+
+const React = require("devtools/client/shared/vendor/react");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom");
+
+const EVENTS = require("devtools/client/performance/events");
+const {
+ CallView,
+} = require("devtools/client/performance/modules/widgets/tree-view");
+const {
+ ThreadNode,
+} = require("devtools/client/performance/modules/logic/tree-model");
+const {
+ DetailsSubview,
+} = require("devtools/client/performance/views/details-abstract-subview");
+
+const JITOptimizationsView = React.createFactory(
+ require("devtools/client/performance/components/JITOptimizations")
+);
+
+const EventEmitter = require("devtools/shared/event-emitter");
+
+/**
+ * CallTree view containing profiler call tree, controlled by DetailsView.
+ */
+const JsCallTreeView = extend(DetailsSubview, {
+ rerenderPrefs: [
+ "invert-call-tree",
+ "show-platform-data",
+ "flatten-tree-recursion",
+ "show-jit-optimizations",
+ ],
+
+ // Units are in milliseconds.
+ rangeChangeDebounceTime: 75,
+
+ /**
+ * Sets up the view with event binding.
+ */
+ initialize: function() {
+ DetailsSubview.initialize.call(this);
+
+ this._onLink = this._onLink.bind(this);
+ this._onFocus = this._onFocus.bind(this);
+
+ this.container = $("#js-calltree-view .call-tree-cells-container");
+
+ this.optimizationsElement = $("#jit-optimizations-view");
+ },
+
+ /**
+ * Unbinds events.
+ */
+ destroy: function() {
+ ReactDOM.unmountComponentAtNode(this.optimizationsElement);
+ this.optimizationsElement = null;
+ this.container = null;
+ this.threadNode = null;
+ DetailsSubview.destroy.call(this);
+ },
+
+ /**
+ * Method for handling all the set up for rendering a new call tree.
+ *
+ * @param object interval [optional]
+ * The { startTime, endTime }, in milliseconds.
+ */
+ render: function(interval = {}) {
+ const recording = PerformanceController.getCurrentRecording();
+ const profile = recording.getProfile();
+ const showOptimizations = PerformanceController.getOption(
+ "show-jit-optimizations"
+ );
+
+ const options = {
+ contentOnly: !PerformanceController.getOption("show-platform-data"),
+ invertTree: PerformanceController.getOption("invert-call-tree"),
+ flattenRecursion: PerformanceController.getOption(
+ "flatten-tree-recursion"
+ ),
+ showOptimizationHint: showOptimizations,
+ };
+ const threadNode = (this.threadNode = this._prepareCallTree(
+ profile,
+ interval,
+ options
+ ));
+ this._populateCallTree(threadNode, options);
+
+ // For better or worse, re-rendering loses frame selection,
+ // so we should always hide opts on rerender
+ this.hideOptimizations();
+
+ this.emit(EVENTS.UI_JS_CALL_TREE_RENDERED);
+ },
+
+ showOptimizations: function() {
+ this.optimizationsElement.classList.remove("hidden");
+ },
+
+ hideOptimizations: function() {
+ this.optimizationsElement.classList.add("hidden");
+ },
+
+ _onFocus: function(treeItem) {
+ const showOptimizations = PerformanceController.getOption(
+ "show-jit-optimizations"
+ );
+ const frameNode = treeItem.frame;
+ const optimizationSites =
+ frameNode && frameNode.hasOptimizations()
+ ? frameNode.getOptimizations().optimizationSites
+ : [];
+
+ if (!showOptimizations || !frameNode || optimizationSites.length === 0) {
+ this.hideOptimizations();
+ this.emit("focus", treeItem);
+ return;
+ }
+
+ this.showOptimizations();
+
+ const frameData = frameNode.getInfo();
+ const optimizations = JITOptimizationsView({
+ frameData,
+ optimizationSites,
+ onViewSourceInDebugger: ({ url, line, column }) => {
+ PerformanceController.viewSourceInDebugger(url, line, column).then(
+ success => {
+ if (success) {
+ this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
+ } else {
+ this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
+ }
+ }
+ );
+ },
+ });
+
+ ReactDOM.render(optimizations, this.optimizationsElement);
+
+ this.emit("focus", treeItem);
+ },
+
+ /**
+ * Fired on the "link" event for the call tree in this container.
+ */
+ _onLink: function(treeItem) {
+ const { url, line, column } = treeItem.frame.getInfo();
+ PerformanceController.viewSourceInDebugger(url, line, column).then(
+ success => {
+ if (success) {
+ this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
+ } else {
+ this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
+ }
+ }
+ );
+ },
+
+ /**
+ * Called when the recording is stopped and prepares data to
+ * populate the call tree.
+ */
+ _prepareCallTree: function(profile, { startTime, endTime }, options) {
+ const thread = profile.threads[0];
+ const { contentOnly, invertTree, flattenRecursion } = options;
+ const threadNode = new ThreadNode(thread, {
+ startTime,
+ endTime,
+ contentOnly,
+ invertTree,
+ flattenRecursion,
+ });
+
+ // Real profiles from nsProfiler (i.e. not synthesized from allocation
+ // logs) always have a (root) node. Go down one level in the uninverted
+ // view to avoid displaying both the synthesized root node and the (root)
+ // node from the profiler.
+ if (!invertTree) {
+ threadNode.calls = threadNode.calls[0].calls;
+ }
+
+ return threadNode;
+ },
+
+ /**
+ * Renders the call tree.
+ */
+ _populateCallTree: function(frameNode, options = {}) {
+ // If we have an empty profile (no samples), then don't invert the tree, as
+ // it would hide the root node and a completely blank call tree space can be
+ // mis-interpreted as an error.
+ const inverted = options.invertTree && frameNode.samples > 0;
+
+ const root = new CallView({
+ frame: frameNode,
+ inverted: inverted,
+ // The synthesized root node is hidden in inverted call trees.
+ hidden: inverted,
+ // Call trees should only auto-expand when not inverted. Passing undefined
+ // will default to the CALL_TREE_AUTO_EXPAND depth.
+ autoExpandDepth: inverted ? 0 : undefined,
+ showOptimizationHint: options.showOptimizationHint,
+ });
+
+ // Bind events.
+ root.on("link", this._onLink);
+ root.on("focus", this._onFocus);
+
+ // Clear out other call trees.
+ this.container.innerHTML = "";
+ root.attachTo(this.container);
+
+ // When platform data isn't shown, hide the cateogry labels, since they're
+ // only available for C++ frames. Pass *false* to make them invisible.
+ root.toggleCategories(!options.contentOnly);
+
+ // Return the CallView for tests
+ return root;
+ },
+
+ toString: () => "[object JsCallTreeView]",
+});
+
+EventEmitter.decorate(JsCallTreeView);
+
+exports.JsCallTreeView = JsCallTreeView;
diff --git a/devtools/client/performance/views/details-js-flamegraph.js b/devtools/client/performance/views/details-js-flamegraph.js
new file mode 100644
index 0000000000..bacdb018f5
--- /dev/null
+++ b/devtools/client/performance/views/details-js-flamegraph.js
@@ -0,0 +1,143 @@
+/* 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 $, PerformanceController, OverviewView */
+"use strict";
+
+const { extend } = require("devtools/shared/extend");
+
+const EVENTS = require("devtools/client/performance/events");
+const { L10N } = require("devtools/client/performance/modules/global");
+const {
+ DetailsSubview,
+} = require("devtools/client/performance/views/details-abstract-subview");
+
+const {
+ FlameGraph,
+ FlameGraphUtils,
+} = require("devtools/client/shared/widgets/FlameGraph");
+
+const EventEmitter = require("devtools/shared/event-emitter");
+
+/**
+ * FlameGraph view containing a pyramid-like visualization of a profile,
+ * controlled by DetailsView.
+ */
+const JsFlameGraphView = extend(DetailsSubview, {
+ shouldUpdateWhileMouseIsActive: true,
+
+ rerenderPrefs: [
+ "invert-flame-graph",
+ "flatten-tree-recursion",
+ "show-platform-data",
+ "show-idle-blocks",
+ ],
+
+ /**
+ * Sets up the view with event binding.
+ */
+ async initialize() {
+ DetailsSubview.initialize.call(this);
+
+ this.graph = new FlameGraph($("#js-flamegraph-view"));
+ this.graph.timelineTickUnits = L10N.getStr("graphs.ms");
+ this.graph.setTheme(PerformanceController.getTheme());
+ await this.graph.ready();
+
+ this._onRangeChangeInGraph = this._onRangeChangeInGraph.bind(this);
+ this._onThemeChanged = this._onThemeChanged.bind(this);
+
+ PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged);
+ this.graph.on("selecting", this._onRangeChangeInGraph);
+ },
+
+ /**
+ * Unbinds events.
+ */
+ async destroy() {
+ DetailsSubview.destroy.call(this);
+
+ PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged);
+ this.graph.off("selecting", this._onRangeChangeInGraph);
+
+ await this.graph.destroy();
+ },
+
+ /**
+ * Method for handling all the set up for rendering a new flamegraph.
+ *
+ * @param object interval [optional]
+ * The { startTime, endTime }, in milliseconds.
+ */
+ render: function(interval = {}) {
+ const recording = PerformanceController.getCurrentRecording();
+ const duration = recording.getDuration();
+ const profile = recording.getProfile();
+ const thread = profile.threads[0];
+
+ const data = FlameGraphUtils.createFlameGraphDataFromThread(thread, {
+ invertTree: PerformanceController.getOption("invert-flame-graph"),
+ flattenRecursion: PerformanceController.getOption(
+ "flatten-tree-recursion"
+ ),
+ contentOnly: !PerformanceController.getOption("show-platform-data"),
+ showIdleBlocks:
+ PerformanceController.getOption("show-idle-blocks") &&
+ L10N.getStr("table.idle"),
+ });
+
+ this.graph.setData({
+ data,
+ bounds: {
+ startTime: 0,
+ endTime: duration,
+ },
+ visible: {
+ startTime: interval.startTime || 0,
+ endTime: interval.endTime || duration,
+ },
+ });
+
+ this.graph.focus();
+
+ this.emit(EVENTS.UI_JS_FLAMEGRAPH_RENDERED);
+ },
+
+ /**
+ * Fired when a range is selected or cleared in the FlameGraph.
+ */
+ _onRangeChangeInGraph: function() {
+ const interval = this.graph.getViewRange();
+
+ // Squelch rerendering this view when we update the range here
+ // to avoid recursion, as our FlameGraph handles rerendering itself
+ // when originating from within the graph.
+ this.requiresUpdateOnRangeChange = false;
+ OverviewView.setTimeInterval(interval);
+ this.requiresUpdateOnRangeChange = true;
+ },
+
+ /**
+ * Called whenever a pref is changed and this view needs to be rerendered.
+ */
+ _onRerenderPrefChanged: function() {
+ const recording = PerformanceController.getCurrentRecording();
+ const profile = recording.getProfile();
+ const thread = profile.threads[0];
+ FlameGraphUtils.removeFromCache(thread);
+ },
+
+ /**
+ * Called when `devtools.theme` changes.
+ */
+ _onThemeChanged: function(theme) {
+ this.graph.setTheme(theme);
+ this.graph.refresh({ force: true });
+ },
+
+ toString: () => "[object JsFlameGraphView]",
+});
+
+EventEmitter.decorate(JsFlameGraphView);
+
+exports.JsFlameGraphView = JsFlameGraphView;
diff --git a/devtools/client/performance/views/details-memory-call-tree.js b/devtools/client/performance/views/details-memory-call-tree.js
new file mode 100644
index 0000000000..17fcfcad0c
--- /dev/null
+++ b/devtools/client/performance/views/details-memory-call-tree.js
@@ -0,0 +1,145 @@
+/* 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 $, PerformanceController */
+"use strict";
+
+const { extend } = require("devtools/shared/extend");
+
+const EVENTS = require("devtools/client/performance/events");
+const {
+ ThreadNode,
+} = require("devtools/client/performance/modules/logic/tree-model");
+const {
+ CallView,
+} = require("devtools/client/performance/modules/widgets/tree-view");
+const {
+ DetailsSubview,
+} = require("devtools/client/performance/views/details-abstract-subview");
+
+const RecordingUtils = require("devtools/shared/performance/recording-utils");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+/**
+ * CallTree view containing memory allocation sites, controlled by DetailsView.
+ */
+const MemoryCallTreeView = extend(DetailsSubview, {
+ rerenderPrefs: ["invert-call-tree"],
+
+ // Units are in milliseconds.
+ rangeChangeDebounceTime: 100,
+
+ /**
+ * Sets up the view with event binding.
+ */
+ initialize: function() {
+ DetailsSubview.initialize.call(this);
+
+ this._onLink = this._onLink.bind(this);
+
+ this.container = $("#memory-calltree-view > .call-tree-cells-container");
+ },
+
+ /**
+ * Unbinds events.
+ */
+ destroy: function() {
+ DetailsSubview.destroy.call(this);
+ },
+
+ /**
+ * Method for handling all the set up for rendering a new call tree.
+ *
+ * @param object interval [optional]
+ * The { startTime, endTime }, in milliseconds.
+ */
+ render: function(interval = {}) {
+ const options = {
+ invertTree: PerformanceController.getOption("invert-call-tree"),
+ };
+ const recording = PerformanceController.getCurrentRecording();
+ const allocations = recording.getAllocations();
+ const threadNode = this._prepareCallTree(allocations, interval, options);
+ this._populateCallTree(threadNode, options);
+ this.emit(EVENTS.UI_MEMORY_CALL_TREE_RENDERED);
+ },
+
+ /**
+ * Fired on the "link" event for the call tree in this container.
+ */
+ _onLink: function(treeItem) {
+ const { url, line, column } = treeItem.frame.getInfo();
+ PerformanceController.viewSourceInDebugger(url, line, column).then(
+ success => {
+ if (success) {
+ this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
+ } else {
+ this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
+ }
+ }
+ );
+ },
+
+ /**
+ * Called when the recording is stopped and prepares data to
+ * populate the call tree.
+ */
+ _prepareCallTree: function(allocations, { startTime, endTime }, options) {
+ const thread = RecordingUtils.getProfileThreadFromAllocations(allocations);
+ const { invertTree } = options;
+
+ return new ThreadNode(thread, { startTime, endTime, invertTree });
+ },
+
+ /**
+ * Renders the call tree.
+ */
+ _populateCallTree: function(frameNode, options = {}) {
+ // If we have an empty profile (no samples), then don't invert the tree, as
+ // it would hide the root node and a completely blank call tree space can be
+ // mis-interpreted as an error.
+ const inverted = options.invertTree && frameNode.samples > 0;
+
+ const root = new CallView({
+ frame: frameNode,
+ inverted: inverted,
+ // Root nodes are hidden in inverted call trees.
+ hidden: inverted,
+ // Call trees should only auto-expand when not inverted. Passing undefined
+ // will default to the CALL_TREE_AUTO_EXPAND depth.
+ autoExpandDepth: inverted ? 0 : undefined,
+ // Some cells like the time duration and cost percentage don't make sense
+ // for a memory allocations call tree.
+ visibleCells: {
+ selfCount: true,
+ count: true,
+ selfSize: true,
+ size: true,
+ selfCountPercentage: true,
+ countPercentage: true,
+ selfSizePercentage: true,
+ sizePercentage: true,
+ function: true,
+ },
+ });
+
+ // Bind events.
+ root.on("link", this._onLink);
+
+ // Pipe "focus" events to the view, mostly for tests
+ root.on("focus", () => this.emit("focus"));
+
+ // Clear out other call trees.
+ this.container.innerHTML = "";
+ root.attachTo(this.container);
+
+ // Memory allocation samples don't contain cateogry labels.
+ root.toggleCategories(false);
+ },
+
+ toString: () => "[object MemoryCallTreeView]",
+});
+
+EventEmitter.decorate(MemoryCallTreeView);
+
+exports.MemoryCallTreeView = MemoryCallTreeView;
diff --git a/devtools/client/performance/views/details-memory-flamegraph.js b/devtools/client/performance/views/details-memory-flamegraph.js
new file mode 100644
index 0000000000..e8f1f51fd1
--- /dev/null
+++ b/devtools/client/performance/views/details-memory-flamegraph.js
@@ -0,0 +1,138 @@
+/* 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 $, PerformanceController, OverviewView */
+"use strict";
+
+const {
+ FlameGraph,
+ FlameGraphUtils,
+} = require("devtools/client/shared/widgets/FlameGraph");
+const { extend } = require("devtools/shared/extend");
+const RecordingUtils = require("devtools/shared/performance/recording-utils");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+const EVENTS = require("devtools/client/performance/events");
+const {
+ DetailsSubview,
+} = require("devtools/client/performance/views/details-abstract-subview");
+const { L10N } = require("devtools/client/performance/modules/global");
+
+/**
+ * FlameGraph view containing a pyramid-like visualization of memory allocation
+ * sites, controlled by DetailsView.
+ */
+const MemoryFlameGraphView = extend(DetailsSubview, {
+ shouldUpdateWhileMouseIsActive: true,
+
+ rerenderPrefs: [
+ "invert-flame-graph",
+ "flatten-tree-recursion",
+ "show-idle-blocks",
+ ],
+
+ /**
+ * Sets up the view with event binding.
+ */
+ async initialize() {
+ DetailsSubview.initialize.call(this);
+
+ this.graph = new FlameGraph($("#memory-flamegraph-view"));
+ this.graph.timelineTickUnits = L10N.getStr("graphs.ms");
+ this.graph.setTheme(PerformanceController.getTheme());
+ await this.graph.ready();
+
+ this._onRangeChangeInGraph = this._onRangeChangeInGraph.bind(this);
+ this._onThemeChanged = this._onThemeChanged.bind(this);
+
+ PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged);
+ this.graph.on("selecting", this._onRangeChangeInGraph);
+ },
+
+ /**
+ * Unbinds events.
+ */
+ async destroy() {
+ DetailsSubview.destroy.call(this);
+
+ PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged);
+ this.graph.off("selecting", this._onRangeChangeInGraph);
+
+ await this.graph.destroy();
+ },
+
+ /**
+ * Method for handling all the set up for rendering a new flamegraph.
+ *
+ * @param object interval [optional]
+ * The { startTime, endTime }, in milliseconds.
+ */
+ render: function(interval = {}) {
+ const recording = PerformanceController.getCurrentRecording();
+ const duration = recording.getDuration();
+ const allocations = recording.getAllocations();
+
+ const thread = RecordingUtils.getProfileThreadFromAllocations(allocations);
+ const data = FlameGraphUtils.createFlameGraphDataFromThread(thread, {
+ invertStack: PerformanceController.getOption("invert-flame-graph"),
+ flattenRecursion: PerformanceController.getOption(
+ "flatten-tree-recursion"
+ ),
+ showIdleBlocks:
+ PerformanceController.getOption("show-idle-blocks") &&
+ L10N.getStr("table.idle"),
+ });
+
+ this.graph.setData({
+ data,
+ bounds: {
+ startTime: 0,
+ endTime: duration,
+ },
+ visible: {
+ startTime: interval.startTime || 0,
+ endTime: interval.endTime || duration,
+ },
+ });
+
+ this.emit(EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED);
+ },
+
+ /**
+ * Fired when a range is selected or cleared in the FlameGraph.
+ */
+ _onRangeChangeInGraph: function() {
+ const interval = this.graph.getViewRange();
+
+ // Squelch rerendering this view when we update the range here
+ // to avoid recursion, as our FlameGraph handles rerendering itself
+ // when originating from within the graph.
+ this.requiresUpdateOnRangeChange = false;
+ OverviewView.setTimeInterval(interval);
+ this.requiresUpdateOnRangeChange = true;
+ },
+
+ /**
+ * Called whenever a pref is changed and this view needs to be rerendered.
+ */
+ _onRerenderPrefChanged: function() {
+ const recording = PerformanceController.getCurrentRecording();
+ const allocations = recording.getAllocations();
+ const thread = RecordingUtils.getProfileThreadFromAllocations(allocations);
+ FlameGraphUtils.removeFromCache(thread);
+ },
+
+ /**
+ * Called when `devtools.theme` changes.
+ */
+ _onThemeChanged: function(theme) {
+ this.graph.setTheme(theme);
+ this.graph.refresh({ force: true });
+ },
+
+ toString: () => "[object MemoryFlameGraphView]",
+});
+
+EventEmitter.decorate(MemoryFlameGraphView);
+
+exports.MemoryFlameGraphView = MemoryFlameGraphView;
diff --git a/devtools/client/performance/views/details-waterfall.js b/devtools/client/performance/views/details-waterfall.js
new file mode 100644
index 0000000000..975aa43874
--- /dev/null
+++ b/devtools/client/performance/views/details-waterfall.js
@@ -0,0 +1,282 @@
+/* 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 $, PerformanceController, OverviewView, DetailsView */
+"use strict";
+
+const { extend } = require("devtools/shared/extend");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {
+ setNamedTimeout,
+ clearNamedTimeout,
+} = require("devtools/client/shared/widgets/view-helpers");
+
+const React = require("devtools/client/shared/vendor/react");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom");
+
+const EVENTS = require("devtools/client/performance/events");
+const WaterfallUtils = require("devtools/client/performance/modules/logic/waterfall-utils");
+const {
+ TickUtils,
+} = require("devtools/client/performance/modules/waterfall-ticks");
+const {
+ MarkerDetails,
+} = require("devtools/client/performance/modules/widgets/marker-details");
+const {
+ DetailsSubview,
+} = require("devtools/client/performance/views/details-abstract-subview");
+
+const Waterfall = React.createFactory(
+ require("devtools/client/performance/components/Waterfall")
+);
+
+const MARKER_DETAILS_WIDTH = 200;
+// Units are in milliseconds.
+const WATERFALL_RESIZE_EVENTS_DRAIN = 100;
+
+/**
+ * Waterfall view containing the timeline markers, controlled by DetailsView.
+ */
+const WaterfallView = extend(DetailsSubview, {
+ // Smallest unit of time between two markers. Larger by 10x^3 than Number.EPSILON.
+ MARKER_EPSILON: 0.000000000001,
+ // px
+ WATERFALL_MARKER_SIDEBAR_WIDTH: 175,
+ // px
+ WATERFALL_MARKER_SIDEBAR_SAFE_BOUNDS: 20,
+
+ observedPrefs: ["hidden-markers"],
+
+ rerenderPrefs: ["hidden-markers"],
+
+ // Units are in milliseconds.
+ rangeChangeDebounceTime: 75,
+
+ /**
+ * Sets up the view with event binding.
+ */
+ initialize: function() {
+ DetailsSubview.initialize.call(this);
+
+ this._cache = new WeakMap();
+
+ this._onMarkerSelected = this._onMarkerSelected.bind(this);
+ this._onResize = this._onResize.bind(this);
+ this._onViewSource = this._onViewSource.bind(this);
+ this._onShowAllocations = this._onShowAllocations.bind(this);
+ this._hiddenMarkers = PerformanceController.getPref("hidden-markers");
+
+ this.treeContainer = $("#waterfall-tree");
+ this.detailsContainer = $("#waterfall-details");
+ this.detailsSplitter = $("#waterfall-view > splitter");
+
+ this.details = new MarkerDetails(
+ $("#waterfall-details"),
+ $("#waterfall-view > splitter")
+ );
+ this.details.hidden = true;
+
+ this.details.on("resize", this._onResize);
+ this.details.on("view-source", this._onViewSource);
+ this.details.on("show-allocations", this._onShowAllocations);
+ window.addEventListener("resize", this._onResize);
+
+ // TODO bug 1167093 save the previously set width, and ensure minimum width
+ this.details.width = MARKER_DETAILS_WIDTH;
+ },
+
+ /**
+ * Unbinds events.
+ */
+ destroy: function() {
+ DetailsSubview.destroy.call(this);
+
+ clearNamedTimeout("waterfall-resize");
+
+ this._cache = null;
+
+ this.details.off("resize", this._onResize);
+ this.details.off("view-source", this._onViewSource);
+ this.details.off("show-allocations", this._onShowAllocations);
+ window.removeEventListener("resize", this._onResize);
+
+ ReactDOM.unmountComponentAtNode(this.treeContainer);
+ },
+
+ /**
+ * Method for handling all the set up for rendering a new waterfall.
+ *
+ * @param object interval [optional]
+ * The { startTime, endTime }, in milliseconds.
+ */
+ render: function(interval = {}) {
+ const recording = PerformanceController.getCurrentRecording();
+ if (recording.isRecording()) {
+ return;
+ }
+ const startTime = interval.startTime || 0;
+ const endTime = interval.endTime || recording.getDuration();
+ const markers = recording.getMarkers();
+ const rootMarkerNode = this._prepareWaterfallTree(markers);
+
+ this._populateWaterfallTree(rootMarkerNode, { startTime, endTime });
+ this.emit(EVENTS.UI_WATERFALL_RENDERED);
+ },
+
+ /**
+ * Called when a marker is selected in the waterfall view,
+ * updating the markers detail view.
+ */
+ _onMarkerSelected: function(event, marker) {
+ const recording = PerformanceController.getCurrentRecording();
+ const frames = recording.getFrames();
+ const allocations = recording.getConfiguration().withAllocations;
+
+ if (event === "selected") {
+ this.details.render({ marker, frames, allocations });
+ this.details.hidden = false;
+ }
+ if (event === "unselected") {
+ this.details.empty();
+ }
+ },
+
+ /**
+ * Called when the marker details view is resized.
+ */
+ _onResize: function() {
+ setNamedTimeout("waterfall-resize", WATERFALL_RESIZE_EVENTS_DRAIN, () => {
+ this.render(OverviewView.getTimeInterval());
+ });
+ },
+
+ /**
+ * Called whenever an observed pref is changed.
+ */
+ _onObservedPrefChange: function(prefName) {
+ this._hiddenMarkers = PerformanceController.getPref("hidden-markers");
+
+ // Clear the cache as we'll need to recompute the collapsed
+ // marker model
+ this._cache = new WeakMap();
+ },
+
+ /**
+ * Called when MarkerDetails view emits an event to view source.
+ */
+ _onViewSource: function(data) {
+ PerformanceController.viewSourceInDebugger(
+ data.url,
+ data.line,
+ data.column
+ );
+ },
+
+ /**
+ * Called when MarkerDetails view emits an event to snap to allocations.
+ */
+ _onShowAllocations: function(data) {
+ let { endTime } = data;
+ let startTime = 0;
+ const recording = PerformanceController.getCurrentRecording();
+ const markers = recording.getMarkers();
+
+ let lastGCMarkerFromPreviousCycle = null;
+ let lastGCMarker = null;
+ // Iterate over markers looking for the most recent GC marker
+ // from the cycle before the marker's whose allocations we're interested in.
+ for (const marker of markers) {
+ // We found the marker whose allocations we're tracking; abort
+ if (marker.start === endTime) {
+ break;
+ }
+
+ if (marker.name === "GarbageCollection") {
+ if (lastGCMarker && lastGCMarker.cycle !== marker.cycle) {
+ lastGCMarkerFromPreviousCycle = lastGCMarker;
+ }
+ lastGCMarker = marker;
+ }
+ }
+
+ if (lastGCMarkerFromPreviousCycle) {
+ startTime = lastGCMarkerFromPreviousCycle.end;
+ }
+
+ // Adjust times so we don't include the range of these markers themselves.
+ endTime -= this.MARKER_EPSILON;
+ startTime += startTime !== 0 ? this.MARKER_EPSILON : 0;
+
+ OverviewView.setTimeInterval({ startTime, endTime });
+ DetailsView.selectView("memory-calltree");
+ },
+
+ /**
+ * Called when the recording is stopped and prepares data to
+ * populate the waterfall tree.
+ */
+ _prepareWaterfallTree: function(markers) {
+ const cached = this._cache.get(markers);
+ if (cached) {
+ return cached;
+ }
+
+ const rootMarkerNode = WaterfallUtils.createParentNode({ name: "(root)" });
+
+ WaterfallUtils.collapseMarkersIntoNode({
+ rootNode: rootMarkerNode,
+ markersList: markers,
+ filter: this._hiddenMarkers,
+ });
+
+ this._cache.set(markers, rootMarkerNode);
+ return rootMarkerNode;
+ },
+
+ /**
+ * Calculates the available width for the waterfall.
+ * This should be invoked every time the container node is resized.
+ */
+ _recalculateBounds: function() {
+ this.waterfallWidth =
+ this.treeContainer.clientWidth -
+ this.WATERFALL_MARKER_SIDEBAR_WIDTH -
+ this.WATERFALL_MARKER_SIDEBAR_SAFE_BOUNDS;
+ },
+
+ /**
+ * Renders the waterfall tree.
+ */
+ _populateWaterfallTree: function(rootMarkerNode, interval) {
+ this._recalculateBounds();
+
+ const doc = this.treeContainer.ownerDocument;
+ const startTime = interval.startTime | 0;
+ const endTime = interval.endTime | 0;
+ const dataScale = this.waterfallWidth / (endTime - startTime);
+
+ this.canvas = TickUtils.drawWaterfallBackground(
+ doc,
+ dataScale,
+ this.waterfallWidth
+ );
+
+ const treeView = Waterfall({
+ marker: rootMarkerNode,
+ startTime,
+ endTime,
+ dataScale,
+ sidebarWidth: this.WATERFALL_MARKER_SIDEBAR_WIDTH,
+ waterfallWidth: this.waterfallWidth,
+ onFocus: node => this._onMarkerSelected("selected", node),
+ });
+
+ ReactDOM.render(treeView, this.treeContainer);
+ },
+
+ toString: () => "[object WaterfallView]",
+});
+
+EventEmitter.decorate(WaterfallView);
+
+exports.WaterfallView = WaterfallView;
diff --git a/devtools/client/performance/views/details.js b/devtools/client/performance/views/details.js
new file mode 100644
index 0000000000..67c97d6bb5
--- /dev/null
+++ b/devtools/client/performance/views/details.js
@@ -0,0 +1,295 @@
+/* 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 $, $$, PerformanceController */
+
+"use strict";
+
+const EVENTS = require("devtools/client/performance/events");
+
+const {
+ WaterfallView,
+} = require("devtools/client/performance/views/details-waterfall");
+const {
+ JsCallTreeView,
+} = require("devtools/client/performance/views/details-js-call-tree");
+const {
+ JsFlameGraphView,
+} = require("devtools/client/performance/views/details-js-flamegraph");
+const {
+ MemoryCallTreeView,
+} = require("devtools/client/performance/views/details-memory-call-tree");
+const {
+ MemoryFlameGraphView,
+} = require("devtools/client/performance/views/details-memory-flamegraph");
+
+const EventEmitter = require("devtools/shared/event-emitter");
+
+/**
+ * Details view containing call trees, flamegraphs and markers waterfall.
+ * Manages subviews and toggles visibility between them.
+ */
+const DetailsView = {
+ /**
+ * Name to (node id, view object, actor requirements, pref killswitch)
+ * mapping of subviews.
+ */
+ components: {
+ waterfall: {
+ id: "waterfall-view",
+ view: WaterfallView,
+ features: ["withMarkers"],
+ },
+ "js-calltree": {
+ id: "js-profile-view",
+ view: JsCallTreeView,
+ },
+ "js-flamegraph": {
+ id: "js-flamegraph-view",
+ view: JsFlameGraphView,
+ },
+ "memory-calltree": {
+ id: "memory-calltree-view",
+ view: MemoryCallTreeView,
+ features: ["withAllocations"],
+ },
+ "memory-flamegraph": {
+ id: "memory-flamegraph-view",
+ view: MemoryFlameGraphView,
+ features: ["withAllocations"],
+ prefs: ["enable-memory-flame"],
+ },
+ },
+
+ /**
+ * Sets up the view with event binding, initializes subviews.
+ */
+ async initialize() {
+ this.el = $("#details-pane");
+ this.toolbar = $("#performance-toolbar-controls-detail-views");
+
+ this._onViewToggle = this._onViewToggle.bind(this);
+ this._onRecordingStoppedOrSelected = this._onRecordingStoppedOrSelected.bind(
+ this
+ );
+ this.setAvailableViews = this.setAvailableViews.bind(this);
+
+ for (const button of $$("toolbarbutton[data-view]", this.toolbar)) {
+ button.addEventListener("command", this._onViewToggle);
+ }
+
+ await this.setAvailableViews();
+
+ PerformanceController.on(
+ EVENTS.RECORDING_STATE_CHANGE,
+ this._onRecordingStoppedOrSelected
+ );
+ PerformanceController.on(
+ EVENTS.RECORDING_SELECTED,
+ this._onRecordingStoppedOrSelected
+ );
+ PerformanceController.on(EVENTS.PREF_CHANGED, this.setAvailableViews);
+ },
+
+ /**
+ * Unbinds events, destroys subviews.
+ */
+ async destroy() {
+ for (const button of $$("toolbarbutton[data-view]", this.toolbar)) {
+ button.removeEventListener("command", this._onViewToggle);
+ }
+
+ for (const component of Object.values(this.components)) {
+ component.initialized && (await component.view.destroy());
+ }
+
+ PerformanceController.off(
+ EVENTS.RECORDING_STATE_CHANGE,
+ this._onRecordingStoppedOrSelected
+ );
+ PerformanceController.off(
+ EVENTS.RECORDING_SELECTED,
+ this._onRecordingStoppedOrSelected
+ );
+ PerformanceController.off(EVENTS.PREF_CHANGED, this.setAvailableViews);
+ },
+
+ /**
+ * Sets the possible views based off of recording features and server actor support
+ * by hiding/showing the buttons that select them and going to default view
+ * if currently selected. Called when a preference changes in
+ * `devtools.performance.ui.`.
+ */
+ async setAvailableViews() {
+ const recording = PerformanceController.getCurrentRecording();
+ const isCompleted = recording && recording.isCompleted();
+ let invalidCurrentView = false;
+
+ for (const [name, { view }] of Object.entries(this.components)) {
+ const isSupported = this._isViewSupported(name);
+
+ $(`toolbarbutton[data-view=${name}]`).hidden = !isSupported;
+
+ // If the view is currently selected and not supported, go back to the
+ // default view.
+ if (!isSupported && this.isViewSelected(view)) {
+ invalidCurrentView = true;
+ }
+ }
+
+ // Two scenarios in which we select the default view.
+ //
+ // 1: If we currently have selected a view that is no longer valid due
+ // to feature support, and this isn't the first view, and the current recording
+ // is completed.
+ //
+ // 2. If we have a finished recording and no panel was selected yet,
+ // use a default now that we have the recording configurations
+ if (
+ (this._initialized && isCompleted && invalidCurrentView) ||
+ (!this._initialized && isCompleted && recording)
+ ) {
+ await this.selectDefaultView();
+ }
+ },
+
+ /**
+ * Takes a view name and determines if the current recording
+ * can support the view.
+ *
+ * @param {string} viewName
+ * @return {boolean}
+ */
+ _isViewSupported: function(viewName) {
+ const { features, prefs } = this.components[viewName];
+ const recording = PerformanceController.getCurrentRecording();
+
+ if (!recording || !recording.isCompleted()) {
+ return false;
+ }
+
+ const prefSupported = prefs?.length
+ ? prefs.every(p => PerformanceController.getPref(p))
+ : true;
+ return PerformanceController.isFeatureSupported(features) && prefSupported;
+ },
+
+ /**
+ * Select one of the DetailView's subviews to be rendered,
+ * hiding the others.
+ *
+ * @param String viewName
+ * Name of the view to be shown.
+ */
+ async selectView(viewName) {
+ const component = this.components[viewName];
+ this.el.selectedPanel = $("#" + component.id);
+
+ await this._whenViewInitialized(component);
+
+ for (const button of $$("toolbarbutton[data-view]", this.toolbar)) {
+ if (button.getAttribute("data-view") === viewName) {
+ button.setAttribute("checked", true);
+ } else {
+ button.removeAttribute("checked");
+ }
+ }
+
+ // Set a flag indicating that a view was explicitly set based on a
+ // recording's features.
+ this._initialized = true;
+
+ this.emit(EVENTS.UI_DETAILS_VIEW_SELECTED, viewName);
+ },
+
+ /**
+ * Selects a default view based off of protocol support
+ * and preferences enabled.
+ */
+ selectDefaultView: function() {
+ // We want the waterfall to be default view in almost all cases, except when
+ // timeline actor isn't supported, or we have markers disabled (which should only
+ // occur temporarily via bug 1156499
+ if (this._isViewSupported("waterfall")) {
+ return this.selectView("waterfall");
+ }
+ // The JS CallTree should always be supported since the profiler
+ // actor is as old as the world.
+ return this.selectView("js-calltree");
+ },
+
+ /**
+ * Checks if the provided view is currently selected.
+ *
+ * @param object viewObject
+ * @return boolean
+ */
+ isViewSelected: function(viewObject) {
+ // If not initialized, and we have no recordings,
+ // no views are selected (even though there's a selected panel)
+ if (!this._initialized) {
+ return false;
+ }
+
+ const selectedPanel = this.el.selectedPanel;
+ const selectedId = selectedPanel.id;
+
+ for (const { id, view } of Object.values(this.components)) {
+ if (id == selectedId && view == viewObject) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Initializes a subview if it wasn't already set up, and makes sure
+ * it's populated with recording data if there is some available.
+ *
+ * @param object component
+ * A component descriptor from DetailsView.components
+ */
+ async _whenViewInitialized(component) {
+ if (component.initialized) {
+ return;
+ }
+ component.initialized = true;
+ await component.view.initialize();
+
+ // If this view is initialized *after* a recording is shown, it won't display
+ // any data. Make sure it's populated by setting `shouldUpdateWhenShown`.
+ // All detail views require a recording to be complete, so do not
+ // attempt to render if recording is in progress or does not exist.
+ const recording = PerformanceController.getCurrentRecording();
+ if (recording && recording.isCompleted()) {
+ component.view.shouldUpdateWhenShown = true;
+ }
+ },
+
+ /**
+ * Called when recording stops or is selected.
+ */
+ _onRecordingStoppedOrSelected: function(state, recording) {
+ if (typeof state === "string" && state !== "recording-stopped") {
+ return;
+ }
+ this.setAvailableViews();
+ },
+
+ /**
+ * Called when a view button is clicked.
+ */
+ _onViewToggle: function(e) {
+ this.selectView(e.target.getAttribute("data-view"));
+ },
+
+ toString: () => "[object DetailsView]",
+};
+
+/**
+ * Convenient way of emitting events from the view.
+ */
+EventEmitter.decorate(DetailsView);
+
+exports.DetailsView = DetailsView;
diff --git a/devtools/client/performance/views/moz.build b/devtools/client/performance/views/moz.build
new file mode 100644
index 0000000000..d10fbf3dac
--- /dev/null
+++ b/devtools/client/performance/views/moz.build
@@ -0,0 +1,17 @@
+# 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(
+ "details-abstract-subview.js",
+ "details-js-call-tree.js",
+ "details-js-flamegraph.js",
+ "details-memory-call-tree.js",
+ "details-memory-flamegraph.js",
+ "details-waterfall.js",
+ "details.js",
+ "overview.js",
+ "recordings.js",
+ "toolbar.js",
+)
diff --git a/devtools/client/performance/views/overview.js b/devtools/client/performance/views/overview.js
new file mode 100644
index 0000000000..9257a48fff
--- /dev/null
+++ b/devtools/client/performance/views/overview.js
@@ -0,0 +1,457 @@
+/* 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 $ PerformanceController */
+"use strict";
+
+// No sense updating the overview more often than receiving data from the
+// backend. Make sure this isn't lower than DEFAULT_TIMELINE_DATA_PULL_TIMEOUT
+// in devtools/server/actors/timeline.js
+
+const EVENTS = require("devtools/client/performance/events");
+const {
+ GraphsController,
+} = require("devtools/client/performance/modules/widgets/graphs");
+
+const EventEmitter = require("devtools/shared/event-emitter");
+
+// The following units are in milliseconds.
+const OVERVIEW_UPDATE_INTERVAL = 200;
+const FRAMERATE_GRAPH_LOW_RES_INTERVAL = 100;
+const FRAMERATE_GRAPH_HIGH_RES_INTERVAL = 16;
+const GRAPH_REQUIREMENTS = {
+ timeline: {
+ features: ["withMarkers"],
+ },
+ framerate: {
+ features: ["withTicks"],
+ },
+ memory: {
+ features: ["withMemory"],
+ },
+};
+
+/**
+ * View handler for the overview panel's time view, displaying
+ * framerate, timeline and memory over time.
+ */
+const OverviewView = {
+ /**
+ * How frequently we attempt to render the graphs. Overridden
+ * in tests.
+ */
+ OVERVIEW_UPDATE_INTERVAL: OVERVIEW_UPDATE_INTERVAL,
+
+ /**
+ * Sets up the view with event binding.
+ */
+ initialize: function() {
+ this.graphs = new GraphsController({
+ root: $("#overview-pane"),
+ getFilter: () => PerformanceController.getPref("hidden-markers"),
+ getTheme: () => PerformanceController.getTheme(),
+ });
+
+ // If no timeline support, shut it all down.
+ if (!PerformanceController.getTraits().features.withMarkers) {
+ this.disable();
+ return;
+ }
+
+ // Store info on multiprocess support.
+ this._multiprocessData = PerformanceController.getMultiprocessStatus();
+
+ this._onRecordingStateChange = this._onRecordingStateChange.bind(this);
+ this._onRecordingSelected = this._onRecordingSelected.bind(this);
+ this._onRecordingTick = this._onRecordingTick.bind(this);
+ this._onGraphSelecting = this._onGraphSelecting.bind(this);
+ this._onGraphRendered = this._onGraphRendered.bind(this);
+ this._onPrefChanged = this._onPrefChanged.bind(this);
+ this._onThemeChanged = this._onThemeChanged.bind(this);
+
+ // Toggle the initial visibility of memory and framerate graph containers
+ // based off of prefs.
+ PerformanceController.on(EVENTS.PREF_CHANGED, this._onPrefChanged);
+ PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged);
+ PerformanceController.on(
+ EVENTS.RECORDING_STATE_CHANGE,
+ this._onRecordingStateChange
+ );
+ PerformanceController.on(
+ EVENTS.RECORDING_SELECTED,
+ this._onRecordingSelected
+ );
+ this.graphs.on("selecting", this._onGraphSelecting);
+ this.graphs.on("rendered", this._onGraphRendered);
+ },
+
+ /**
+ * Unbinds events.
+ */
+ async destroy() {
+ PerformanceController.off(EVENTS.PREF_CHANGED, this._onPrefChanged);
+ PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged);
+ PerformanceController.off(
+ EVENTS.RECORDING_STATE_CHANGE,
+ this._onRecordingStateChange
+ );
+ PerformanceController.off(
+ EVENTS.RECORDING_SELECTED,
+ this._onRecordingSelected
+ );
+ this.graphs.off("selecting", this._onGraphSelecting);
+ this.graphs.off("rendered", this._onGraphRendered);
+ await this.graphs.destroy();
+ },
+
+ /**
+ * Returns true if any of the overview graphs have mouse dragging active,
+ * false otherwise.
+ */
+ get isMouseActive() {
+ // Fetch all graphs currently stored in the GraphsController.
+ // These graphs are not necessarily active, but will not have
+ // an active mouse, in that case.
+ return !!this.graphs.getWidgets().some(e => e.isMouseActive);
+ },
+
+ /**
+ * Disabled in the event we're using a Timeline mock, so we'll have no
+ * timeline, ticks or memory data to show, so just block rendering and hide
+ * the panel.
+ */
+ disable: function() {
+ this._disabled = true;
+ this.graphs.disableAll();
+ },
+
+ /**
+ * Returns the disabled status.
+ *
+ * @return boolean
+ */
+ isDisabled: function() {
+ return this._disabled;
+ },
+
+ /**
+ * Sets the time interval selection for all graphs in this overview.
+ *
+ * @param object interval
+ * The { startTime, endTime }, in milliseconds.
+ */
+ setTimeInterval: function(interval, options = {}) {
+ const recording = PerformanceController.getCurrentRecording();
+ if (recording == null) {
+ throw new Error(
+ "A recording should be available in order to set the selection."
+ );
+ }
+ if (this.isDisabled()) {
+ return;
+ }
+ const mapStart = () => 0;
+ const mapEnd = () => recording.getDuration();
+ const selection = { start: interval.startTime, end: interval.endTime };
+ this._stopSelectionChangeEventPropagation = options.stopPropagation;
+ this.graphs.setMappedSelection(selection, { mapStart, mapEnd });
+ this._stopSelectionChangeEventPropagation = false;
+ },
+
+ /**
+ * Gets the time interval selection for all graphs in this overview.
+ *
+ * @return object
+ * The { startTime, endTime }, in milliseconds.
+ */
+ getTimeInterval: function() {
+ const recording = PerformanceController.getCurrentRecording();
+ if (recording == null) {
+ throw new Error(
+ "A recording should be available in order to get the selection."
+ );
+ }
+ if (this.isDisabled()) {
+ return { startTime: 0, endTime: recording.getDuration() };
+ }
+ const mapStart = () => 0;
+ const mapEnd = () => recording.getDuration();
+ const selection = this.graphs.getMappedSelection({ mapStart, mapEnd });
+ // If no selection returned, this means the overview graphs have not been rendered
+ // yet, so act as if we have no selection (the full recording). Also
+ // if the selection range distance is tiny, assume the range was cleared or just
+ // clicked, and we do not have a range.
+ if (!selection || selection.max - selection.min < 1) {
+ return { startTime: 0, endTime: recording.getDuration() };
+ }
+ return { startTime: selection.min, endTime: selection.max };
+ },
+
+ /**
+ * Method for handling all the set up for rendering the overview graphs.
+ *
+ * @param number resolution
+ * The fps graph resolution. @see Graphs.js
+ */
+ async render(resolution) {
+ if (this.isDisabled()) {
+ return;
+ }
+
+ const recording = PerformanceController.getCurrentRecording();
+ await this.graphs.render(recording.getAllData(), resolution);
+
+ // Finished rendering all graphs in this overview.
+ this.emit(EVENTS.UI_OVERVIEW_RENDERED, resolution);
+ },
+
+ /**
+ * Called at most every OVERVIEW_UPDATE_INTERVAL milliseconds
+ * and uses data fetched from the controller to render
+ * data into all the corresponding overview graphs.
+ */
+ async _onRecordingTick() {
+ await this.render(FRAMERATE_GRAPH_LOW_RES_INTERVAL);
+ this._prepareNextTick();
+ },
+
+ /**
+ * Called to refresh the timer to keep firing _onRecordingTick.
+ */
+ _prepareNextTick: function() {
+ // Check here to see if there's still a _timeoutId, incase
+ // `stop` was called before the _prepareNextTick call was executed.
+ if (this.isRendering()) {
+ this._timeoutId = setTimeout(
+ this._onRecordingTick,
+ this.OVERVIEW_UPDATE_INTERVAL
+ );
+ }
+ },
+
+ /**
+ * Called when recording state changes.
+ */
+ _onRecordingStateChange: OverviewViewOnStateChange(async function(
+ state,
+ recording
+ ) {
+ if (state !== "recording-stopped") {
+ return;
+ }
+ // Check to see if the recording that just stopped is the current recording.
+ // If it is, render the high-res graphs. For manual recordings, it will also
+ // be the current recording, but profiles generated by `console.profile` can stop
+ // while having another profile selected -- in this case, OverviewView should keep
+ // rendering the current recording.
+ if (recording !== PerformanceController.getCurrentRecording()) {
+ return;
+ }
+ await this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL);
+ await this._checkSelection(recording);
+ }),
+
+ /**
+ * Called when a new recording is selected.
+ */
+ _onRecordingSelected: OverviewViewOnStateChange(async function(recording) {
+ this._setGraphVisibilityFromRecordingFeatures(recording);
+
+ // If this recording is complete, render the high res graph
+ if (recording.isCompleted()) {
+ await this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL);
+ }
+ await this._checkSelection(recording);
+ this.graphs.dropSelection();
+ }),
+
+ /**
+ * Start the polling for rendering the overview graph.
+ */
+ _startPolling: function() {
+ this._timeoutId = setTimeout(
+ this._onRecordingTick,
+ this.OVERVIEW_UPDATE_INTERVAL
+ );
+ },
+
+ /**
+ * Stop the polling for rendering the overview graph.
+ */
+ _stopPolling: function() {
+ clearTimeout(this._timeoutId);
+ this._timeoutId = null;
+ },
+
+ /**
+ * Whether or not the overview view is in a state of polling rendering.
+ */
+ isRendering: function() {
+ return !!this._timeoutId;
+ },
+
+ /**
+ * Makes sure the selection is enabled or disabled in all the graphs,
+ * based on whether a recording currently exists and is not in progress.
+ */
+ async _checkSelection(recording) {
+ const isEnabled = recording ? recording.isCompleted() : false;
+ await this.graphs.selectionEnabled(isEnabled);
+ },
+
+ /**
+ * Fired when the graph selection has changed. Called by
+ * mouseup and scroll events.
+ */
+ _onGraphSelecting: function() {
+ if (this._stopSelectionChangeEventPropagation) {
+ return;
+ }
+
+ this.emit(EVENTS.UI_OVERVIEW_RANGE_SELECTED, this.getTimeInterval());
+ },
+
+ _onGraphRendered: function(graphName) {
+ switch (graphName) {
+ case "timeline":
+ this.emit(EVENTS.UI_MARKERS_GRAPH_RENDERED);
+ break;
+ case "memory":
+ this.emit(EVENTS.UI_MEMORY_GRAPH_RENDERED);
+ break;
+ case "framerate":
+ this.emit(EVENTS.UI_FRAMERATE_GRAPH_RENDERED);
+ break;
+ }
+ },
+
+ /**
+ * Called whenever a preference in `devtools.performance.ui.` changes.
+ * Does not care about the enabling of memory/framerate graphs,
+ * because those will set values on a recording model, and
+ * the graphs will render based on the existence.
+ */
+ async _onPrefChanged(prefName, prefValue) {
+ switch (prefName) {
+ case "hidden-markers": {
+ const graph = await this.graphs.isAvailable("timeline");
+ if (graph) {
+ const filter = PerformanceController.getPref("hidden-markers");
+ graph.setFilter(filter);
+ graph.refresh({ force: true });
+ }
+ break;
+ }
+ }
+ },
+
+ _setGraphVisibilityFromRecordingFeatures: function(recording) {
+ for (const [graphName, requirements] of Object.entries(
+ GRAPH_REQUIREMENTS
+ )) {
+ this.graphs.enable(
+ graphName,
+ PerformanceController.isFeatureSupported(requirements.features)
+ );
+ }
+ },
+
+ /**
+ * Fetch the multiprocess status and if e10s is not currently on, disable
+ * realtime rendering.
+ *
+ * @return {boolean}
+ */
+ isRealtimeRenderingEnabled: function() {
+ return this._multiprocessData.enabled;
+ },
+
+ /**
+ * Show the graphs overview panel when a recording is finished
+ * when non-realtime graphs are enabled. Also set the graph visibility
+ * so the performance graphs know which graphs to render.
+ *
+ * @param {RecordingModel} recording
+ */
+ _showGraphsPanel: function(recording) {
+ this._setGraphVisibilityFromRecordingFeatures(recording);
+ $("#overview-pane").classList.remove("hidden");
+ },
+
+ /**
+ * Hide the graphs container completely.
+ */
+ _hideGraphsPanel: function() {
+ $("#overview-pane").classList.add("hidden");
+ },
+
+ /**
+ * Called when `devtools.theme` changes.
+ */
+ _onThemeChanged: function(theme) {
+ this.graphs.setTheme({ theme, redraw: true });
+ },
+
+ toString: () => "[object OverviewView]",
+};
+
+/**
+ * Utility that can wrap a method of OverviewView that
+ * handles a recording state change like when a recording is starting,
+ * stopping, or about to start/stop, and determines whether or not
+ * the polling for rendering the overview graphs needs to start or stop.
+ * Must be called with the OverviewView context.
+ *
+ * @param {function?} fn
+ * @return {function}
+ */
+function OverviewViewOnStateChange(fn) {
+ return function _onRecordingStateChange(recording) {
+ // Normalize arguments for the RECORDING_STATE_CHANGE event,
+ // as it also has a `state` argument.
+ if (typeof recording === "string") {
+ recording = arguments[1];
+ }
+
+ const currentRecording = PerformanceController.getCurrentRecording();
+
+ // All these methods require a recording to exist selected and
+ // from the event name, since there is a delay between starting
+ // a recording and changing the selection.
+ if (!currentRecording || !recording) {
+ // If no recording (this can occur when having a console.profile recording, and
+ // we do not stop it from the backend), and we are still rendering updates,
+ // stop that.
+ if (this.isRendering()) {
+ this._stopPolling();
+ }
+ return;
+ }
+
+ // If realtime rendering is not enabed (e10s not on), then
+ // show the disabled message, or the full graphs if the recording is completed
+ if (!this.isRealtimeRenderingEnabled()) {
+ if (recording.isRecording()) {
+ this._hideGraphsPanel();
+ // Abort, as we do not want to change polling status.
+ return;
+ }
+ this._showGraphsPanel(recording);
+ }
+
+ if (this.isRendering() && !currentRecording.isRecording()) {
+ this._stopPolling();
+ } else if (currentRecording.isRecording() && !this.isRendering()) {
+ this._startPolling();
+ }
+
+ if (fn) {
+ fn.apply(this, arguments);
+ }
+ };
+}
+
+// Decorates the OverviewView as an EventEmitter
+EventEmitter.decorate(OverviewView);
+
+exports.OverviewView = OverviewView;
diff --git a/devtools/client/performance/views/recordings.js b/devtools/client/performance/views/recordings.js
new file mode 100644
index 0000000000..d73b1ac083
--- /dev/null
+++ b/devtools/client/performance/views/recordings.js
@@ -0,0 +1,249 @@
+/* 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 $, PerformanceController */
+"use strict";
+
+const EVENTS = require("devtools/client/performance/events");
+const { L10N } = require("devtools/client/performance/modules/global");
+
+const PerformanceUtils = require("devtools/client/performance/modules/utils");
+
+const React = require("devtools/client/shared/vendor/react");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom");
+const RecordingList = React.createFactory(
+ require("devtools/client/performance/components/RecordingList")
+);
+const RecordingListItem = React.createFactory(
+ require("devtools/client/performance/components/RecordingListItem")
+);
+
+const EventEmitter = require("devtools/shared/event-emitter");
+
+/**
+ * Functions handling the recordings UI.
+ */
+const RecordingsView = {
+ /**
+ * Initialization function, called when the tool is started.
+ */
+ initialize: function() {
+ this._onSelect = this._onSelect.bind(this);
+ this._onRecordingStateChange = this._onRecordingStateChange.bind(this);
+ this._onNewRecording = this._onNewRecording.bind(this);
+ this._onSaveButtonClick = this._onSaveButtonClick.bind(this);
+ this._onRecordingDeleted = this._onRecordingDeleted.bind(this);
+ this._onRecordingExported = this._onRecordingExported.bind(this);
+
+ PerformanceController.on(
+ EVENTS.RECORDING_STATE_CHANGE,
+ this._onRecordingStateChange
+ );
+ PerformanceController.on(EVENTS.RECORDING_ADDED, this._onNewRecording);
+ PerformanceController.on(
+ EVENTS.RECORDING_DELETED,
+ this._onRecordingDeleted
+ );
+ PerformanceController.on(
+ EVENTS.RECORDING_EXPORTED,
+ this._onRecordingExported
+ );
+
+ // DE-XUL: Begin migrating the recording sidebar to React. Temporarily hold state
+ // here.
+ this._listState = {
+ recordings: [],
+ labels: new WeakMap(),
+ selected: null,
+ };
+ this._listMount = PerformanceUtils.createHtmlMount(
+ $("#recording-list-mount")
+ );
+ this._renderList();
+ },
+
+ /**
+ * Get the index of the currently selected recording. Only used by tests.
+ * @return {integer} index
+ */
+ getSelectedIndex() {
+ const { recordings, selected } = this._listState;
+ return recordings.indexOf(selected);
+ },
+
+ /**
+ * Set the currently selected recording via its index. Only used by tests.
+ * @param {integer} index
+ */
+ setSelectedByIndex(index) {
+ this._onSelect(this._listState.recordings[index]);
+ this._renderList();
+ },
+
+ /**
+ * DE-XUL: During the migration, this getter will access the selected recording from
+ * the private _listState object so that tests will continue to pass.
+ */
+ get selected() {
+ return this._listState.selected;
+ },
+
+ /**
+ * DE-XUL: During the migration, this getter will access the number of recordings.
+ */
+ get itemCount() {
+ return this._listState.recordings.length;
+ },
+
+ /**
+ * DE-XUL: Render the recording list using React.
+ */
+ _renderList: function() {
+ const { recordings, labels, selected } = this._listState;
+
+ const recordingList = RecordingList({
+ itemComponent: RecordingListItem,
+ items: recordings.map(recording => ({
+ onSelect: () => this._onSelect(recording),
+ onSave: () => this._onSaveButtonClick(recording),
+ isLoading: !recording.isRecording() && !recording.isCompleted(),
+ isRecording: recording.isRecording(),
+ isSelected: recording === selected,
+ duration: recording.getDuration().toFixed(0),
+ label: labels.get(recording),
+ })),
+ });
+
+ ReactDOM.render(recordingList, this._listMount);
+ },
+
+ /**
+ * Destruction function, called when the tool is closed.
+ */
+ destroy: function() {
+ PerformanceController.off(
+ EVENTS.RECORDING_STATE_CHANGE,
+ this._onRecordingStateChange
+ );
+ PerformanceController.off(EVENTS.RECORDING_ADDED, this._onNewRecording);
+ PerformanceController.off(
+ EVENTS.RECORDING_DELETED,
+ this._onRecordingDeleted
+ );
+ PerformanceController.off(
+ EVENTS.RECORDING_EXPORTED,
+ this._onRecordingExported
+ );
+ },
+
+ /**
+ * Called when a new recording is stored in the UI. This handles
+ * when recordings are lazily loaded (like a console.profile occurring
+ * before the tool is loaded) or imported. In normal manual recording cases,
+ * this will also be fired.
+ */
+ _onNewRecording: function(recording) {
+ this._onRecordingStateChange(null, recording);
+ },
+
+ /**
+ * Signals that a recording has changed state.
+ *
+ * @param string state
+ * Can be "recording-started", "recording-stopped", "recording-stopping"
+ * @param RecordingModel recording
+ * Model of the recording that was started.
+ */
+ _onRecordingStateChange: function(state, recording) {
+ const { recordings, labels } = this._listState;
+
+ if (!recordings.includes(recording)) {
+ recordings.push(recording);
+ labels.set(
+ recording,
+ recording.getLabel() ||
+ L10N.getFormatStr("recordingsList.itemLabel", recordings.length)
+ );
+
+ // If this is a manual recording, immediately select it, or
+ // select a console profile if its the only one
+ if (!recording.isConsole() || !this._listState.selected) {
+ this._onSelect(recording);
+ }
+ }
+
+ // Determine if the recording needs to be selected.
+ const isCompletedManualRecording =
+ !recording.isConsole() && recording.isCompleted();
+ if (recording.isImported() || isCompletedManualRecording) {
+ this._onSelect(recording);
+ }
+
+ this._renderList();
+ },
+
+ /**
+ * Clears out all non-console recordings.
+ */
+ _onRecordingDeleted: function(recording) {
+ const { recordings } = this._listState;
+ const index = recordings.indexOf(recording);
+ if (index === -1) {
+ throw new Error("Attempting to remove a recording that doesn't exist.");
+ }
+ recordings.splice(index, 1);
+ this._renderList();
+ },
+
+ /**
+ * The select listener for this container.
+ */
+ async _onSelect(recording) {
+ this._listState.selected = recording;
+ this.emit(EVENTS.UI_RECORDING_SELECTED, recording);
+ this._renderList();
+ },
+
+ /**
+ * The click listener for the "save" button of each item in this container.
+ */
+ _onSaveButtonClick: function(recording) {
+ const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(
+ window,
+ L10N.getStr("recordingsList.saveDialogTitle"),
+ Ci.nsIFilePicker.modeSave
+ );
+ fp.appendFilter(
+ L10N.getStr("recordingsList.saveDialogJSONFilter"),
+ "*.json"
+ );
+ fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*");
+ fp.defaultString = "profile.json";
+
+ fp.open({
+ done: result => {
+ if (result == Ci.nsIFilePicker.returnCancel) {
+ return;
+ }
+ this.emit(EVENTS.UI_EXPORT_RECORDING, recording, fp.file);
+ },
+ });
+ },
+
+ _onRecordingExported: function(recording, file) {
+ if (recording.isConsole()) {
+ return;
+ }
+ const name = file.leafName.replace(/\..+$/, "");
+ this._listState.labels.set(recording, name);
+ this._renderList();
+ },
+};
+
+/**
+ * Convenient way of emitting events from the RecordingsView.
+ */
+EventEmitter.decorate(RecordingsView);
+
+exports.RecordingsView = window.RecordingsView = RecordingsView;
diff --git a/devtools/client/performance/views/toolbar.js b/devtools/client/performance/views/toolbar.js
new file mode 100644
index 0000000000..cd156e4c4d
--- /dev/null
+++ b/devtools/client/performance/views/toolbar.js
@@ -0,0 +1,188 @@
+/* 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 $, $$, PerformanceController */
+"use strict";
+
+const EVENTS = require("devtools/client/performance/events");
+const {
+ TIMELINE_BLUEPRINT,
+} = require("devtools/client/performance/modules/markers");
+const {
+ MarkerBlueprintUtils,
+} = require("devtools/client/performance/modules/marker-blueprint-utils");
+
+const { OptionsView } = require("devtools/client/shared/options-view");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+const BRANCH_NAME = "devtools.performance.ui.";
+
+/**
+ * View handler for toolbar events (mostly option toggling and triggering)
+ */
+const ToolbarView = {
+ /**
+ * Sets up the view with event binding.
+ */
+ async initialize() {
+ this._onFilterPopupShowing = this._onFilterPopupShowing.bind(this);
+ this._onFilterPopupHiding = this._onFilterPopupHiding.bind(this);
+ this._onHiddenMarkersChanged = this._onHiddenMarkersChanged.bind(this);
+ this._onPrefChanged = this._onPrefChanged.bind(this);
+ this._popup = $("#performance-options-menupopup");
+
+ this.optionsView = new OptionsView({
+ branchName: BRANCH_NAME,
+ menupopup: this._popup,
+ });
+
+ // Set the visibility of experimental UI options on load
+ // based off of `devtools.performance.ui.experimental` preference
+ const experimentalEnabled = PerformanceController.getOption("experimental");
+ this._toggleExperimentalUI(experimentalEnabled);
+
+ await this.optionsView.initialize();
+ this.optionsView.on("pref-changed", this._onPrefChanged);
+
+ this._buildMarkersFilterPopup();
+ this._updateHiddenMarkersPopup();
+ $("#performance-filter-menupopup").addEventListener(
+ "popupshowing",
+ this._onFilterPopupShowing
+ );
+ $("#performance-filter-menupopup").addEventListener(
+ "popuphiding",
+ this._onFilterPopupHiding
+ );
+ },
+
+ /**
+ * Unbinds events and cleans up view.
+ */
+ destroy: function() {
+ $("#performance-filter-menupopup").removeEventListener(
+ "popupshowing",
+ this._onFilterPopupShowing
+ );
+ $("#performance-filter-menupopup").removeEventListener(
+ "popuphiding",
+ this._onFilterPopupHiding
+ );
+ this._popup = null;
+
+ this.optionsView.off("pref-changed", this._onPrefChanged);
+ this.optionsView.destroy();
+ },
+
+ /**
+ * Creates the timeline markers filter popup.
+ */
+ _buildMarkersFilterPopup: function() {
+ for (const [markerName, markerDetails] of Object.entries(
+ TIMELINE_BLUEPRINT
+ )) {
+ const menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("closemenu", "none");
+ menuitem.setAttribute("type", "checkbox");
+ menuitem.setAttribute("align", "center");
+ menuitem.setAttribute("flex", "1");
+ menuitem.setAttribute(
+ "label",
+ MarkerBlueprintUtils.getMarkerGenericName(markerName)
+ );
+ menuitem.setAttribute("marker-type", markerName);
+ menuitem.className = `marker-color-${markerDetails.colorName}`;
+
+ menuitem.addEventListener("command", this._onHiddenMarkersChanged);
+
+ $("#performance-filter-menupopup").appendChild(menuitem);
+ }
+ },
+
+ /**
+ * Updates the menu items checked state in the timeline markers filter popup.
+ */
+ _updateHiddenMarkersPopup: function() {
+ const menuItems = $$("#performance-filter-menupopup menuitem[marker-type]");
+ const hiddenMarkers = PerformanceController.getPref("hidden-markers");
+
+ for (const menuitem of menuItems) {
+ if (~hiddenMarkers.indexOf(menuitem.getAttribute("marker-type"))) {
+ menuitem.removeAttribute("checked");
+ } else {
+ menuitem.setAttribute("checked", "true");
+ }
+ }
+ },
+
+ /**
+ * Fired when `devtools.performance.ui.experimental` is changed, or
+ * during init. Toggles the visibility of experimental performance tool options
+ * in the UI options.
+ *
+ * Sets or removes "experimental-enabled" on the menu and main elements,
+ * hiding or showing all elements with class "experimental-option".
+ *
+ * TODO re-enable "#option-enable-memory" permanently once stable in bug 1163350
+ * TODO re-enable "#option-show-jit-optimizations" permanently once stable in
+ * bug 1163351
+ *
+ * @param {boolean} isEnabled
+ */
+ _toggleExperimentalUI: function(isEnabled) {
+ if (isEnabled) {
+ $(".theme-body").classList.add("experimental-enabled");
+ this._popup.classList.add("experimental-enabled");
+ } else {
+ $(".theme-body").classList.remove("experimental-enabled");
+ this._popup.classList.remove("experimental-enabled");
+ }
+ },
+
+ /**
+ * Fired when the markers filter popup starts to show.
+ */
+ _onFilterPopupShowing: function() {
+ $("#filter-button").setAttribute("open", "true");
+ },
+
+ /**
+ * Fired when the markers filter popup starts to hide.
+ */
+ _onFilterPopupHiding: function() {
+ $("#filter-button").removeAttribute("open");
+ },
+
+ /**
+ * Fired when a menu item in the markers filter popup is checked or unchecked.
+ */
+ _onHiddenMarkersChanged: function() {
+ const checkedMenuItems = $$(
+ "#performance-filter-menupopup menuitem[marker-type]:not([checked])"
+ );
+ const hiddenMarkers = Array.from(checkedMenuItems, e =>
+ e.getAttribute("marker-type")
+ );
+ PerformanceController.setPref("hidden-markers", hiddenMarkers);
+ },
+
+ /**
+ * Fired when a preference changes in the underlying OptionsView.
+ * Propogated by the PerformanceController.
+ */
+ _onPrefChanged: function(prefName) {
+ const value = PerformanceController.getOption(prefName);
+
+ if (prefName === "experimental") {
+ this._toggleExperimentalUI(value);
+ }
+
+ this.emit(EVENTS.UI_PREF_CHANGED, prefName, value);
+ },
+
+ toString: () => "[object ToolbarView]",
+};
+
+EventEmitter.decorate(ToolbarView);
+
+exports.ToolbarView = window.ToolbarView = ToolbarView;