Bug 879008 - New UI for the sampling Profiler, r=rcampbell,fitzgen,pbrosset
authorVictor Porof <vporof@mozilla.com>
Wed, 06 Aug 2014 11:25:18 -0400
changeset 198161 f20b1f5b288dd77eb6785e3904966c7efeb7fc38
parent 198160 d6ea14edc3d4b0ae33b390aec62b1fa2a0a205dc
child 198162 d1adfecad34e1eb80c0624aaadc0928dedad27a4
push id47317
push userryanvm@gmail.com
push dateWed, 06 Aug 2014 20:50:27 +0000
treeherdermozilla-inbound@24a71651c6a3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrcampbell, fitzgen, pbrosset
bugs879008
milestone34.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 879008 - New UI for the sampling Profiler, r=rcampbell,fitzgen,pbrosset
browser/devtools/framework/gDevTools.jsm
browser/devtools/jar.mn
browser/devtools/main.js
browser/devtools/profiler/moz.build
browser/devtools/profiler/panel.js
browser/devtools/profiler/profiler.js
browser/devtools/profiler/profiler.xul
browser/devtools/profiler/ui-profile.js
browser/devtools/profiler/ui-recordings.js
browser/devtools/profiler/utils/global.js
browser/devtools/profiler/utils/shared.js
browser/devtools/profiler/utils/tree-model.js
browser/devtools/profiler/utils/tree-view.js
browser/devtools/shared/moz.build
browser/devtools/shared/widgets/AbstractTreeItem.jsm
browser/devtools/shared/widgets/ViewHelpers.jsm
browser/locales/en-US/chrome/browser/devtools/profiler.dtd
browser/locales/en-US/chrome/browser/devtools/profiler.properties
browser/themes/linux/devtools/profiler.css
browser/themes/linux/jar.mn
browser/themes/osx/devtools/profiler.css
browser/themes/osx/jar.mn
browser/themes/shared/devtools/images/newtab-inverted.png
browser/themes/shared/devtools/images/newtab-inverted@2x.png
browser/themes/shared/devtools/images/newtab.png
browser/themes/shared/devtools/images/newtab@2x.png
browser/themes/shared/devtools/profiler.inc.css
browser/themes/shared/devtools/toolbars.inc.css
browser/themes/windows/jar.mn
js/public/ProfilingStack.h
toolkit/devtools/Loader.jsm
toolkit/devtools/server/actors/framerate.js
toolkit/devtools/server/actors/profiler.js
toolkit/devtools/server/main.js
--- a/browser/devtools/framework/gDevTools.jsm
+++ b/browser/devtools/framework/gDevTools.jsm
@@ -889,19 +889,23 @@ let gDevToolsBrowser = {
         broadcaster.setAttribute("checked", "true");
       } else {
         broadcaster.removeAttribute("checked");
       }
     }
   },
 
   /**
-   * Connects to the SPS profiler when the developer tools are open.
+   * Connects to the SPS profiler when the developer tools are open. This is
+   * necessary because of the WebConsole's `profile` and `profileEnd` methods.
    */
-  _connectToProfiler: function DT_connectToProfiler() {
+  _connectToProfiler: function DT_connectToProfiler(event, toolbox) {
+    let SharedProfilerUtils = devtools.require("devtools/profiler/shared");
+    let connection = SharedProfilerUtils.getProfilerConnection(toolbox);
+    connection.open();
   },
 
   /**
    * Remove the menuitem for a tool to all open browser windows.
    *
    * @param {string} toolId
    *        id of the tool to remove
    */
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -73,16 +73,19 @@ browser.jar:
     content/browser/devtools/canvasdebugger.xul                        (canvasdebugger/canvasdebugger.xul)
     content/browser/devtools/canvasdebugger.js                         (canvasdebugger/canvasdebugger.js)
     content/browser/devtools/webaudioeditor.xul                        (webaudioeditor/webaudioeditor.xul)
     content/browser/devtools/d3.js                                     (shared/d3.js)
     content/browser/devtools/dagre-d3.js                               (webaudioeditor/lib/dagre-d3.js)
     content/browser/devtools/webaudioeditor-controller.js              (webaudioeditor/webaudioeditor-controller.js)
     content/browser/devtools/webaudioeditor-view.js                    (webaudioeditor/webaudioeditor-view.js)
     content/browser/devtools/profiler.xul                              (profiler/profiler.xul)
+    content/browser/devtools/profiler.js                               (profiler/profiler.js)
+    content/browser/devtools/ui-recordings.js                          (profiler/ui-recordings.js)
+    content/browser/devtools/ui-profile.js                             (profiler/ui-profile.js)
     content/browser/devtools/responsivedesign/resize-commands.js       (responsivedesign/resize-commands.js)
     content/browser/devtools/commandline.css                           (commandline/commandline.css)
     content/browser/devtools/commandlineoutput.xhtml                   (commandline/commandlineoutput.xhtml)
     content/browser/devtools/commandlinetooltip.xhtml                  (commandline/commandlinetooltip.xhtml)
     content/browser/devtools/commandline/commands-index.js             (commandline/commands-index.js)
     content/browser/devtools/framework/toolbox-window.xul              (framework/toolbox-window.xul)
     content/browser/devtools/framework/toolbox-options.xul             (framework/toolbox-options.xul)
     content/browser/devtools/framework/toolbox-options.js              (framework/toolbox-options.js)
--- a/browser/devtools/main.js
+++ b/browser/devtools/main.js
@@ -25,17 +25,17 @@ let events = require("sdk/system/events"
 loader.lazyGetter(this, "OptionsPanel", () => require("devtools/framework/toolbox-options").OptionsPanel);
 loader.lazyGetter(this, "InspectorPanel", () => require("devtools/inspector/inspector-panel").InspectorPanel);
 loader.lazyGetter(this, "WebConsolePanel", () => require("devtools/webconsole/panel").WebConsolePanel);
 loader.lazyGetter(this, "DebuggerPanel", () => require("devtools/debugger/panel").DebuggerPanel);
 loader.lazyGetter(this, "StyleEditorPanel", () => require("devtools/styleeditor/styleeditor-panel").StyleEditorPanel);
 loader.lazyGetter(this, "ShaderEditorPanel", () => require("devtools/shadereditor/panel").ShaderEditorPanel);
 loader.lazyGetter(this, "CanvasDebuggerPanel", () => require("devtools/canvasdebugger/panel").CanvasDebuggerPanel);
 loader.lazyGetter(this, "WebAudioEditorPanel", () => require("devtools/webaudioeditor/panel").WebAudioEditorPanel);
-loader.lazyGetter(this, "ProfilerPanel", () => require("devtools/profiler/panel"));
+loader.lazyGetter(this, "ProfilerPanel", () => require("devtools/profiler/panel").ProfilerPanel);
 loader.lazyGetter(this, "NetMonitorPanel", () => require("devtools/netmonitor/panel").NetMonitorPanel);
 loader.lazyGetter(this, "ScratchpadPanel", () => require("devtools/scratchpad/scratchpad-panel").ScratchpadPanel);
 
 // Strings
 const toolboxProps = "chrome://browser/locale/devtools/toolbox.properties";
 const inspectorProps = "chrome://browser/locale/devtools/inspector.properties";
 const debuggerProps = "chrome://browser/locale/devtools/debugger.properties";
 const styleEditorProps = "chrome://browser/locale/devtools/styleeditor.properties";
@@ -266,21 +266,20 @@ Tools.jsprofiler = {
   accesskey: l10n("profiler.accesskey", profilerStrings),
   key: l10n("profiler.commandkey2", profilerStrings),
   ordinal: 7,
   modifiers: "shift",
   visibilityswitch: "devtools.profiler.enabled",
   icon: "chrome://browser/skin/devtools/tool-profiler.svg",
   invertIconForLightTheme: true,
   url: "chrome://browser/content/devtools/profiler.xul",
-  label: l10n("profiler.label", profilerStrings),
-  panelLabel: l10n("profiler.panelLabel", profilerStrings),
+  label: l10n("profiler.label2", profilerStrings),
+  panelLabel: l10n("profiler.panelLabel2", profilerStrings),
   tooltip: l10n("profiler.tooltip2", profilerStrings),
   inMenu: true,
-  commands: "devtools/profiler/commands",
 
   isTargetSupported: function (target) {
     return !target.isAddon;
   },
 
   build: function (frame, target) {
     let panel = new ProfilerPanel(frame, target);
     return panel.open();
--- a/browser/devtools/profiler/moz.build
+++ b/browser/devtools/profiler/moz.build
@@ -1,4 +1,14 @@
 # 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/.
+
+EXTRA_JS_MODULES.devtools.profiler += [
+    'panel.js',
+    'utils/global.js',
+    'utils/shared.js',
+    'utils/tree-model.js',
+    'utils/tree-view.js'
+]
+
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
--- a/browser/devtools/profiler/panel.js
+++ b/browser/devtools/profiler/panel.js
@@ -1,6 +1,65 @@
 /* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* 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";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+loader.lazyRequireGetter(this, "promise");
+loader.lazyRequireGetter(this, "EventEmitter",
+  "devtools/toolkit/event-emitter");
+
+loader.lazyRequireGetter(this, "getProfilerConnection",
+  "devtools/profiler/shared", true);
+loader.lazyRequireGetter(this, "ProfilerFront",
+  "devtools/profiler/shared", true);
+
+function ProfilerPanel(iframeWindow, toolbox) {
+  this.panelWin = iframeWindow;
+  this._toolbox = toolbox;
+
+  EventEmitter.decorate(this);
+}
+
+exports.ProfilerPanel = ProfilerPanel;
+
+ProfilerPanel.prototype = {
+  /**
+   * Open is effectively an asynchronous constructor.
+   *
+   * @return object
+   *         A promise that is resolved when the Profiler completes opening.
+   */
+  open: Task.async(function*() {
+    let connection = getProfilerConnection(this._toolbox);
+    yield connection.open();
+
+    this.panelWin.gToolbox = this._toolbox;
+    this.panelWin.gTarget = this.target;
+    this.panelWin.gFront = new ProfilerFront(connection);
+    yield this.panelWin.startupProfiler();
+
+    this.isReady = true;
+    this.emit("ready");
+    return this;
+  }),
+
+  // DevToolPanel API
+
+  get target() this._toolbox.target,
+
+  destroy: Task.async(function*() {
+    // Make sure this panel is not already destroyed.
+    if (this._destroyed) {
+      return;
+    }
+
+    yield this.panelWin.shutdownProfiler();
+    this.emit("destroyed");
+    this._destroyed = true;
+  })
+};
new file mode 100644
--- /dev/null
+++ b/browser/devtools/profiler/profiler.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/. */
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/devtools/Loader.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+devtools.lazyRequireGetter(this, "Services");
+devtools.lazyRequireGetter(this, "promise");
+devtools.lazyRequireGetter(this, "EventEmitter",
+  "devtools/toolkit/event-emitter");
+devtools.lazyRequireGetter(this, "DevToolsUtils",
+  "devtools/toolkit/DevToolsUtils");
+devtools.lazyRequireGetter(this, "FramerateFront",
+  "devtools/server/actors/framerate", true);
+
+devtools.lazyRequireGetter(this, "L10N",
+  "devtools/profiler/global", true);
+devtools.lazyRequireGetter(this, "CATEGORIES",
+  "devtools/profiler/global", true);
+devtools.lazyRequireGetter(this, "CATEGORY_MAPPINGS",
+  "devtools/profiler/global", true);
+devtools.lazyRequireGetter(this, "ThreadNode",
+  "devtools/profiler/tree-model", true);
+devtools.lazyRequireGetter(this, "CallView",
+  "devtools/profiler/tree-view", true);
+
+devtools.lazyImporter(this, "FileUtils",
+  "resource://gre/modules/FileUtils.jsm");
+devtools.lazyImporter(this, "NetUtil",
+  "resource://gre/modules/NetUtil.jsm");
+devtools.lazyImporter(this, "LineGraphWidget",
+  "resource:///modules/devtools/Graphs.jsm");
+devtools.lazyImporter(this, "BarGraphWidget",
+  "resource:///modules/devtools/Graphs.jsm");
+devtools.lazyImporter(this, "CanvasGraphUtils",
+  "resource:///modules/devtools/Graphs.jsm");
+devtools.lazyImporter(this, "SideMenuWidget",
+  "resource:///modules/devtools/SideMenuWidget.jsm");
+
+const RECORDING_DATA_DISPLAY_DELAY = 10; // ms
+const FRAMERATE_CALC_INTERVAL = 16; // ms
+const FRAMERATE_GRAPH_HEIGHT = 60; // px
+const CATEGORIES_GRAPH_HEIGHT = 60; // px
+const CATEGORIES_GRAPH_MIN_BARS_WIDTH = 3; // px
+const CALL_VIEW_FOCUS_EVENTS_DRAIN = 10; // ms
+const GRAPH_SCROLL_EVENTS_DRAIN = 50; // ms
+const GRAPH_ZOOM_MIN_TIMESPAN = 20; // ms
+
+// This identifier string is used to tentatively ascertain whether or not
+// a JSON loaded from disk is actually something generated by this tool.
+// It isn't, of course, a definitive verification, but a Good Enough™
+// approximation before continuing the import. Don't localize this.
+const PROFILE_SERIALIZER_IDENTIFIER = "Recorded Performance Data";
+const PROFILE_SERIALIZER_VERSION = 1;
+
+// The panel's window global is an EventEmitter firing the following events:
+const EVENTS = {
+  // When a recording is started or stopped, via the `stopwatch` button, or
+  // when `console.profile` and `console.profileEnd` is invoked.
+  RECORDING_STARTED: "Profiler:RecordingStarted",
+  RECORDING_ENDED: "Profiler:RecordingEnded",
+
+  // When a recording is abruptly ended, either because the built-in profiler
+  // module is stopped by a third party, or because the recordings list is
+  // cleared while there's one in progress.
+  RECORDING_LOST: "Profiler:RecordingCancelled",
+
+  // When a recording is displayed in the ProfileView.
+  RECORDING_DISPLAYED: "Profiler:RecordingDisplayed",
+
+  // When a new tab is spawned in the ProfileView from a graphs selection.
+  TAB_SPAWNED_FROM_SELECTION: "Profiler:TabSpawnedFromSelection",
+
+  // When a new tab is spawned in the ProfileView from a node in the tree.
+  TAB_SPAWNED_FROM_FRAME_NODE: "Profiler:TabSpawnedFromFrameNode",
+
+  // When different panels in the ProfileView are shown.
+  EMPTY_NOTICE_SHOWN: "Profiler:EmptyNoticeShown",
+  RECORDING_NOTICE_SHOWN: "Profiler:RecordingNoticeShown",
+  LOADING_NOTICE_SHOWN: "Profiler:LoadingNoticeShown",
+  TABBED_BROWSER_SHOWN: "Profiler:TabbedBrowserShown",
+
+  // When a source is shown in the JavaScript Debugger at a specific location.
+  SOURCE_SHOWN_IN_JS_DEBUGGER: "Profiler:SourceShownInJsDebugger",
+  SOURCE_NOT_FOUND_IN_JS_DEBUGGER: "Profiler:SourceNotFoundInJsDebugger"
+};
+
+/**
+ * The current target and the profiler connection, set by this tool's host.
+ */
+let gToolbox, gTarget, gFront;
+
+/**
+ * Initializes the profiler controller and views.
+ */
+let startupProfiler = Task.async(function*() {
+  yield promise.all([
+    PrefObserver.register(),
+    EventsHandler.initialize(),
+    RecordingsListView.initialize(),
+    ProfileView.initialize()
+  ]);
+
+  // Profiles may have been created before this tool was opened, e.g. via
+  // `console.profile` and `console.profileEnd(). Populate the UI with them.
+  for (let recordingData of gFront.finishedConsoleRecordings) {
+    let profileLabel = recordingData.profilerData.profileLabel;
+    let recordingItem = RecordingsListView.addEmptyRecording(profileLabel);
+    RecordingsListView.customizeRecording(recordingItem, recordingData);
+  }
+  for (let { profileLabel } of gFront.pendingConsoleRecordings) {
+    RecordingsListView.handleRecordingStarted(profileLabel);
+  }
+
+  // Select the first recording, if available.
+  RecordingsListView.selectedIndex = 0;
+});
+
+/**
+ * Destroys the profiler controller and views.
+ */
+let shutdownProfiler = Task.async(function*() {
+  yield promise.all([
+    PrefObserver.unregister(),
+    EventsHandler.destroy(),
+    RecordingsListView.destroy(),
+    ProfileView.destroy()
+  ]);
+});
+
+/**
+ * Observes pref changes on the devtools.profiler branch and triggers the
+ * required frontend modifications.
+ */
+let PrefObserver = {
+  register: function() {
+    this.branch = Services.prefs.getBranch("devtools.profiler.");
+    this.branch.addObserver("", this, false);
+  },
+  unregister: function() {
+    this.branch.removeObserver("", this);
+  },
+  observe: function(subject, topic, pref) {
+    Prefs.refresh();
+
+    if (pref == "ui.show-platform-data") {
+      RecordingsListView.forceSelect(RecordingsListView.selectedItem);
+    }
+  }
+};
+
+/**
+ * Functions handling target-related lifetime events.
+ */
+let EventsHandler = {
+  /**
+   * Listen for events emitted by the current tab target.
+   */
+  initialize: function() {
+    this._onConsoleProfileStart = this._onConsoleProfileStart.bind(this);
+    this._onConsoleProfileEnd = this._onConsoleProfileEnd.bind(this);
+
+    gFront.on("profile", this._onConsoleProfileStart);
+    gFront.on("profileEnd", this._onConsoleProfileEnd);
+    gFront.on("profiler-unexpectedly-stopped", this._onProfilerDeactivated);
+  },
+
+  /**
+   * Remove events emitted by the current tab target.
+   */
+  destroy: function() {
+    gFront.off("profile", this._onConsoleProfileStart);
+    gFront.off("profileEnd", this._onConsoleProfileEnd);
+    gFront.off("profiler-unexpectedly-stopped", this._onProfilerDeactivated);
+  },
+
+  /**
+   * Invoked whenever `console.profile` is called.
+   *
+   * @param string profileLabel
+   *        The provided string argument if available, undefined otherwise.
+   */
+  _onConsoleProfileStart: function(event, profileLabel) {
+    RecordingsListView.handleRecordingStarted(profileLabel);
+  },
+
+  /**
+   * Invoked whenever `console.profileEnd` is called.
+   *
+   * @param object recordingData
+   *        The profiler and refresh driver ticks data received from the front.
+   */
+  _onConsoleProfileEnd: function(event, recordingData) {
+    RecordingsListView.handleRecordingEnded(recordingData);
+  },
+
+  /**
+   * Invoked whenever the built-in profiler module is deactivated.
+   * @see ProfilerConnection.prototype._onProfilerUnexpectedlyStopped
+   */
+  _onProfilerDeactivated: function() {
+    RecordingsListView.removeForPredicate(e => e.isRecording);
+    RecordingsListView.handleRecordingCancelled();
+  }
+};
+
+/**
+ * Shortcuts for accessing various profiler preferences.
+ */
+const Prefs = new ViewHelpers.Prefs("devtools.profiler", {
+  showPlatformData: ["Bool", "ui.show-platform-data"]
+});
+
+/**
+ * Convenient way of emitting events from the panel window.
+ */
+EventEmitter.decorate(this);
+
+/**
+ * DOM query helpers.
+ */
+function $(selector, target = document) {
+  return target.querySelector(selector);
+}
+function $$(selector, target = document) {
+  return target.querySelectorAll(selector);
+}
--- a/browser/devtools/profiler/profiler.xul
+++ b/browser/devtools/profiler/profiler.xul
@@ -1,4 +1,129 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- 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/. -->
+<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/common.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/widgets.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/devtools/profiler.css" type="text/css"?>
+<!DOCTYPE window [
+  <!ENTITY % profilerDTD SYSTEM "chrome://browser/locale/devtools/profiler.dtd">
+  %profilerDTD;
+]>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+  <script src="chrome://browser/content/devtools/theme-switching.js"/>
+  <script type="application/javascript" src="profiler.js"/>
+  <script type="application/javascript" src="ui-recordings.js"/>
+  <script type="application/javascript" src="ui-profile.js"/>
+
+  <hbox class="theme-body" flex="1">
+    <vbox id="recordings-pane">
+      <toolbar id="recordings-toolbar"
+               class="devtools-toolbar">
+        <hbox id="recordings-controls"
+              class="devtools-toolbarbutton-group">
+          <toolbarbutton id="record-button"
+                         class="devtools-toolbarbutton"
+                         oncommand="RecordingsListView._onRecordButtonClick()"
+                         tooltiptext="&profilerUI.recordButton.tooltip;"/>
+          <toolbarbutton id="import-button"
+                         class="devtools-toolbarbutton"
+                         oncommand="RecordingsListView._onImportButtonClick()"
+                         label="&profilerUI.importButton;"/>
+          <toolbarbutton id="clear-button"
+                         class="devtools-toolbarbutton"
+                         oncommand="RecordingsListView._onClearButtonClick()"
+                         label="&profilerUI.clearButton;"/>
+        </hbox>
+      </toolbar>
+      <vbox id="recordings-list" flex="1"/>
+    </vbox>
+
+    <deck id="profile-pane"
+          class="devtools-responsive-container"
+          flex="1">
+      <hbox id="empty-notice"
+            class="notice-container"
+            align="center"
+            pack="center"
+            flex="1">
+        <label value="&profilerUI.emptyNotice1;"/>
+        <button id="profiling-notice-button"
+                class="devtools-toolbarbutton"
+                standalone="true"
+                oncommand="RecordingsListView._onRecordButtonClick()"/>
+        <label value="&profilerUI.emptyNotice2;"/>
+      </hbox>
+
+      <hbox id="recording-notice"
+            class="notice-container"
+            align="center"
+            pack="center"
+            flex="1">
+        <label value="&profilerUI.stopNotice1;"/>
+        <button id="profiling-notice-button"
+                class="devtools-toolbarbutton"
+                standalone="true"
+                checked="true"
+                oncommand="RecordingsListView._onRecordButtonClick()"/>
+        <label value="&profilerUI.stopNotice2;"/>
+      </hbox>
+
+      <hbox id="loading-notice"
+            class="notice-container"
+            align="center"
+            pack="center"
+            flex="1">
+        <label value="&profilerUI.loadingNotice;"/>
+      </hbox>
+
+      <tabbox id="profile-content"
+              class="theme-sidebar devtools-sidebar-tabs"
+              flex="1">
+        <hbox>
+          <tabs/>
+          <button id="profile-newtab-button"
+                  tooltiptext="&profilerUI.newtab.tooltiptext;"/>
+        </hbox>
+        <tabpanels flex="1"/>
+      </tabbox>
+    </deck>
+  </hbox>
+
+  <template>
+    <!-- Template for a tab inside the #profile-content tabbox. -->
+    <tab id="profile-content-tab-template" covered="true">
+      <label class="tab-title-label"/>
+    </tab>
+
+    <!-- Template for a panel inside the #profile-content tabbox. -->
+    <tabpanel id="profile-content-tabpanel-template">
+      <vbox class="framerate"/>
+      <vbox class="categories"/>
+      <vbox class="call-tree" flex="1">
+        <hbox class="call-tree-headers-container">
+          <label class="plain call-tree-header"
+                 type="duration"
+                 crop="end"
+                 value="&profilerUI.table.duration;"/>
+          <label class="plain call-tree-header"
+                 type="percentage"
+                 crop="end"
+                 value="&profilerUI.table.percentage;"/>
+          <label class="plain call-tree-header"
+                 type="invocations"
+                 crop="end"
+                 value="&profilerUI.table.invocations;"/>
+          <label class="plain call-tree-header"
+                 type="function"
+                 crop="end"
+                 value="&profilerUI.table.function;"/>
+        </hbox>
+        <vbox class="call-tree-cells-container" flex="1"/>
+      </vbox>
+    </tabpanel>
+  </template>
+
+</window>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/profiler/ui-profile.js
@@ -0,0 +1,792 @@
+/* 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";
+
+/**
+ * Functions handling the profile inspection UI, showing the framerate and
+ * cateogry graphs, along with a call tree view.
+ *
+ * A profile view is a tabbed browser, so recording data will be displayed in
+ * tabs. Certain messages like 'Loading' or 'Recording...' may also be shown.
+ */
+let ProfileView = {
+  /**
+   * Initialization function, called when the tool is started.
+   */
+  initialize: function() {
+    this._tabs = $("#profile-content tabs");
+    this._panels = $("#profile-content tabpanels");
+    this._tabTemplate = $("#profile-content-tab-template");
+    this._panelTemplate = $("#profile-content-tabpanel-template");
+    this._newtabButton = $("#profile-newtab-button");
+
+    this._recordingInfoByPanel = new WeakMap();
+    this._framerateGraphByPanel = new Map();
+    this._categoriesGraphByPanel = new Map();
+    this._callTreeRootByPanel = new Map();
+
+    this._onTabSelect = this._onTabSelect.bind(this);
+    this._onNewTabClick = this._onNewTabClick.bind(this);
+    this._onGraphLegendSelection = this._onGraphLegendSelection.bind(this);
+    this._onGraphMouseUp = this._onGraphMouseUp.bind(this);
+    this._onGraphScroll = this._onGraphScroll.bind(this);
+    this._onCallViewFocus = this._onCallViewFocus.bind(this);
+    this._onCallViewLink = this._onCallViewLink.bind(this);
+    this._onCallViewZoom = this._onCallViewZoom.bind(this);
+
+    this._panels.addEventListener("select", this._onTabSelect, false);
+    this._newtabButton.addEventListener("click", this._onNewTabClick, false);
+  },
+
+  /**
+   * Destruction function, called when the tool is closed.
+   */
+  destroy: function() {
+    this.removeAllTabs();
+
+    this._panels.removeEventListener("select", this._onTabSelect, false);
+    this._newtabButton.removeEventListener("click", this._onNewTabClick, false);
+  },
+
+  /**
+   * Shows a message detailing that there are is no data available.
+   * The tabbed browser will also be hidden.
+   */
+  showEmptyNotice: function() {
+    $("#profile-pane").selectedPanel = $("#empty-notice");
+    window.emit(EVENTS.EMPTY_NOTICE_SHOWN);
+  },
+
+  /**
+   * Shows a message detailing that a recording is currently in progress.
+   * The tabbed browser will also be hidden.
+   */
+  showRecordingNotice: function() {
+    $("#profile-pane").selectedPanel = $("#recording-notice");
+    window.emit(EVENTS.RECORDING_NOTICE_SHOWN);
+  },
+
+  /**
+   * Shows a message detailing that a finished recording is being loaded.
+   * The tabbed browser will also be hidden.
+   */
+  showLoadingNotice: function() {
+    $("#profile-pane").selectedPanel = $("#loading-notice");
+    window.emit(EVENTS.LOADING_NOTICE_SHOWN);
+  },
+
+  /**
+   * Shows the tabbed browser displaying recording data.
+   */
+  showTabbedBrowser: function() {
+    $("#profile-pane").selectedPanel = $("#profile-content");
+    window.emit(EVENTS.TABBED_BROWSER_SHOWN);
+  },
+
+  /**
+   * Selects the tab at the specified index in this tabbed browser.
+   *
+   * @param number tabIndex
+   *        The index of the tab to select. If no tab is available at the
+   *        specified index, all tabs will be deselected.
+   */
+  selectTab: function(tabIndex) {
+    $("#profile-content").selectedIndex = tabIndex;
+  },
+
+  /**
+   * Adds an empty tab in this tabbed browser.
+   *
+   * @return number
+   *         The newly created tab's index.
+   */
+  addTab: function() {
+    let tab = this._tabs.appendChild(this._tabTemplate.cloneNode(true));
+    let panel = this._panels.appendChild(this._panelTemplate.cloneNode(true));
+
+    // "Uncover" the tab via a CSS animation.
+    tab.removeAttribute("covered");
+
+    let tabIndex = this._tabs.itemCount - 1;
+    return tabIndex;
+  },
+
+  /**
+   * Sets the title of a tab in this tabbed browser.
+   *
+   * @param number tabIndex
+   *        The index of the tab to name.
+   * @param number beginAt, endAt
+   *        The 'start → stop' components of the tab title.
+   */
+  nameTab: function(tabIndex, beginAt, endAt) {
+    let tab = this._getTab(tabIndex);
+    let a = L10N.numberWithDecimals(beginAt, 2);
+    let b = L10N.numberWithDecimals(endAt, 2);
+    let labelNode = $(".tab-title-label", tab);
+    labelNode.setAttribute("value", L10N.getFormatStr("profile.tab", a, b));
+  },
+
+  /**
+   * Populates the panel for a tab in this tabbed browser with the provided
+   * recording data.
+   *
+   * @param number tabIndex
+   *        The index of the tab to populate.
+   * @param object recordingData
+   *        The profiler and refresh driver ticks data received from the front.
+   * @param number beginAt
+   *        The earliest time in the recording data to start at (in milliseconds).
+   * @param number endAt
+   *        The latest time in the recording data to end at (in milliseconds).
+   * @param object options
+   *        Additional options supported by this operation.
+   *        @see ProfileView._populatePanelWidgets
+   */
+  populateTab: Task.async(function*(tabIndex, recordingData, beginAt, endAt, options) {
+    let tab = this._getTab(tabIndex);
+    let panel = this._getPanel(tabIndex);
+    if (!tab || !panel) {
+      return;
+    }
+
+    this._recordingInfoByPanel.set(panel, {
+      recordingData: recordingData,
+      displayRange: { beginAt: beginAt, endAt: endAt }
+    });
+
+    let { profilerData, ticksData } = recordingData;
+    let categoriesData = RecordingUtils.plotCategoriesFor(profilerData, beginAt, endAt);
+    let framerateData = RecordingUtils.plotFramerateFor(ticksData, beginAt, endAt);
+    RecordingUtils.syncCategoriesWithFramerate(categoriesData, framerateData);
+
+    yield this._populatePanelWidgets(panel, {
+      profilerData: profilerData,
+      framerateData: framerateData,
+      categoriesData: categoriesData
+    }, beginAt, endAt, options);
+  }),
+
+  /**
+   * Adds a new tab in this tabbed browser, populates it with the provided
+   * recording data and automatically selects it.
+   *
+   * @param object recordingData
+   *        The profiler and refresh driver ticks data received from the front.
+   * @param number beginAt
+   *        The earliest time in the recording data to start at (in milliseconds).
+   * @param number endAt
+   *        The latest time in the recording data to end at (in milliseconds).
+   * @param object options
+   *        Additional options supported by this operation.
+   *        @see ProfileView._populatePanelWidgets
+   */
+  addTabAndPopulate: Task.async(function*(recordingData, beginAt, endAt, options) {
+    let tabIndex = this.addTab();
+    this.nameTab(tabIndex, beginAt, endAt);
+
+    // Wait for a few milliseconds before presenting the recording data,
+    // to allow the 'Loading' panel to finish being drawn (if there is one).
+    yield DevToolsUtils.waitForTime(RECORDING_DATA_DISPLAY_DELAY);
+    yield this.populateTab(tabIndex, recordingData, beginAt, endAt, options);
+    this.selectTab(tabIndex);
+  }),
+
+  /**
+   * Removes all tabs and corresponding views from this tabbed browser.
+   */
+  removeAllTabs: function() {
+    for (let [, graph] of this._framerateGraphByPanel) graph.destroy();
+    for (let [, graph] of this._categoriesGraphByPanel) graph.destroy();
+    for (let [, root] of this._callTreeRootByPanel) root.remove();
+
+    this._recordingInfoByPanel = new WeakMap();
+    this._framerateGraphByPanel.clear();
+    this._categoriesGraphByPanel.clear();
+    this._callTreeRootByPanel.clear();
+
+    while (this._tabs.hasChildNodes()) {
+      this._tabs.firstChild.remove();
+    }
+    while (this._panels.hasChildNodes()) {
+      this._panels.firstChild.remove();
+    }
+  },
+
+  /**
+   * Removes all tabs exclusively after the one at the specified index.
+   *
+   * @param number tabIndex
+   *        The "leftmost" tab to still keep. Remaining tabs will be removed.
+   */
+  removeTabsAfter: function(tabIndex) {
+    tabIndex++;
+
+    while (tabIndex < this._tabs.itemCount) {
+      let tab = this._getTab(tabIndex);
+      let panel = this._getPanel(tabIndex);
+
+      this._framerateGraphByPanel.delete(panel);
+      this._categoriesGraphByPanel.delete(panel);
+      this._callTreeRootByPanel.delete(panel);
+      tab.remove();
+      panel.remove();
+    }
+  },
+
+  /**
+   * Gets the total number of tabs displayed in this tabbed browser.
+   * @return number
+   */
+  get tabCount() {
+    let tabs = this._tabs.childNodes.length;
+    let tabpanels = this._panels.childNodes.length;
+    if (tabs != tabpanels) {
+      throw "The number of tabs isn't equal to the number of tabpanels.";
+    }
+    return tabs;
+  },
+
+  /**
+   * Adds a new tab in this tabbed browser, populates it based on the current
+   * selection range in the displayed data and automatically selects it.
+   */
+  _spawnTabFromSelection: Task.async(function*() {
+    let { recordingData } = this._getRecordingInfo();
+    let categoriesGraph = this._getCategoriesGraph();
+
+    // A selection is assumed to be available in the current tab.
+    let { min: beginAt, max: endAt } = categoriesGraph.getMappedSelection();
+
+    // Hide the "new tab" button since a selection won't implicitly be made
+    // in the newly created tab.
+    this._newtabButton.hidden = true;
+
+    yield this.addTabAndPopulate(recordingData, beginAt, endAt);
+
+    // Signal that a new tab was spawned from a graph's selection.
+    window.emit(EVENTS.TAB_SPAWNED_FROM_SELECTION);
+  }),
+
+  /**
+   * Adds a new tab in this tabbed browser, populates it based on the provided
+   * frame node and automatically selects it.
+   *
+   * @param FrameNode frameNode
+   *        Information about the function call node in the tree.
+   */
+  _spawnTabFromFrameNode: Task.async(function*(frameNode) {
+    let { recordingData } = this._getRecordingInfo();
+    let sampleTimes = frameNode.sampleTimes;
+    let beginAt = sampleTimes[0].start;
+    let endAt = sampleTimes[sampleTimes.length - 1].end;
+
+    // Hide the "new tab" button since a selection won't implicitly be made
+    // in the newly created tab.
+    this._newtabButton.hidden = true;
+
+    yield this.addTabAndPopulate(recordingData, beginAt, endAt, { skipCallTree: true });
+    this._populateCallTreeFromFrameNode(this._getPanel(), frameNode);
+
+    // Signal that a new tab was spawned from a node in the call tree.
+    window.emit(EVENTS.TAB_SPAWNED_FROM_FRAME_NODE);
+  }),
+
+  /**
+   * Filters the recording data displayed in the call tree view to match
+   * the current selection range in the graphs.
+   *
+   * @param object options
+   *        Additional options supported by this operation.
+   *        @see ProfileView._populatePanelWidgets
+   */
+  _zoomTreeFromSelection: function(options) {
+    let { recordingData, displayRange } = this._getRecordingInfo();
+    let categoriesGraph = this._getCategoriesGraph();
+    let selectedPanel = this._getPanel();
+
+    // If there's no selection, get the original display range and hide the
+    // "new tab" button.
+    if (!categoriesGraph.hasSelection()) {
+      let { beginAt, endAt } = displayRange;
+      this._newtabButton.hidden = true;
+      this._populateCallTree(selectedPanel, recordingData.profilerData, beginAt, endAt, options);
+    }
+    // Otherwise, just get the selected display range and only show the
+    // "new tab" button if the selection is wide enough.
+    else {
+      let { min: beginAt, max: endAt } = categoriesGraph.getMappedSelection();
+      this._newtabButton.hidden = (endAt - beginAt) < GRAPH_ZOOM_MIN_TIMESPAN;
+      this._populateCallTree(selectedPanel, recordingData.profilerData, beginAt, endAt, options);
+    }
+  },
+
+  /**
+   * Highlights certain areas in the categories graph to match the currently
+   * selected frame node's sample times in the tree view.
+   *
+   * @param ThreadNode | FrameNode frameNode
+   *        The root node data source for this tree.
+   */
+  _highlightAreaFromFrameNode: function(frameNode) {
+    let categoriesGraph = this._getCategoriesGraph();
+    if (categoriesGraph) {
+      categoriesGraph.setMask(frameNode.sampleTimes);
+    }
+  },
+
+  /**
+   * Populates all the widgets in the specified tab's panel with the provided
+   * data. The already existing widgets will be removed.
+   *
+   * @param nsIDOMNode panel
+   *        The <panel> element in this <tabbox>.
+   * @param object dataSource
+   *        The profiler, framerate and categories data source.
+   * @param number beginAt
+   *        The earliest allowed time for tree nodes (in milliseconds).
+   * @param number endAt
+   *        The latest allowed time for tree nodes (in milliseconds).
+   * @param object options
+   *        Additional options supported by this operation:
+   *          - skipCallTree: true if the call tree should not be populated
+   *          - skipCallTreeFocus: true if the root node shouldn't be focused
+   */
+  _populatePanelWidgets: Task.async(function*(panel, dataSource, beginAt, endAt, options = {}) {
+    let { profilerData, framerateData, categoriesData } = dataSource;
+
+    let framerateGraph = yield this._populateFramerateGraph(panel, framerateData, beginAt);
+    let categoriesGraph = yield this._populateCategoriesGraph(panel, categoriesData, beginAt);
+    CanvasGraphUtils.linkAnimation(framerateGraph, categoriesGraph);
+    CanvasGraphUtils.linkSelection(framerateGraph, categoriesGraph);
+
+    if (!options.skipCallTree) {
+      this._populateCallTree(panel, profilerData, beginAt, endAt, options);
+    }
+  }),
+
+  /**
+   * Populates the framerate graph in the specified tab's panel with the
+   * provided data. The already existing graph will be removed.
+   *
+   * @param nsIDOMNode panel
+   *        The <panel> element in this <tabbox>.
+   * @param object framerateData
+   *        The data source for this graph.
+   * @param number beginAt
+   *        The earliest time in the recording data to start at (in milliseconds).
+   */
+  _populateFramerateGraph: Task.async(function*(panel, framerateData, beginAt) {
+    let oldGraph = this._getFramerateGraph(panel);
+    if (oldGraph) {
+      oldGraph.destroy();
+    }
+    // Don't create a graph if there's not enough data to show.
+    if (!framerateData || framerateData.length < 2) {
+      return null;
+    }
+
+    let graph = new LineGraphWidget($(".framerate", panel), L10N.getStr("graphs.fps"));
+    graph.fixedHeight = FRAMERATE_GRAPH_HEIGHT;
+    graph.minDistanceBetweenPoints = 1;
+    graph.dataOffsetX = beginAt;
+
+    yield graph.setDataWhenReady(framerateData);
+
+    graph.on("mouseup", this._onGraphMouseUp);
+    graph.on("scroll", this._onGraphScroll);
+
+    this._framerateGraphByPanel.set(panel, graph);
+    return graph;
+  }),
+
+  /**
+   * Populates the categories graph in the specified tab's panel with the
+   * provided data. The already existing graph will be removed.
+   *
+   * @param nsIDOMNode panel
+   *        The <panel> element in this <tabbox>.
+   * @param object categoriesData
+   *        The data source for this graph.
+   * @param number beginAt
+   *        The earliest time in the recording data to start at (in milliseconds).
+   */
+  _populateCategoriesGraph: Task.async(function*(panel, categoriesData, beginAt) {
+    let oldGraph = this._getCategoriesGraph(panel);
+    if (oldGraph) {
+      oldGraph.destroy();
+    }
+    // Don't create a graph if there's not enough data to show.
+    if (!categoriesData || categoriesData.length < 2) {
+      return null;
+    }
+
+    let graph = new BarGraphWidget($(".categories", panel));
+    graph.fixedHeight = CATEGORIES_GRAPH_HEIGHT;
+    graph.minBarsWidth = CATEGORIES_GRAPH_MIN_BARS_WIDTH;
+    graph.format = CATEGORIES.sort((a, b) => a.ordinal > b.ordinal);
+    graph.dataOffsetX = beginAt;
+
+    yield graph.setDataWhenReady(categoriesData);
+
+    graph.on("legend-selection", this._onGraphLegendSelection);
+    graph.on("mouseup", this._onGraphMouseUp);
+    graph.on("scroll", this._onGraphScroll);
+
+    this._categoriesGraphByPanel.set(panel, graph);
+    return graph;
+  }),
+
+  /**
+   * Populates the call tree view in the specified tab's panel with the
+   * provided data. The already existing tree will be removed.
+   *
+   * @param nsIDOMNode panel
+   *        The <panel> element in this <tabbox>.
+   * @param object profilerData
+   *        The data source for this tree.
+   * @param number beginAt
+   *        The earliest time in the data source to start at (in milliseconds).
+   * @param number endAt
+   *        The latest time in the data source to end at (in milliseconds).
+   * @param object options
+   *        Additional options supported by this operation.
+   *        @see ProfileView._populatePanelWidgets
+   */
+  _populateCallTree: function(panel, profilerData, beginAt, endAt, options) {
+    let threadSamples = profilerData.profile.threads[0].samples;
+    let contentOnly = !Prefs.showPlatformData;
+    let threadNode = new ThreadNode(threadSamples, contentOnly, beginAt, endAt);
+    this._populateCallTreeFromFrameNode(panel, threadNode, options);
+  },
+
+  /**
+   * Populates the call tree view in the specified tab's panel with the
+   * provided frame node. The already existing tree will be removed.
+   *
+   * @param nsIDOMNode panel
+   *        The <panel> element in this <tabbox>.
+   * @param ThreadNode | FrameNode frameNode
+   *        The root node data source for this tree.
+   * @param object options
+   *        Additional options supported by this operation.
+   *        @see ProfileView._populatePanelWidgets
+   */
+  _populateCallTreeFromFrameNode: function(panel, frameNode, options = {}) {
+    let oldRoot = this._getCallTreeRoot(panel);
+    if (oldRoot) {
+      oldRoot.remove();
+    }
+
+    let callTreeRoot = new CallView({ frame: frameNode });
+    callTreeRoot.on("focus", this._onCallViewFocus);
+    callTreeRoot.on("link", this._onCallViewLink);
+    callTreeRoot.on("zoom", this._onCallViewZoom);
+    callTreeRoot.attachTo($(".call-tree-cells-container", panel));
+
+    if (!options.skipCallTreeFocus) {
+      callTreeRoot.focus();
+    }
+
+    let contentOnly = !Prefs.showPlatformData;
+    callTreeRoot.toggleCategories(!contentOnly);
+
+    this._callTreeRootByPanel.set(panel, callTreeRoot);
+  },
+
+  /**
+   * Shortcuts for accessing the recording info or widgets for a <panel>.
+   * @param nsIDOMNode panel [optional]
+   * @return object
+   */
+  _getRecordingInfo: function(panel = this._getPanel()) {
+    return this._recordingInfoByPanel.get(panel);
+  },
+  _getFramerateGraph: function(panel = this._getPanel()) {
+    return this._framerateGraphByPanel.get(panel);
+  },
+  _getCategoriesGraph: function(panel = this._getPanel()) {
+    return this._categoriesGraphByPanel.get(panel);
+  },
+  _getCallTreeRoot: function(panel = this._getPanel()) {
+    return this._callTreeRootByPanel.get(panel);
+  },
+  _getTab: function(tabIndex = this._getSelectedIndex()) {
+    return this._tabs.childNodes[tabIndex];
+  },
+  _getPanel: function(tabIndex = this._getSelectedIndex()) {
+    return this._panels.childNodes[tabIndex];
+  },
+  _getSelectedIndex: function() {
+    return $("#profile-content").selectedIndex;
+  },
+
+  /**
+   * Listener handling the tab "select" event in this container.
+   */
+  _onTabSelect: function() {
+    let categoriesGraph = this._getCategoriesGraph();
+    if (categoriesGraph) {
+      this._newtabButton.hidden = !categoriesGraph.hasSelection();
+    } else {
+      this._newtabButton.hidden = true;
+    }
+
+    this.removeTabsAfter(this._getSelectedIndex());
+  },
+
+  /**
+   * Listener handling the new tab "click" event in this container.
+   */
+  _onNewTabClick: function() {
+    this._spawnTabFromSelection();
+  },
+
+  /**
+   * Listener handling the "legend-selection" event for the graphs in this container.
+   */
+  _onGraphLegendSelection: function() {
+    this._zoomTreeFromSelection({ skipCallTreeFocus: true });
+  },
+
+  /**
+   * Listener handling the "mouseup" event for the graphs in this container.
+   */
+  _onGraphMouseUp: function() {
+    this._zoomTreeFromSelection();
+  },
+
+  /**
+   * Listener handling the "scroll" event for the graphs in this container.
+   */
+  _onGraphScroll: function() {
+    setNamedTimeout("graph-scroll", GRAPH_SCROLL_EVENTS_DRAIN, () => {
+      this._zoomTreeFromSelection();
+    });
+  },
+
+  /**
+   * Listener handling the "focus" event for the call tree in this container.
+   */
+  _onCallViewFocus: function(event, treeItem) {
+    setNamedTimeout("graph-focus", CALL_VIEW_FOCUS_EVENTS_DRAIN, () => {
+      this._highlightAreaFromFrameNode(treeItem.frame);
+    });
+  },
+
+  /**
+   * Listener handling the "link" event for the call tree in this container.
+   */
+  _onCallViewLink: function(event, treeItem) {
+    let { url, line } = treeItem.frame.getInfo();
+    viewSourceInDebugger(url, line);
+  },
+
+  /**
+   * Listener handling the "zoom" event for the call tree in this container.
+   */
+  _onCallViewZoom: function(event, treeItem) {
+    this._spawnTabFromFrameNode(treeItem.frame);
+  }
+};
+
+/**
+ * Utility functions handling recording data.
+ */
+let RecordingUtils = {
+  _frameratePlotsCache: new WeakMap(),
+
+  /**
+   * Creates an appropriate data source to be displayed in a categories graph
+   * from on the provided profiler data.
+   *
+   * @param object profilerData
+   *        The profiler data received from the front.
+   * @param number beginAt
+   *        The earliest time in the profiler data to start at (in milliseconds).
+   * @param number endAt
+   *        The latest time in the profiler data to end at (in milliseconds).
+   * @return array
+   *         A data source useful for a BarGraphWidget.
+   */
+  plotCategoriesFor: function(profilerData, beginAt, endAt) {
+    let categoriesData = [];
+    let profile = profilerData.profile;
+    let samples = profile.threads[0].samples;
+
+    // Accumulate the category of each frame for every sample.
+    for (let { frames, time } of samples) {
+      if (!time || time < beginAt || time > endAt) continue;
+      let blocks = [];
+
+      for (let { category } of frames) {
+        if (!category) continue;
+        let ordinal = CATEGORY_MAPPINGS[category].ordinal;
+
+        if (!blocks[ordinal]) {
+          blocks[ordinal] = 1;
+        } else {
+          blocks[ordinal]++;
+        }
+      }
+
+      categoriesData.push({
+        delta: time,
+        values: blocks
+      });
+    }
+
+    return categoriesData;
+  },
+
+  /**
+   * Creates an appropriate data source to be displayed in a framerate graph
+   * from on the provided refresh driver ticks data.
+   *
+   * @param object ticksData
+   *        The refresh driver ticks received from the front.
+   * @param number beginAt
+   *        The earliest time in the ticks data to start at (in milliseconds).
+   * @param number endAt
+   *        The latest time in the ticks data to end at (in milliseconds).
+   * @return array
+   *         A data source useful for a LineGraphWidget.
+   */
+  plotFramerateFor: function(ticksData, beginAt, endAt) {
+    let framerateData = this._frameratePlotsCache.get(ticksData);
+    if (framerateData == null) {
+      framerateData = FramerateFront.plotFPS(ticksData, FRAMERATE_CALC_INTERVAL);
+      this._frameratePlotsCache.set(ticksData, framerateData);
+    }
+
+    // Quickly find the earliest and oldest valid index in the plotted
+    // framerate data based on the specified beginAt and endAt time. Sure,
+    // using [].findIndex would be more elegant, but also slower.
+    let earliestValidIndex = findFirstIndex(framerateData, e => e.delta >= beginAt);
+    let oldestValidIndex = findLastIndex(framerateData, e => e.delta <= endAt);
+    let totalValues = framerateData.length;
+
+    // If all the plotted framerate data fits inside the specified time range,
+    // simply return it.
+    if (earliestValidIndex == 0 && oldestValidIndex == totalValues - 1) {
+      return framerateData;
+    }
+
+    // Otherwise, a slice will need to be made. Be very careful here, the
+    // beginAt and endAt timestamps can refer to a point in *between* two
+    // entries in the framerate data, so we'll need to insert new values where
+    // the cuts are made.
+    let slicedData = framerateData.slice(earliestValidIndex, oldestValidIndex + 1);
+    if (earliestValidIndex > 0) {
+      slicedData.unshift({
+        delta: beginAt,
+        value: framerateData[earliestValidIndex - 1].value
+      });
+    }
+    if (oldestValidIndex < totalValues - 1) {
+      slicedData.push({
+        delta: endAt,
+        value: framerateData[oldestValidIndex + 1].value
+      });
+    }
+
+    return slicedData;
+  },
+
+  /**
+   * Makes sure the data sources for the categories and framerate graphs
+   * have the same beginning and ending, time-wise.
+   *
+   * @param array categoriesData
+   *        Data source generated by `RecordingUtils.plotCategoriesFor`.
+   * @param array framerateData
+   *        Data source generated by `RecordingUtils.plotFramerateFor`.
+   */
+  syncCategoriesWithFramerate: function(categoriesData, framerateData) {
+    if (categoriesData.length < 2 || framerateData.length < 2) {
+      return;
+    }
+    let categoryBegin = categoriesData[0];
+    let categoryEnd = categoriesData[categoriesData.length - 1];
+    let framerateBegin = framerateData[0];
+    let framerateEnd = framerateData[framerateData.length - 1];
+
+    if (categoryBegin.delta > framerateBegin.delta) {
+      categoriesData.unshift({
+        delta: framerateBegin.delta,
+        values: categoryBegin.values
+      });
+    } else {
+      framerateData.unshift({
+        delta: categoryBegin.delta,
+        value: framerateBegin.value
+      });
+    }
+    if (categoryEnd.delta < framerateEnd.delta) {
+      categoriesData.push({
+        delta: framerateEnd.delta,
+        values: categoryEnd.values
+      });
+    } else {
+      framerateData.push({
+        delta: categoryEnd.delta,
+        value: framerateEnd.value
+      });
+    }
+  }
+};
+
+/**
+ * Finds the index of the first element in an array that validates a predicate.
+ * @param array
+ * @param function predicate
+ * @return number
+ */
+function findFirstIndex(array, predicate) {
+  for (let i = 0, len = array.length; i < len; i++) {
+    if (predicate(array[i])) return i;
+  }
+}
+
+/**
+ * Finds the last of the first element in an array that validates a predicate.
+ * @param array
+ * @param function predicate
+ * @return number
+ */
+function findLastIndex(array, predicate) {
+  for (let i = array.length - 1; i >= 0; i--) {
+    if (predicate(array[i])) return i;
+  }
+}
+
+/**
+ * Opens/selects the debugger in this toolbox and jumps to the specified
+ * file name and line number.
+ * @param string url
+ * @param number line
+ */
+function viewSourceInDebugger(url, line) {
+  let showSource = ({ DebuggerView }) => {
+    if (DebuggerView.Sources.containsValue(url)) {
+      DebuggerView.setEditorLocation(url, line, { noDebug: true }).then(() => {
+        window.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER);
+      }, () => {
+        window.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER);
+      });
+    }
+  };
+
+  // If the Debugger was already open, switch to it and try to show the
+  // source immediately. Otherwise, initialize it and wait for the sources
+  // to be added first.
+  let debuggerAlreadyOpen = gToolbox.getPanel("jsdebugger");
+  gToolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => {
+    if (debuggerAlreadyOpen) {
+      showSource(dbg);
+    } else {
+      dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg));
+    }
+  });
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/profiler/ui-recordings.js
@@ -0,0 +1,357 @@
+/* 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";
+
+/**
+ * Functions handling the recordings UI.
+ */
+let RecordingsListView = Heritage.extend(WidgetMethods, {
+  /**
+   * Initialization function, called when the tool is started.
+   */
+  initialize: function() {
+    this.widget = new SideMenuWidget($("#recordings-list"));
+
+    this._onSelect = this._onSelect.bind(this);
+    this._onClearButtonClick = this._onClearButtonClick.bind(this);
+    this._onRecordButtonClick = this._onRecordButtonClick.bind(this);
+    this._onImportButtonClick = this._onImportButtonClick.bind(this);
+    this._onSaveButtonClick = this._onSaveButtonClick.bind(this);
+
+    this.emptyText = L10N.getStr("noRecordingsText");
+    this.widget.addEventListener("select", this._onSelect, false);
+  },
+
+  /**
+   * Destruction function, called when the tool is closed.
+   */
+  destroy: function() {
+    this.widget.removeEventListener("select", this._onSelect, false);
+  },
+
+  /**
+   * Adds an empty recording to this container.
+   *
+   * @param string profileLabel [optional]
+   *        A custom label for the newly created recording item.
+   */
+  addEmptyRecording: function(profileLabel) {
+    let titleNode = document.createElement("label");
+    titleNode.className = "plain recording-item-title";
+    titleNode.setAttribute("value", profileLabel ||
+      L10N.getFormatStr("recordingsList.itemLabel", this.itemCount + 1));
+
+    let durationNode = document.createElement("label");
+    durationNode.className = "plain recording-item-duration";
+    durationNode.setAttribute("value",
+      L10N.getStr("recordingsList.recordingLabel"));
+
+    let saveNode = document.createElement("label");
+    saveNode.className = "plain recording-item-save";
+    saveNode.addEventListener("click", this._onSaveButtonClick);
+
+    let hspacer = document.createElement("spacer");
+    hspacer.setAttribute("flex", "1");
+
+    let footerNode = document.createElement("hbox");
+    footerNode.className = "recording-item-footer";
+    footerNode.appendChild(durationNode);
+    footerNode.appendChild(hspacer);
+    footerNode.appendChild(saveNode);
+
+    let vspacer = document.createElement("spacer");
+    vspacer.setAttribute("flex", "1");
+
+    let contentsNode = document.createElement("vbox");
+    contentsNode.className = "recording-item";
+    contentsNode.setAttribute("flex", "1");
+    contentsNode.appendChild(titleNode);
+    contentsNode.appendChild(vspacer);
+    contentsNode.appendChild(footerNode);
+
+    // Append a recording item to this container.
+    return this.push([contentsNode], {
+      attachment: {
+        // The profiler and refresh driver ticks data will be available
+        // as soon as recording finishes.
+        profilerData: { profileLabel },
+        ticksData: null
+      }
+    });
+  },
+
+  /**
+   * Signals that a recording session has started.
+   *
+   * @param string profileLabel
+   *        The provided string argument if available, undefined otherwise.
+   */
+  handleRecordingStarted: function(profileLabel) {
+    // Insert a "dummy" recording item, to hint that recording has now started.
+    let recordingItem;
+
+    // If a label is specified (e.g due to a call to `console.profile`),
+    // then try reusing a pre-existing recording item, if there is one.
+    // This is symmetrical to how `this.handleRecordingEnded` works.
+    if (profileLabel) {
+      recordingItem = this.getItemForAttachment(e =>
+        e.profilerData.profileLabel == profileLabel);
+    }
+    // Otherwise, create a new empty recording item.
+    if (!recordingItem) {
+      recordingItem = this.addEmptyRecording(profileLabel);
+    }
+
+    // Mark the corresponding item as being a "record in progress".
+    recordingItem.isRecording = true;
+
+    // If this is the first item, immediately select it.
+    if (this.itemCount == 1) {
+      this.selectedItem = recordingItem;
+    }
+
+    window.emit(EVENTS.RECORDING_STARTED, profileLabel);
+  },
+
+  /**
+   * Signals that a recording session has ended.
+   *
+   * @param object recordingData
+   *        The profiler and refresh driver ticks data received from the front.
+   */
+  handleRecordingEnded: function(recordingData) {
+    let profileLabel = recordingData.profilerData.profileLabel;
+    let recordingItem;
+
+    // If a label is specified (e.g due to a call to `console.profileEnd`),
+    // then try reusing a pre-existing recording item, if there is one.
+    // This is symmetrical to how `this.handleRecordingStarted` works.
+    if (profileLabel) {
+      recordingItem = this.getItemForAttachment(e =>
+        e.profilerData.profileLabel == profileLabel);
+    }
+    // Otherwise, just use the first available recording item.
+    if (!recordingItem) {
+      recordingItem = this.getItemForPredicate(e => e.isRecording);
+    }
+
+    // Mark the corresponding item as being a "finished recording".
+    recordingItem.isRecording = false;
+
+    // Store the recording data, customize and select this recording item.
+    this.customizeRecording(recordingItem, recordingData);
+    this.forceSelect(recordingItem);
+
+    window.emit(EVENTS.RECORDING_ENDED, recordingData);
+  },
+
+  /**
+   * Signals that a recording session has ended abruptly and the accumulated
+   * data should be discarded.
+   */
+  handleRecordingCancelled: Task.async(function*() {
+    if ($("#record-button").hasAttribute("checked")) {
+      $("#record-button").removeAttribute("checked");
+      yield gFront.cancelRecording();
+    }
+    ProfileView.showEmptyNotice();
+
+    window.emit(EVENTS.RECORDING_LOST);
+  }),
+
+  /**
+   * Adds recording data to a recording item in this container.
+   *
+   * @param Item recordingItem
+   *        An item inserted via `RecordingsListView.addEmptyRecording`.
+   * @param object recordingData
+   *        The profiler and refresh driver ticks data received from the front.
+   */
+  customizeRecording: function(recordingItem, recordingData) {
+    recordingItem.attachment = recordingData;
+
+    let saveNode = $(".recording-item-save", recordingItem.target);
+    saveNode.setAttribute("value",
+      L10N.getStr("recordingsList.saveLabel"));
+
+    let durationMillis = recordingData.recordingDuration;
+    let durationNode = $(".recording-item-duration", recordingItem.target);
+    durationNode.setAttribute("value",
+      L10N.getFormatStr("recordingsList.durationLabel", durationMillis));
+  },
+
+  /**
+   * The select listener for this container.
+   */
+  _onSelect: Task.async(function*({ detail: recordingItem }) {
+    if (!recordingItem) {
+      ProfileView.showEmptyNotice();
+      return;
+    }
+    if (recordingItem.isRecording) {
+      ProfileView.showRecordingNotice();
+      return;
+    }
+
+    ProfileView.showLoadingNotice();
+    ProfileView.removeAllTabs();
+
+    let recordingData = recordingItem.attachment;
+    let durationMillis = recordingData.recordingDuration;
+    yield ProfileView.addTabAndPopulate(recordingData, 0, durationMillis);
+    ProfileView.showTabbedBrowser();
+
+    $("#record-button").removeAttribute("checked");
+    $("#record-button").removeAttribute("locked");
+
+    window.emit(EVENTS.RECORDING_DISPLAYED);
+  }),
+
+  /**
+   * The click listener for the "clear" button in this container.
+   */
+  _onClearButtonClick: Task.async(function*() {
+    this.empty();
+    yield this.handleRecordingCancelled();
+  }),
+
+  /**
+   * The click listener for the "record" button in this container.
+   */
+  _onRecordButtonClick: Task.async(function*() {
+    if (!$("#record-button").hasAttribute("checked")) {
+      $("#record-button").setAttribute("checked", "true");
+      yield gFront.startRecording();
+      this.handleRecordingStarted();
+    } else {
+      $("#record-button").setAttribute("locked", "");
+      let recordingData = yield gFront.stopRecording();
+      this.handleRecordingEnded(recordingData);
+    }
+  }),
+
+  /**
+   * The click listener for the "import" button in this container.
+   */
+  _onImportButtonClick: Task.async(function*() {
+    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+    fp.init(window, L10N.getStr("recordingsList.saveDialogTitle"), Ci.nsIFilePicker.modeOpen);
+    fp.appendFilter(L10N.getStr("recordingsList.saveDialogJSONFilter"), "*.json");
+    fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*");
+
+    if (fp.show() == Ci.nsIFilePicker.returnOK) {
+      loadRecordingFromFile(fp.file);
+    }
+  }),
+
+  /**
+   * The click listener for the "save" button of each item in this container.
+   */
+  _onSaveButtonClick: function(e) {
+    let 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;
+      }
+      let recordingItem = this.getItemForElement(e.target);
+      saveRecordingToFile(recordingItem, fp.file);
+    }});
+  }
+});
+
+/**
+ * Gets a nsIScriptableUnicodeConverter instance with a default UTF-8 charset.
+ * @return object
+ */
+function getUnicodeConverter() {
+  let className = "@mozilla.org/intl/scriptableunicodeconverter";
+  let converter = Cc[className].createInstance(Ci.nsIScriptableUnicodeConverter);
+  converter.charset = "UTF-8";
+  return converter;
+}
+
+/**
+ * Saves a recording as JSON to a file. The provided data is assumed to be
+ * acyclical, so that it can be properly serialized.
+ *
+ * @param Item recordingItem
+ *        The recording item containing the data to stream as JSON.
+ * @param nsILocalFile file
+ *        The file to stream the data into.
+ * @return object
+ *         A promise that is resolved once streaming finishes, or rejected
+ *         if there was an error.
+ */
+function saveRecordingToFile(recordingItem, file) {
+  let deferred = promise.defer();
+
+  let recordingData = recordingItem.attachment;
+  recordingData.fileType = PROFILE_SERIALIZER_IDENTIFIER;
+  recordingData.version = PROFILE_SERIALIZER_VERSION;
+
+  let string = JSON.stringify(recordingData);
+  let inputStream = getUnicodeConverter().convertToInputStream(string);
+  let outputStream = FileUtils.openSafeFileOutputStream(file);
+
+  NetUtil.asyncCopy(inputStream, outputStream, status => {
+    if (!Components.isSuccessCode(status)) {
+      deferred.reject(new Error("Could not save recording data file."));
+    }
+    deferred.resolve();
+  });
+
+  return deferred.promise;
+}
+
+/**
+ * Loads a recording stored as JSON from a file.
+ *
+ * @param nsILocalFile file
+ *        The file to import the data from.
+ * @return object
+ *         A promise that is resolved once importing finishes, or rejected
+ *         if there was an error.
+ */
+function loadRecordingFromFile(file) {
+  let deferred = promise.defer();
+
+  let channel = NetUtil.newChannel(file);
+  channel.contentType = "text/plain";
+
+  NetUtil.asyncFetch(channel, (inputStream, status) => {
+    if (!Components.isSuccessCode(status)) {
+      deferred.reject(new Error("Could not import recording data file."));
+      return;
+    }
+    try {
+      let string = NetUtil.readInputStreamToString(inputStream, inputStream.available());
+      var recordingData = JSON.parse(string);
+    } catch (e) {
+      deferred.reject(new Error("Could not read recording data file."));
+      return;
+    }
+    if (recordingData.fileType != PROFILE_SERIALIZER_IDENTIFIER) {
+      deferred.reject(new Error("Unrecognized recording data file."));
+      return;
+    }
+
+    let profileLabel = recordingData.profilerData.profileLabel;
+    let recordingItem = RecordingsListView.addEmptyRecording(profileLabel);
+    RecordingsListView.customizeRecording(recordingItem, recordingData);
+
+    // If this is the first item, immediately select it.
+    if (RecordingsListView.itemCount == 1) {
+      RecordingsListView.selectedItem = recordingItem;
+    }
+
+    deferred.resolve();
+  });
+
+  return deferred.promise;
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/profiler/utils/global.js
@@ -0,0 +1,55 @@
+/* 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";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+/**
+ * Localization convenience methods.
+ */
+const STRINGS_URI = "chrome://browser/locale/devtools/profiler.properties";
+const L10N = new ViewHelpers.L10N(STRINGS_URI);
+
+/**
+ * Details about each profile entry cateogry.
+ * @see CATEGORY_MAPPINGS.
+ */
+const CATEGORIES = [
+  { ordinal: 7, color: "#5e88b0", abbrev: "other", label: L10N.getStr("category.other") },
+  { ordinal: 4, color: "#46afe3", abbrev: "css", label: L10N.getStr("category.css") },
+  { ordinal: 1, color: "#d96629", abbrev: "js", label: L10N.getStr("category.js") },
+  { ordinal: 2, color: "#eb5368", abbrev: "gc", label: L10N.getStr("category.gc") },
+  { ordinal: 0, color: "#df80ff", abbrev: "network", label: L10N.getStr("category.network") },
+  { ordinal: 5, color: "#70bf53", abbrev: "graphics", label: L10N.getStr("category.graphics") },
+  { ordinal: 6, color: "#8fa1b2", abbrev: "storage", label: L10N.getStr("category.storage") },
+  { ordinal: 3, color: "#d99b28", abbrev: "events", label: L10N.getStr("category.events") }
+];
+
+/**
+ * Mapping from category bitmasks in the profiler data to additional details.
+ * To be kept in sync with the js::ProfileEntry::Category in ProfilingStack.h
+ */
+const CATEGORY_MAPPINGS = {
+  "8": CATEGORIES[0],    // js::ProfileEntry::Category::OTHER
+  "16": CATEGORIES[1],   // js::ProfileEntry::Category::CSS
+  "32": CATEGORIES[2],   // js::ProfileEntry::Category::JS
+  "64": CATEGORIES[3],   // js::ProfileEntry::Category::GC
+  "128": CATEGORIES[3],  // js::ProfileEntry::Category::CC
+  "256": CATEGORIES[4],  // js::ProfileEntry::Category::NETWORK
+  "512": CATEGORIES[5],  // js::ProfileEntry::Category::GRAPHICS
+  "1024": CATEGORIES[6], // js::ProfileEntry::Category::STORAGE
+  "2048": CATEGORIES[7], // js::ProfileEntry::Category::EVENTS
+};
+
+// Human-readable JIT category bitmask. Certain pseudo-frames in a sample,
+// like "EnterJIT", don't have any associated `cateogry` information.
+const CATEGORY_JIT = 32;
+
+// Exported symbols.
+exports.L10N = L10N;
+exports.CATEGORIES = CATEGORIES;
+exports.CATEGORY_MAPPINGS = CATEGORY_MAPPINGS;
+exports.CATEGORY_JIT = CATEGORY_JIT;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/profiler/utils/shared.js
@@ -0,0 +1,478 @@
+/* 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";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+loader.lazyRequireGetter(this, "Services");
+loader.lazyRequireGetter(this, "promise");
+loader.lazyRequireGetter(this, "EventEmitter",
+  "devtools/toolkit/event-emitter");
+loader.lazyRequireGetter(this, "FramerateFront",
+  "devtools/server/actors/framerate", true);
+loader.lazyRequireGetter(this, "DevToolsUtils",
+  "devtools/toolkit/DevToolsUtils");
+
+loader.lazyImporter(this, "gDevTools",
+  "resource:///modules/devtools/gDevTools.jsm");
+
+/**
+ * A cache of all ProfilerConnection instances. The keys are Toolbox objects.
+ */
+let SharedProfilerConnection = new WeakMap();
+
+/**
+ * Instantiates a shared ProfilerConnection for the specified toolbox.
+ * Consumers must yield on `open` to make sure the connection is established.
+ *
+ * @param Toolbox toolbox
+ *        The toolbox owning this connection.
+ */
+SharedProfilerConnection.forToolbox = function(toolbox) {
+  if (this.has(toolbox)) {
+    return this.get(toolbox);
+  }
+
+  let instance = new ProfilerConnection(toolbox);
+  this.set(toolbox, instance);
+  return instance;
+};
+
+/**
+ * A connection to the profiler actor, along with other miscellaneous actors,
+ * shared by all tools in a toolbox.
+ *
+ * Use `SharedProfilerConnection.forToolbox` to make sure you get the same
+ * instance every time, and the `ProfilerFront` to start/stop recordings.
+ *
+ * @param Toolbox toolbox
+ *        The toolbox owning this connection.
+ */
+function ProfilerConnection(toolbox) {
+  EventEmitter.decorate(this);
+
+  this._toolbox = toolbox;
+  this._target = this._toolbox.target;
+  this._client = this._target.client;
+  this._request = this._request.bind(this);
+
+  this._pendingFramerateConsumers = 0;
+  this._pendingConsoleRecordings = [];
+  this._finishedConsoleRecordings = [];
+  this._onEventNotification = this._onEventNotification.bind(this);
+
+  Services.obs.notifyObservers(null, "profiler-connection-created", null);
+}
+
+ProfilerConnection.prototype = {
+  /**
+   * Initializes a connection to the profiler and other miscellaneous actors.
+   * If already open, nothing happens.
+   *
+   * @return object
+   *         A promise that is resolved once the connection is established.
+   */
+  open: Task.async(function*() {
+    if (this._connected) {
+      return;
+    }
+
+    // Local debugging needs to make the target remote.
+    yield this._target.makeRemote();
+
+    // Chrome debugging targets have already obtained a reference
+    // to the profiler actor.
+    if (this._target.chrome) {
+      this._profiler = this._target.form.profilerActor;
+    }
+    // Check if we already have a grip to the `listTabs` response object
+    // and, if we do, use it to get to the profiler actor.
+    else if (this._target.root) {
+      this._profiler = this._target.root.profilerActor;
+      yield this._registerEventNotifications();
+    }
+    // Otherwise, call `listTabs`.
+    else {
+      this._profiler = (yield listTabs(this._client)).profilerActor;
+      yield this._registerEventNotifications();
+    }
+
+    this._connectMiscActors();
+    this._connected = true;
+
+    Services.obs.notifyObservers(null, "profiler-connection-opened", null);
+  }),
+
+  /**
+   * Initializes a connection to miscellaneous actors which are going to be
+   * used in tandem with the profiler actor.
+   */
+  _connectMiscActors: function() {
+    this._framerate = new FramerateFront(this._target.client, this._target.form);
+  },
+
+  /**
+   * Sends the request over the remote debugging protocol to the
+   * specified actor.
+   *
+   * @param string actor
+   *        The designated actor. Currently supported: "profiler", "framerate".
+   * @param string method
+   *        Method to call on the backend.
+   * @param any args [optional]
+   *        Additional data or arguments to send with the request.
+   * @return object
+   *         A promise resolved with the response once the request finishes.
+   */
+  _request: function(actor, method, ...args) {
+    // Handle requests to the profiler actor.
+    if (actor == "profiler") {
+      let deferred = promise.defer();
+      let data = args[0] || {};
+      data.to = this._profiler;
+      data.type = method;
+      this._client.request(data, deferred.resolve);
+      return deferred.promise;
+    }
+
+    // Handle requests to the framerate actor.
+    if (actor == "framerate") {
+      // Only stop recording framerate if there are no other pending consumers.
+      // Otherwise, for example, the next time `console.profileEnd` is called
+      // there won't be any framerate data available, since we're reusing the
+      // same actor for multiple overlapping recordings.
+      switch (method) {
+        case "startRecording":
+          this._pendingFramerateConsumers++;
+          break;
+        case "stopRecording":
+        case "cancelRecording":
+          if (--this._pendingFramerateConsumers > 0) return;
+          break;
+      }
+      checkPendingFramerateConsumers(this);
+      return this._framerate[method].apply(this._framerate, args);
+    }
+  },
+
+  /**
+   * Starts listening to certain events emitted by the profiler actor.
+   *
+   * @return object
+   *         A promise that is resolved once the notifications are registered.
+   */
+  _registerEventNotifications: Task.async(function*() {
+    let events = ["console-api-profiler", "profiler-stopped"];
+    yield this._request("profiler", "registerEventNotifications", { events });
+    this._client.addListener("eventNotification", this._onEventNotification);
+  }),
+
+  /**
+   * Invoked whenever a registered event was emitted by the profiler actor.
+   *
+   * @param object response
+   *        The data received from the backend.
+   */
+  _onEventNotification: function(event, response) {
+    let toolbox = gDevTools.getToolbox(this._target);
+    if (toolbox == null) {
+      return;
+    }
+    if (response.topic == "console-api-profiler") {
+      let action = response.subject.action;
+      let details = response.details;
+      if (action == "profile") {
+        this.emit("invoked-console-profile", details.profileLabel); // used in tests
+        this._onConsoleProfileStart(details);
+      } else if (action == "profileEnd") {
+        this.emit("invoked-console-profileEnd", details.profileLabel); // used in tests
+        this._onConsoleProfileEnd(details);
+      }
+    } else if (response.topic == "profiler-stopped") {
+      this._onProfilerUnexpectedlyStopped();
+    }
+  },
+
+  /**
+   * Invoked whenever `console.profile` is called.
+   *
+   * @param string profileLabel
+   *        The provided string argument if available, undefined otherwise.
+   * @param number currentTime
+   *        The time (in milliseconds) when the call was made, relative to
+   *        when the nsIProfiler module was started.
+   */
+  _onConsoleProfileStart: Task.async(function*({ profileLabel, currentTime }) {
+    let pending = this._pendingConsoleRecordings;
+    if (pending.find(e => e.profileLabel == profileLabel)) {
+      return;
+    }
+    // Push this unique `console.profile` call to the stack.
+    pending.push({
+      profileLabel: profileLabel,
+      profilingStartTime: currentTime
+    });
+
+    // Profiling was automatically started when `console.profile` was invoked,
+    // so we all we have to do is make sure the framerate actor is recording.
+    yield this._request("framerate", "startRecording");
+
+    // Signal that a call to `console.profile` actually created a new recording.
+    this.emit("profile", profileLabel);
+  }),
+
+  /**
+   * Invoked whenever `console.profileEnd` is called.
+   *
+   * @param object profilerData
+   *        The profiler data received from the backend.
+   */
+  _onConsoleProfileEnd: Task.async(function*(profilerData) {
+    let pending = this._pendingConsoleRecordings;
+    if (pending.length == 0) {
+      return;
+    }
+    // Try to find the corresponding `console.profile` call in the stack
+    // with the specified label (be it undefined, or a string).
+    let info = pending.find(e => e.profileLabel == profilerData.profileLabel);
+    if (info) {
+      pending.splice(pending.indexOf(info), 1);
+    }
+    // If no corresponding `console.profile` call was found, and if no label
+    // is specified, pop the most recent entry from the stack.
+    else if (!profilerData.profileLabel) {
+      info = pending.pop();
+      profilerData.profileLabel = info.profileLabel;
+    }
+    // ...Otherwise this is a call to `console.profileEnd` with a label that
+    // doesn't exist on the stack. Bail out.
+    else {
+      return;
+    }
+
+    // The profile is already available when console.profileEnd() was invoked,
+    // but we need to filter out all samples that fall out of current profile's
+    // range. This is necessary because the profiler is continuously running.
+    filterSamples(profilerData, info.profilingStartTime);
+    offsetSampleTimes(profilerData, info.profilingStartTime);
+
+    // Fetch the recorded refresh driver ticks, during the same time window
+    // as the filtered profiler data.
+    let beginAt = findEarliestSampleTime(profilerData);
+    let endAt = findOldestSampleTime(profilerData);
+    let ticksData = yield this._request("framerate", "getPendingTicks", beginAt, endAt);
+    yield this._request("framerate", "cancelRecording");
+
+    // Join all the acquired data and emit it for outside consumers.
+    let recordingData = {
+      recordingDuration: profilerData.currentTime - info.profilingStartTime,
+      profilerData: profilerData,
+      ticksData: ticksData
+    };
+    this._finishedConsoleRecordings.push(recordingData);
+
+    // Signal that a call to `console.profileEnd` actually finished a recording.
+    this.emit("profileEnd", recordingData);
+  }),
+
+  /**
+   * Invoked whenever the built-in profiler module is deactivated. Since this
+   * should *never* happen while there's a consumer (i.e. "toolbox") available,
+   * treat this notification as being unexpected.
+   *
+   * This may happen, for example, if the Gecko Profiler add-on is installed
+   * (and isn't using the profiler actor over the remote protocol). There's no
+   * way to prevent it from stopping the profiler and affecting our tool.
+   */
+  _onProfilerUnexpectedlyStopped: function() {
+    // Pop all pending `console.profile` calls from the stack.
+    this._pendingConsoleRecordings.length = 0;
+    this.emit("profiler-unexpectedly-stopped");
+  }
+};
+
+/**
+ * A thin wrapper around a shared ProfilerConnection for the parent toolbox.
+ * Handles manually starting and stopping a recording.
+ *
+ * @param ProfilerConnection connection
+ *        The shared instance for the parent toolbox.
+ */
+function ProfilerFront(connection) {
+  EventEmitter.decorate(this);
+
+  this._request = connection._request;
+  this.pendingConsoleRecordings = connection._pendingConsoleRecordings;
+  this.finishedConsoleRecordings = connection._finishedConsoleRecordings;
+
+  connection.on("profile", (e, args) => this.emit(e, args));
+  connection.on("profileEnd", (e, args) => this.emit(e, args));
+  connection.on("profiler-unexpectedly-stopped", (e, args) => this.emit(e, args));
+}
+
+ProfilerFront.prototype = {
+  /**
+   * Manually begins a recording session.
+   *
+   * @return object
+   *         A promise that is resolved once recording has started.
+   */
+  startRecording: Task.async(function*() {
+    let { isActive, currentTime } = yield this._request("profiler", "isActive");
+
+    // Start the profiler only if it wasn't already active. The built-in
+    // nsIProfiler module will be kept recording, because it's the same instance
+    // for all toolboxes and interacts with the whole platform, so we don't want
+    // to affect other clients by stopping (or restarting) it.
+    if (!isActive) {
+      yield this._request("profiler", "startProfiler", this._customProfilerOptions);
+      this._profilingStartTime = 0;
+      this.emit("profiler-activated");
+    } else {
+      this._profilingStartTime = currentTime;
+      this.emit("profiler-already-active");
+    }
+
+    // The framerate actor is target-dependent, so just make sure
+    // it's recording.
+    yield this._request("framerate", "startRecording");
+  }),
+
+  /**
+   * Manually ends the current recording session.
+   *
+   * @return object
+   *         A promise that is resolved once recording has stopped,
+   *         with the profiler and framerate data.
+   */
+  stopRecording: Task.async(function*() {
+    // We'll need to filter out all samples that fall out of current profile's
+    // range. This is necessary because the profiler is continuously running.
+    let profilerData = yield this._request("profiler", "getProfile");
+    filterSamples(profilerData, this._profilingStartTime);
+    offsetSampleTimes(profilerData, this._profilingStartTime);
+
+    // Fetch the recorded refresh driver ticks, during the same time window
+    // as the filtered profiler data.
+    let beginAt = findEarliestSampleTime(profilerData);
+    let endAt = findOldestSampleTime(profilerData);
+    let ticksData = yield this._request("framerate", "getPendingTicks", beginAt, endAt);
+    yield this._request("framerate", "cancelRecording");
+
+    // Join all the aquired data and return it for outside consumers.
+    return {
+      recordingDuration: profilerData.currentTime - this._profilingStartTime,
+      profilerData: profilerData,
+      ticksData: ticksData
+    };
+  }),
+
+  /**
+   * Ends the current recording session without trying to retrieve any data.
+   */
+  cancelRecording: Task.async(function*() {
+    yield this._request("framerate", "cancelRecording");
+  }),
+
+  /**
+   * Overrides the options sent to the built-in profiler module when activating,
+   * such as the maximum entries count, the sampling interval etc.
+   *
+   * Used in tests.
+   */
+  _options: undefined
+};
+
+/**
+ * Filters all the samples in the provided profiler data to be more recent
+ * than the specified start time.
+ *
+ * @param object profilerData
+ *        The profiler data received from the backend.
+ * @param number profilingStartTime
+ *        The earliest acceptable sample time (in milliseconds).
+ */
+function filterSamples(profilerData, profilingStartTime) {
+  let firstThread = profilerData.profile.threads[0];
+
+  firstThread.samples = firstThread.samples.filter(e => {
+    return e.time >= profilingStartTime;
+  });
+}
+
+/**
+ * Offsets all the samples in the provided profiler data by the specified time.
+ *
+ * @param object profilerData
+ *        The profiler data received from the backend.
+ * @param number timeOffset
+ *        The amount of time to offset by (in milliseconds).
+ */
+function offsetSampleTimes(profilerData, timeOffset) {
+  let firstThreadSamples = profilerData.profile.threads[0].samples;
+
+  for (let sample of firstThreadSamples) {
+    sample.time -= timeOffset;
+  }
+}
+
+/**
+ * Finds the earliest sample time in the provided profiler data.
+ *
+ * @param object profilerData
+ *        The profiler data received from the backend.
+ * @return number
+ *         The earliest sample time (in milliseconds).
+ */
+function findEarliestSampleTime(profilerData) {
+  let firstThreadSamples = profilerData.profile.threads[0].samples;
+
+  for (let sample of firstThreadSamples) {
+    if ("time" in sample) {
+      return sample.time;
+    }
+  }
+}
+
+/**
+ * Finds the oldest sample time in the provided profiler data.
+ *
+ * @param object profilerData
+ *        The profiler data received from the backend.
+ * @return number
+ *         The oldest sample time (in milliseconds).
+ */
+function findOldestSampleTime(profilerData) {
+  let firstThreadSamples = profilerData.profile.threads[0].samples;
+
+  for (let i = firstThreadSamples.length - 1; i >= 0; i--) {
+    if ("time" in firstThreadSamples[i]) {
+      return firstThreadSamples[i].time;
+    }
+  }
+}
+
+/**
+ * Asserts the value sanity of `pendingFramerateConsumers`.
+ */
+function checkPendingFramerateConsumers(connection) {
+  if (connection._pendingFramerateConsumers < 0) {
+    let msg = "Somehow the number of framerate consumers is now negative.";
+    DevToolsUtils.reportException("ProfilerConnection", msg);
+  }
+}
+
+/**
+ * A collection of small wrappers promisifying functions invoking callbacks.
+ */
+function listTabs(client) {
+  let deferred = promise.defer();
+  client.listTabs(deferred.resolve);
+  return deferred.promise;
+}
+
+exports.getProfilerConnection = toolbox => SharedProfilerConnection.forToolbox(toolbox);
+exports.ProfilerFront = ProfilerFront;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/profiler/utils/tree-model.js
@@ -0,0 +1,258 @@
+/* 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";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+
+loader.lazyRequireGetter(this, "Services");
+loader.lazyRequireGetter(this, "L10N",
+  "devtools/profiler/global", true);
+loader.lazyRequireGetter(this, "CATEGORY_MAPPINGS",
+  "devtools/profiler/global", true);
+loader.lazyRequireGetter(this, "CATEGORY_JIT",
+  "devtools/profiler/global", true);
+
+const CHROME_SCHEMES = ["chrome://", "resource://"];
+const CONTENT_SCHEMES = ["http://", "https://", "file://"];
+
+exports.ThreadNode = ThreadNode;
+exports.FrameNode = FrameNode;
+exports._isContent = isContent; // used in tests
+
+/**
+ * A call tree for a thread. This is essentially a linkage between all frames
+ * of all samples into a single tree structure, with additional information
+ * on each node, like the time spent (in milliseconds) and invocations count.
+ *
+ * Example:
+ * {
+ *   duration: number,
+ *   calls: {
+ *     "FunctionName (url:line)": {
+ *       line: number,
+ *       category: number,
+ *       duration: number,
+ *       invocations: number,
+ *       calls: {
+ *         ...
+ *       }
+ *     }, // FrameNode
+ *     ...
+ *   }
+ * } // ThreadNode
+ *
+ * @param object threadSamples
+ *        The raw samples array received from the backend.
+ * @param boolean contentOnly [optional]
+ *        @see ThreadNode.prototype.insert
+ * @param number beginAt [optional]
+ *        @see ThreadNode.prototype.insert
+ * @param number endAt [optional]
+ *        @see ThreadNode.prototype.insert
+ */
+function ThreadNode(threadSamples, contentOnly, beginAt, endAt) {
+  this.duration = 0;
+  this.calls = {};
+  this._previousSampleTime = 0;
+
+  for (let sample of threadSamples) {
+    this.insert(sample, contentOnly, beginAt, endAt);
+  }
+}
+
+ThreadNode.prototype = {
+  /**
+   * Adds function calls in the tree from a sample's frames.
+   *
+   * @param object sample
+   *        The { frames, time } sample, containing an array of frames and
+   *        the time the sample was taken. This sample is assumed to be older
+   *        than the most recently inserted one.
+   * @param boolean contentOnly [optional]
+   *        Specifies if platform frames shouldn't be taken into consideration.
+   * @param number beginAt [optional]
+   *        The earliest sample to start at (in milliseconds).
+   * @param number endAt [optional]
+   *        The latest sample to end at (in milliseconds).
+   */
+  insert: function(sample, contentOnly = false, beginAt = 0, endAt = Infinity) {
+    let sampleTime = sample.time;
+    if (!sampleTime || sampleTime < beginAt || sampleTime > endAt) {
+      return;
+    }
+
+    let sampleFrames = sample.frames;
+    let rootIndex = 1;
+
+    // Filter out platform frames if only content-related function calls
+    // should be taken into consideration.
+    if (contentOnly) {
+      sampleFrames = sampleFrames.filter(frame => isContent(frame));
+      rootIndex = 0;
+    }
+    if (!sampleFrames.length) {
+      return;
+    }
+
+    let sampleDuration = sampleTime - this._previousSampleTime;
+    this._previousSampleTime = sampleTime;
+    this.duration += sampleDuration;
+
+    FrameNode.prototype.insert(
+      sampleFrames, rootIndex, sampleTime, sampleDuration, this.calls);
+  },
+
+  /**
+   * Gets additional details about this node.
+   * @return object
+   */
+  getInfo: function() {
+    return {
+      nodeType: "Thread",
+      functionName: L10N.getStr("table.root"),
+      categoryData: {}
+    };
+  }
+};
+
+/**
+ * A function call node in a tree.
+ *
+ * @param string location
+ *        The location of this function call. Note that this isn't sanitized,
+ *        so it may very well (not?) include the function name, url, etc.
+ * @param number line
+ *        The line number inside the source containing this function call.
+ * @param number category
+ *        The category type of this function call ("js", "graphics" etc.).
+ */
+function FrameNode({ location, line, category }) {
+  this.location = location;
+  this.line = line;
+  this.category = category;
+  this.sampleTimes = [];
+  this.duration = 0;
+  this.invocations = 0;
+  this.calls = {};
+}
+
+FrameNode.prototype = {
+  /**
+   * Adds function calls in the tree from a sample's frames. For example, given
+   * the the frames below (which would account for three calls to `insert` on
+   * the root frame), the following tree structure is created:
+   *
+   *                          A
+   *   A -> B -> C           / \
+   *   A -> B -> D    ~>    B   E
+   *   A -> E -> F         / \   \
+   *                      C   D   F
+   * @param frames
+   *        The sample call stack.
+   * @param index
+   *        The index of the call in the stack representing this node.
+   * @param number time
+   *        The delta time (in milliseconds) when the frame was sampled.
+   * @param number duration
+   *        The amount of time spent executing all functions on the stack.
+   */
+  insert: function(frames, index, time, duration, _store = this.calls) {
+    let frame = frames[index];
+    if (!frame) {
+      return;
+    }
+    let location = frame.location;
+    let child = _store[location] || (_store[location] = new FrameNode(frame));
+    child.sampleTimes.push({ start: time, end: time + duration });
+    child.duration += duration;
+    child.invocations++;
+    child.insert(frames, ++index, time, duration);
+  },
+
+  /**
+   * Parses the raw location of this function call to retrieve the actual
+   * function name and source url.
+   *
+   * @return object
+   *         The computed { name, file, url, line } properties for this
+   *         function call.
+   */
+  getInfo: function() {
+    // "EnterJIT" pseudoframes are special, not actually on the stack.
+    if (this.location == "EnterJIT") {
+      this.category = CATEGORY_JIT;
+    }
+
+    // Since only C++ stack frames have associated category information,
+    // default to an "unknown" category otherwise.
+    let categoryData = CATEGORY_MAPPINGS[this.category] || {};
+
+    // Parse the `location` for the function name, source url and line.
+    let firstParen = this.location.indexOf("(");
+    let lastColon = this.location.lastIndexOf(":");
+    let resource = this.location.substring(firstParen + 1, lastColon);
+    let line = this.location.substring(lastColon + 1).replace(")", "");
+    let url = resource.split(" -> ").pop();
+    let uri = nsIURL(url);
+    let functionName, fileName, hostName;
+
+    // If the URI digged out from the `location` is valid, this is a JS frame.
+    if (uri) {
+      functionName = this.location.substring(0, firstParen - 1);
+      fileName = (uri.fileName + (uri.ref ? "#" + uri.ref : "")) || "/";
+      hostName = uri.host;
+    } else {
+      functionName = this.location;
+      url = null;
+      line = null;
+    }
+
+    return {
+      nodeType: "Frame",
+      functionName: functionName,
+      fileName: fileName,
+      hostName: hostName,
+      url: url,
+      line: line || this.line,
+      categoryData: categoryData,
+      isContent: !!isContent(this)
+    };
+  }
+};
+
+/**
+ * Checks if the specified function represents a chrome or content frame.
+ *
+ * @param object frame
+ *        The { category, location } properties of the frame.
+ * @return boolean
+ *         True if a content frame, false if a chrome frame.
+ */
+function isContent({ category, location }) {
+  // Only C++ stack frames have associated category information.
+  return !category &&
+    !CHROME_SCHEMES.find(e => location.contains(e)) &&
+    CONTENT_SCHEMES.find(e => location.contains(e));
+}
+
+/**
+ * Helper for getting an nsIURL instance out of a string.
+ */
+function nsIURL(url) {
+  let cached = gNSURLStore.get(url);
+  if (cached) {
+    return cached;
+  }
+  let uri = null;
+  try {
+    uri = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
+  } catch(e) {
+    // The passed url string is invalid.
+  }
+  gNSURLStore.set(url, uri);
+  return uri;
+}
+
+// The cache used in the `nsIURL` function.
+let gNSURLStore = new Map();
new file mode 100644
--- /dev/null
+++ b/browser/devtools/profiler/utils/tree-view.js
@@ -0,0 +1,227 @@
+/* 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";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+
+loader.lazyRequireGetter(this, "L10N",
+  "devtools/profiler/global", true);
+
+loader.lazyImporter(this, "Heritage",
+  "resource:///modules/devtools/ViewHelpers.jsm");
+loader.lazyImporter(this, "AbstractTreeItem",
+  "resource:///modules/devtools/AbstractTreeItem.jsm");
+
+const URL_LABEL_TOOLTIP = L10N.getStr("table.url.tooltiptext");
+const ZOOM_BUTTON_TOOLTIP = L10N.getStr("table.zoom.tooltiptext");
+const CALL_TREE_INDENTATION = 16; // px
+const CALL_TREE_AUTO_EXPAND = 3; // depth
+
+exports.CallView = CallView;
+
+/**
+ * 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 instance will be
+ *        represent the "callee". Should be null for root nodes.
+ * @param ThreadNode | FrameNode frame
+ *        Details about this function, like { duration, invocation, calls } etc.
+ * @param number level
+ *        The indentation level in the call tree. The root node is at level 0.
+ */
+function CallView({ caller, frame, level }) {
+  AbstractTreeItem.call(this, { parent: caller, level: level });
+
+  this.autoExpandDepth = caller ? caller.autoExpandDepth : CALL_TREE_AUTO_EXPAND;
+  this.frame = frame;
+
+  this._onUrlClick = this._onUrlClick.bind(this);
+  this._onZoomClick = this._onZoomClick.bind(this);
+};
+
+CallView.prototype = Heritage.extend(AbstractTreeItem.prototype, {
+  /**
+   * Creates the view for this tree node.
+   * @param nsIDOMNode document
+   * @param nsIDOMNode arrowNode
+   * @return nsIDOMNode
+   */
+  _displaySelf: function(document, arrowNode) {
+    this.document = document;
+
+    let frameInfo = this.frame.getInfo();
+    let framePercentage = this.frame.duration / this.root.frame.duration * 100;
+
+    let durationCell = this._createTimeCell(this.frame.duration);
+    let percentageCell = this._createExecutionCell(framePercentage);
+    let invocationsCell = this._createInvocationsCell(this.frame.invocations);
+    let functionCell = this._createFunctionCell(arrowNode, frameInfo, this.level);
+
+    let targetNode = document.createElement("hbox");
+    targetNode.className = "call-tree-item";
+    targetNode.setAttribute("origin", frameInfo.isContent ? "content" : "chrome");
+    targetNode.setAttribute("category", frameInfo.categoryData.abbrev || "");
+    targetNode.setAttribute("tooltiptext", this.frame.location || "");
+
+    let isRoot = frameInfo.nodeType == "Thread";
+    if (isRoot) {
+      functionCell.querySelector(".call-tree-zoom").hidden = true;
+      functionCell.querySelector(".call-tree-category").hidden = true;
+    }
+
+    targetNode.appendChild(durationCell);
+    targetNode.appendChild(percentageCell);
+    targetNode.appendChild(invocationsCell);
+    targetNode.appendChild(functionCell);
+
+    return targetNode;
+  },
+
+  /**
+   * Populates this node in the call tree with the corresponding "callees".
+   * These are defined in the `frame` data source for this call view.
+   * @param array:AbstractTreeItem children
+   */
+  _populateSelf: function(children) {
+    let newLevel = this.level + 1;
+
+    for (let [, newFrame] of _Iterator(this.frame.calls)) {
+      children.push(new CallView({
+        caller: this,
+        frame: newFrame,
+        level: newLevel
+      }));
+    }
+
+    // Sort the "callees" asc. by duration, before inserting them in the tree.
+    children.sort((a, b) => a.frame.duration < b.frame.duration ? 1 : -1);
+  },
+
+  /**
+   * Functions creating each cell in this call view.
+   * Invoked by `_displaySelf`.
+   */
+  _createTimeCell: function(duration) {
+    let cell = this.document.createElement("label");
+    cell.className = "plain call-tree-cell";
+    cell.setAttribute("type", "duration");
+    cell.setAttribute("crop", "end");
+    cell.setAttribute("value", L10N.numberWithDecimals(duration, 2));
+    return cell;
+  },
+  _createExecutionCell: function(percentage) {
+    let cell = this.document.createElement("label");
+    cell.className = "plain call-tree-cell";
+    cell.setAttribute("type", "percentage");
+    cell.setAttribute("crop", "end");
+    cell.setAttribute("value", L10N.numberWithDecimals(percentage, 2) + "%");
+    return cell;
+  },
+  _createInvocationsCell: function(count) {
+    let cell = this.document.createElement("label");
+    cell.className = "plain call-tree-cell";
+    cell.setAttribute("type", "invocations");
+    cell.setAttribute("crop", "end");
+    cell.setAttribute("value", count || "");
+    return cell;
+  },
+  _createFunctionCell: function(arrowNode, frameInfo, frameLevel) {
+    let cell = this.document.createElement("hbox");
+    cell.className = "call-tree-cell";
+    cell.style.MozMarginStart = (frameLevel * CALL_TREE_INDENTATION) + "px";
+    cell.setAttribute("type", "function");
+    cell.appendChild(arrowNode);
+
+    let nameNode = this.document.createElement("label");
+    nameNode.className = "plain call-tree-name";
+    nameNode.setAttribute("flex", "1");
+    nameNode.setAttribute("crop", "end");
+    nameNode.setAttribute("value", frameInfo.functionName || "");
+    cell.appendChild(nameNode);
+
+    let urlNode = this.document.createElement("label");
+    urlNode.className = "plain call-tree-url";
+    urlNode.setAttribute("flex", "1");
+    urlNode.setAttribute("crop", "end");
+    urlNode.setAttribute("value", frameInfo.fileName || "");
+    urlNode.setAttribute("tooltiptext", URL_LABEL_TOOLTIP + " → " + frameInfo.url);
+    urlNode.addEventListener("mousedown", this._onUrlClick);
+    cell.appendChild(urlNode);
+
+    let lineNode = this.document.createElement("label");
+    lineNode.className = "plain call-tree-line";
+    lineNode.setAttribute("value", frameInfo.line ? ":" + frameInfo.line : "");
+    cell.appendChild(lineNode);
+
+    let hostNode = this.document.createElement("label");
+    hostNode.className = "plain call-tree-host";
+    hostNode.setAttribute("value", frameInfo.hostName || "");
+    cell.appendChild(hostNode);
+
+    let zoomNode = this.document.createElement("button");
+    zoomNode.className = "plain call-tree-zoom";
+    zoomNode.setAttribute("tooltiptext", ZOOM_BUTTON_TOOLTIP);
+    zoomNode.addEventListener("mousedown", this._onZoomClick);
+    cell.appendChild(zoomNode);
+
+    let spacerNode = this.document.createElement("spacer");
+    spacerNode.setAttribute("flex", "10000");
+    cell.appendChild(spacerNode);
+
+    let categoryNode = this.document.createElement("label");
+    categoryNode.className = "plain call-tree-category";
+    categoryNode.style.color = frameInfo.categoryData.color;
+    categoryNode.setAttribute("value", frameInfo.categoryData.label || "");
+    cell.appendChild(categoryNode);
+
+    let hasDescendants = Object.keys(this.frame.calls).length > 0;
+    if (hasDescendants == false) {
+      arrowNode.setAttribute("invisible", "");
+    }
+
+    return cell;
+  },
+
+  /**
+   * 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();
+    this.root.emit("link", this);
+  },
+
+  /**
+   * Handler for the "click" event on the zoom node of this call view.
+   */
+  _onZoomClick: function(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.root.emit("zoom", this);
+  }
+});
--- a/browser/devtools/shared/moz.build
+++ b/browser/devtools/shared/moz.build
@@ -14,16 +14,17 @@ EXTRA_JS_MODULES.devtools += [
     'DOMHelpers.jsm',
     'FloatingScrollbars.jsm',
     'Jsbeautify.jsm',
     'Parser.jsm',
     'SplitView.jsm',
 ]
 
 EXTRA_JS_MODULES.devtools += [
+    'widgets/AbstractTreeItem.jsm',
     'widgets/BreadcrumbsWidget.jsm',
     'widgets/Chart.jsm',
     'widgets/Graphs.jsm',
     'widgets/SideMenuWidget.jsm',
     'widgets/SimpleListWidget.jsm',
     'widgets/VariablesView.jsm',
     'widgets/VariablesViewController.jsm',
     'widgets/ViewHelpers.jsm',
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/widgets/AbstractTreeItem.jsm
@@ -0,0 +1,474 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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";
+
+const Cu = Components.utils;
+
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource://gre/modules/devtools/event-emitter.js");
+
+this.EXPORTED_SYMBOLS = ["AbstractTreeItem"];
+
+/**
+ * A very generic and low-level tree view implementation. It is not intended
+ * to be used alone, but as a base class that you can extend to build your
+ * own custom implementation.
+ *
+ * Language:
+ *   - An "item" is an instance of an AbstractTreeItem.
+ *   - An "element" or "node" is an nsIDOMNode.
+ *
+ * The following events are emitted by this tree, always from the root item,
+ * with the first argument pointing to the affected child item:
+ *   - "expand": when an item is expanded in the tree
+ *   - "collapse": when an item is collapsed in the tree
+ *   - "focus": when an item is selected in the tree
+ *
+ * For example, you can extend this abstract class like this:
+ *
+ * function MyCustomTreeItem(dataSrc, properties) {
+ *   AbstractTreeItem.call(this, properties);
+ *   this.itemDataSrc = dataSrc;
+ * }
+ *
+ * MyCustomTreeItem.prototype = Heritage.extend(AbstractTreeItem.prototype, {
+ *   _displaySelf: function(document, arrowNode) {
+ *     let node = document.createElement("hbox");
+ *     ...
+ *     // Append the provided arrow node wherever you want.
+ *     node.appendChild(arrowNode);
+ *     ...
+ *     // Use `this.itemDataSrc` to customize the tree item and
+ *     // `this.level` to calculate the indentation.
+ *     node.MozMarginStart = (this.level * 10) + "px";
+ *     node.appendChild(document.createTextNode(this.itemDataSrc.label));
+ *     ...
+ *     return node;
+ *   },
+ *   _populateSelf: function(children) {
+ *     ...
+ *     // Use `this.itemDataSrc` to get the data source for the child items.
+ *     let someChildDataSrc = this.itemDataSrc.children[0];
+ *     ...
+ *     children.push(new MyCustomTreeItem(someChildDataSrc, {
+ *       parent: this,
+ *       level: this.level + 1
+ *     }));
+ *     ...
+ *   }
+ * });
+ *
+ * And then you could use it like this:
+ *
+ * let dataSrc = {
+ *   label: "root",
+ *   children: [{
+ *     label: "foo",
+ *     children: []
+ *   }, {
+ *     label: "bar",
+ *     children: [{
+ *       label: "baz",
+ *       children: []
+ *     }]
+ *   }]
+ * };
+ * let root = new MyCustomTreeItem(dataSrc, { parent: null });
+ * root.attachTo(nsIDOMNode);
+ * root.expand();
+ *
+ * The following tree view will be generated (after expanding all nodes):
+ * ▼ root
+ *   ▶ foo
+ *   ▼ bar
+ *     ▶ baz
+ *
+ * The way the data source is implemented is completely up to you. There's
+ * no assumptions made and you can use it however you like inside the
+ * `_displaySelf` and `populateSelf` methods. If you need to add children to a
+ * node at a later date, you just need to modify the data source:
+ *
+ * dataSrc[...path-to-foo...].children.push({
+ *   label: "lazily-added-node"
+ *   children: []
+ * });
+ *
+ * The existing tree view will be modified like so (after expanding `foo`):
+ * ▼ root
+ *   ▼ foo
+ *     ▶ lazily-added-node
+ *   ▼ bar
+ *     ▶ baz
+ *
+ * Everything else is taken care of automagically!
+ *
+ * @param AbstractTreeItem parent
+ *        The parent tree item. Should be null for root items.
+ * @param number level
+ *        The indentation level in the tree. The root item is at level 0.
+ */
+function AbstractTreeItem({ parent, level }) {
+  this._rootItem = parent ? parent._rootItem : this;
+  this._parentItem = parent;
+  this._level = level || 0;
+  this._childTreeItems = [];
+  this._onArrowClick = this._onArrowClick.bind(this);
+  this._onClick = this._onClick.bind(this);
+  this._onDoubleClick = this._onDoubleClick.bind(this);
+  this._onKeyPress = this._onKeyPress.bind(this);
+  this._onFocus = this._onFocus.bind(this);
+
+  EventEmitter.decorate(this);
+}
+
+AbstractTreeItem.prototype = {
+  _containerNode: null,
+  _targetNode: null,
+  _arrowNode: null,
+  _constructed: false,
+  _populated: false,
+  _expanded: false,
+
+  /**
+   * Optionally, trees may be allowed to automatically expand a few levels deep
+   * to avoid initially displaying a completely collapsed tree.
+   */
+  autoExpandDepth: 0,
+
+  /**
+   * Creates the view for this tree item. Implement this method in the
+   * inheriting classes to create the child node displayed in the tree.
+   * Use `this.level` and the provided `arrowNode` as you see fit.
+   *
+   * @param nsIDOMNode document
+   * @param nsIDOMNode arrowNode
+   * @return nsIDOMNode
+   */
+  _displaySelf: function(document, arrowNode) {
+    throw "This method needs to be implemented by inheriting classes.";
+  },
+
+  /**
+   * Populates this tree item with child items, whenever it's expanded.
+   * Implement this method in the inheriting classes to fill the provided
+   * `children` array with AbstractTreeItem instances, which will then be
+   * magically handled by this tree item.
+   *
+   * @param array:AbstractTreeItem children
+   */
+  _populateSelf: function(children) {
+    throw "This method needs to be implemented by inheriting classes.";
+  },
+
+  /**
+   * Gets the root item of this tree.
+   * @return AbstractTreeItem
+   */
+  get root() {
+    return this._rootItem;
+  },
+
+  /**
+   * Gets the parent of this tree item.
+   * @return AbstractTreeItem
+   */
+  get parent() {
+    return this._parentItem;
+  },
+
+  /**
+   * Gets the indentation level of this tree item.
+   */
+  get level() {
+    return this._level;
+  },
+
+  /**
+   * Gets the element displaying this tree item.
+   */
+  get target() {
+    return this._targetNode;
+  },
+
+  /**
+   * Gets the element containing all tree items.
+   * @return nsIDOMNode
+   */
+  get container() {
+    return this._containerNode;
+  },
+
+  /**
+   * Returns whether or not this item is populated in the tree.
+   * Collapsed items can still be populated.
+   * @return boolean
+   */
+  get populated() {
+    return this._populated;
+  },
+
+  /**
+   * Returns whether or not this item is expanded in the tree.
+   * Expanded items with no children aren't consudered `populated`.
+   * @return boolean
+   */
+  get expanded() {
+    return this._expanded;
+  },
+
+  /**
+   * Creates and appends this tree item to the specified parent element.
+   *
+   * @param nsIDOMNode containerNode
+   *        The parent element for this tree item (and every other tree item).
+   * @param nsIDOMNode beforeNode
+   *        The child element which should succeed this tree item.
+   */
+  attachTo: function(containerNode, beforeNode = null) {
+    this._containerNode = containerNode;
+    this._constructTargetNode();
+    containerNode.insertBefore(this._targetNode, beforeNode);
+
+    if (this._level < this.autoExpandDepth) {
+      this.expand();
+    }
+  },
+
+  /**
+   * Permanently removes this tree item (and all subsequent children) from the
+   * parent container.
+   */
+  remove: function() {
+    this._targetNode.remove();
+    this._hideChildren();
+    this._childTreeItems.length = 0;
+  },
+
+  /**
+   * Focuses this item in the tree.
+   */
+  focus: function() {
+    this._targetNode.focus();
+  },
+
+  /**
+   * Expands this item in the tree.
+   */
+  expand: function() {
+    if (this._expanded) {
+      return;
+    }
+    this._expanded = true;
+    this._arrowNode.setAttribute("open", "");
+    this._toggleChildren(true);
+    this._rootItem.emit("expand", this);
+  },
+
+  /**
+   * Collapses this item in the tree.
+   */
+  collapse: function() {
+    if (!this._expanded) {
+      return;
+    }
+    this._expanded = false;
+    this._arrowNode.removeAttribute("open");
+    this._toggleChildren(false);
+    this._rootItem.emit("collapse", this);
+  },
+
+  /**
+   * Returns the child item at the specified index.
+   *
+   * @param number index
+   * @return AbstractTreeItem
+   */
+  getChild: function(index = 0) {
+    return this._childTreeItems[index];
+  },
+
+  /**
+   * Shows or hides all the children of this item in the tree. If neessary,
+   * populates this item with children.
+   *
+   * @param boolean visible
+   *        True if the children should be visible, false otherwise.
+   */
+  _toggleChildren: function(visible) {
+    if (visible) {
+      if (!this._populated) {
+        this._populateSelf(this._childTreeItems);
+        this._populated = this._childTreeItems.length > 0;
+      }
+      this._showChildren();
+    } else {
+      this._hideChildren();
+    }
+  },
+
+  /**
+   * Shows all children of this item in the tree.
+   */
+  _showChildren: function() {
+    let childTreeItems = this._childTreeItems;
+    let expandedChildTreeItems = childTreeItems.filter(e => e._expanded);
+    let nextNode = this._getSiblingAtDelta(1);
+
+    // First append the child items, and afterwards append any descendants.
+    // Otherwise, the tree will become garbled and nodes will intertwine.
+    for (let item of childTreeItems) {
+      item.attachTo(this._containerNode, nextNode);
+    }
+    for (let item of expandedChildTreeItems) {
+      item._showChildren();
+    }
+  },
+
+  /**
+   * Hides all children of this item in the tree.
+   */
+  _hideChildren: function() {
+    for (let item of this._childTreeItems) {
+      item._targetNode.remove();
+      item._hideChildren();
+    }
+  },
+
+  /**
+   * Constructs and stores the target node displaying this tree item.
+   */
+  _constructTargetNode: function() {
+    if (this._constructed) {
+      return;
+    }
+    let document = this._containerNode.ownerDocument;
+
+    let arrowNode = this._arrowNode = document.createElement("hbox");
+    arrowNode.className = "arrow theme-twisty";
+    arrowNode.addEventListener("mousedown", this._onArrowClick);
+
+    let targetNode = this._targetNode = this._displaySelf(document, arrowNode);
+    targetNode.style.MozUserFocus = "normal";
+
+    targetNode.addEventListener("mousedown", this._onClick);
+    targetNode.addEventListener("dblclick", this._onDoubleClick);
+    targetNode.addEventListener("keypress", this._onKeyPress);
+    targetNode.addEventListener("focus", this._onFocus);
+
+    this._constructed = true;
+  },
+
+  /**
+   * Gets the element displaying an item in the tree at the specified offset
+   * relative to this item.
+   *
+   * @param number delta
+   *        The offset from this item to the target item.
+   * @return nsIDOMNode
+   *         The element displaying the target item at the specified offset.
+   */
+  _getSiblingAtDelta: function(delta) {
+    let childNodes = this._containerNode.childNodes;
+    let indexOfSelf = Array.indexOf(childNodes, this._targetNode);
+    return childNodes[indexOfSelf + delta];
+  },
+
+  /**
+   * Focuses the next item in this tree.
+   */
+  _focusNextNode: function() {
+    let nextElement = this._getSiblingAtDelta(1);
+    if (nextElement) nextElement.focus(); // nsIDOMNode
+  },
+
+  /**
+   * Focuses the previous item in this tree.
+   */
+  _focusPrevNode: function() {
+    let prevElement = this._getSiblingAtDelta(-1);
+    if (prevElement) prevElement.focus(); // nsIDOMNode
+  },
+
+  /**
+   * Focuses the parent item in this tree.
+   *
+   * The parent item is not always the previous item, because any tree item
+   * may have multiple children.
+   */
+  _focusParentNode: function() {
+    let parentItem = this._parentItem;
+    if (parentItem) parentItem.focus(); // AbstractTreeItem
+  },
+
+  /**
+   * Handler for the "click" event on the arrow node of this tree item.
+   */
+  _onArrowClick: function(e) {
+    if (!this._expanded) {
+      this.expand();
+    } else {
+      this.collapse();
+    }
+  },
+
+  /**
+   * Handler for the "click" event on the element displaying this tree item.
+   */
+  _onClick: function(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.focus();
+  },
+
+  /**
+   * Handler for the "dblclick" event on the element displaying this tree item.
+   */
+  _onDoubleClick: function(e) {
+    this._onArrowClick(e);
+    this.focus();
+  },
+
+  /**
+   * Handler for the "keypress" event on the element displaying this tree item.
+   */
+  _onKeyPress: function(e) {
+    // Prevent scrolling when pressing navigation keys.
+    ViewHelpers.preventScrolling(e);
+
+    switch (e.keyCode) {
+      case e.DOM_VK_UP:
+        this._focusPrevNode();
+        return;
+
+      case e.DOM_VK_DOWN:
+        this._focusNextNode();
+        return;
+
+      case e.DOM_VK_LEFT:
+        if (this._expanded && this._populated) {
+          this.collapse();
+        } else {
+          this._focusParentNode();
+        }
+        return;
+
+      case e.DOM_VK_RIGHT:
+        if (!this._expanded) {
+          this.expand();
+        } else {
+          this._focusNextNode();
+        }
+        return;
+    }
+  },
+
+  /**
+   * Handler for the "focus" event on the element displaying this tree item.
+   */
+  _onFocus: function(e) {
+    this._rootItem.emit("focus", this);
+  }
+};
--- a/browser/devtools/shared/widgets/ViewHelpers.jsm
+++ b/browser/devtools/shared/widgets/ViewHelpers.jsm
@@ -791,16 +791,26 @@ this.WidgetMethods = {
    * @param number aIndex
    *        The index of the item to remove.
    */
   removeAt: function(aIndex) {
     this.remove(this.getItemAtIndex(aIndex));
   },
 
   /**
+   * Removes the items in this container based on a predicate.
+   */
+  removeForPredicate: function(aPredicate) {
+    let item;
+    while (item = this.getItemForPredicate(aPredicate)) {
+      this.remove(item);
+    }
+  },
+
+  /**
    * Removes all items from this container.
    */
   empty: function() {
     this._preferredValue = this.selectedValue;
     this._widget.selectedItem = null;
     this._widget.removeAllItems();
     this._widget.setAttribute("emptyText", this._emptyText);
 
@@ -1070,16 +1080,18 @@ this.WidgetMethods = {
     let targetElement = aItem ? aItem._target : null;
     let prevElement = this._widget.selectedItem;
 
     // Make sure the selected item's target element is focused and visible.
     if (this.autoFocusOnSelection && targetElement) {
       targetElement.focus();
     }
     if (this.maintainSelectionVisible && targetElement) {
+      // Some methods are optional. See the WidgetMethods object documentation
+      // for a comprehensive list.
       if ("ensureElementIsVisible" in this._widget) {
         this._widget.ensureElementIsVisible(targetElement);
       }
     }
 
     // Prevent selecting the same item again and avoid dispatching
     // a redundant selection event, so return early.
     if (targetElement != prevElement) {
@@ -1107,16 +1119,30 @@ this.WidgetMethods = {
    * Selects the element with the specified value in this container.
    * @param string aValue
    */
   set selectedValue(aValue) {
     this.selectedItem = this._itemsByValue.get(aValue);
   },
 
   /**
+   * Deselects and re-selects an item in this container.
+   *
+   * Useful when you want a "select" event to be emitted, even though
+   * the specified item was already selected.
+   *
+   * @param Item | function aItem
+   * @see `set selectedItem`
+   */
+  forceSelect: function(aItem) {
+    this.selectedItem = null;
+    this.selectedItem = aItem;
+  },
+
+  /**
    * Specifies if this container should try to keep the selected item visible.
    * (For example, when new items are added the selection is brought into view).
    */
   maintainSelectionVisible: true,
 
   /**
    * Specifies if "select" events dispatched from the elements in this container
    * when their respective items are selected should be suppressed or not.
--- a/browser/locales/en-US/chrome/browser/devtools/profiler.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/profiler.dtd
@@ -1,11 +1,49 @@
 <!-- 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/. -->
 
 <!-- LOCALIZATION NOTE : FILE This file contains the Profiler strings -->
+<!-- LOCALIZATION NOTE : FILE Do not translate commandkey -->
 
 <!-- LOCALIZATION NOTE : FILE The correct localization of this file might be to
   - keep it in English, or another language commonly spoken among web developers.
   - You want to make that choice consistent across the developer tools.
   - A good criteria is the language in which you'd find the best
   - documentation on web development on the web. -->
+
+<!-- LOCALIZATION NOTE (profilerUI.emptyNotice1/2): This is the label shown
+  -  in the call list view when empty. -->
+<!ENTITY profilerUI.emptyNotice1    "Click on the">
+<!ENTITY profilerUI.emptyNotice2    "button to start recording JavaScript function calls.">
+
+<!-- LOCALIZATION NOTE (profilerUI.stopNotice1/2): This is the label shown
+  -  in the call list view while recording a profile. -->
+<!ENTITY profilerUI.stopNotice1    "Click on the">
+<!ENTITY profilerUI.stopNotice2    "button again to stop profiling.">
+
+<!-- LOCALIZATION NOTE (profilerUI.loadingNotice): This is the label shown
+  -  in the call list view while loading a profile. -->
+<!ENTITY profilerUI.loadingNotice "Loading…">
+
+<!-- LOCALIZATION NOTE (profilerUI.recordButton): This string is displayed
+  -  on a button that starts a new profile. -->
+<!ENTITY profilerUI.recordButton.tooltip "Record JavaScript function calls.">
+
+<!-- LOCALIZATION NOTE (profilerUI.importButton): This string is displayed
+  -  on a button that opens a dialog to import a saved profile data file. -->
+<!ENTITY profilerUI.importButton "Import…">
+
+<!-- LOCALIZATION NOTE (profilerUI.clearButton): This string is displayed
+  -  on a button that remvoes all the recordings. -->
+<!ENTITY profilerUI.clearButton "Clear">
+
+<!-- LOCALIZATION NOTE (profilerUI.table.*): These strings are displayed
+  -  in the call tree headers for a recording. -->
+<!ENTITY profilerUI.table.duration    "Time (ms)">
+<!ENTITY profilerUI.table.percentage  "Cost">
+<!ENTITY profilerUI.table.invocations "Calls">
+<!ENTITY profilerUI.table.function    "Function">
+
+<!-- LOCALIZATION NOTE (profilerUI.newtab.tooltiptext): The tooltiptext shown
+  -  on the "+" (new tab) button for a profile when a selection is available. -->
+<!ENTITY profilerUI.newtab.tooltiptext "Add new tab from selection">
--- a/browser/locales/en-US/chrome/browser/devtools/profiler.properties
+++ b/browser/locales/en-US/chrome/browser/devtools/profiler.properties
@@ -8,23 +8,97 @@
 # English, or another language commonly spoken among web developers.
 # You want to make that choice consistent across the developer tools.
 # A good criteria is the language in which you'd find the best
 # documentation on web development on the web.
 
 # LOCALIZATION NOTE (profiler.label):
 # This string is displayed in the title of the tab when the profiler is
 # displayed inside the developer tools window and in the Developer Tools Menu.
-profiler.label=Profiler
+profiler.label2=Performance
 
 # LOCALIZATION NOTE (profiler.panelLabel):
 # This is used as the label for the toolbox panel.
-profiler.panelLabel=Profiler Panel
+profiler.panelLabel2=Performance Panel
 
 # LOCALIZATION NOTE (profiler2.commandkey, profiler.accesskey)
 # Used for the menuitem in the tool menu
 profiler.commandkey2=VK_F5
 profiler.accesskey=P
 
 # LOCALIZATION NOTE (profiler.tooltip2):
 # This string is displayed in the tooltip of the tab when the profiler is
 # displayed inside the developer tools window.
 profiler.tooltip2=JavaScript Profiler
+
+# LOCALIZATION NOTE (noRecordingsText): The text to display in the
+# recordings menu when there are no recorded profiles yet.
+noRecordingsText=There are no profiles yet.
+
+# LOCALIZATION NOTE (recordingsList.itemLabel):
+# This string is displayed in the recordings list of the Profiler,
+# identifying a set of function calls.
+recordingsList.itemLabel=Recording #%S
+
+# LOCALIZATION NOTE (recordingsList.recordingLabel):
+# This string is displayed in the recordings list of the Profiler,
+# for an item that has not finished recording.
+recordingsList.recordingLabel=In progress…
+
+# LOCALIZATION NOTE (recordingsList.durationLabel):
+# This string is displayed in the recordings list of the Profiler,
+# for an item that has finished recording.
+recordingsList.durationLabel=%S ms
+
+# LOCALIZATION NOTE (recordingsList.saveLabel):
+# This string is displayed in the recordings list of the Profiler,
+# for saving an item to disk.
+recordingsList.saveLabel=Save
+
+# LOCALIZATION NOTE (profile.tab):
+# This string is displayed in the profile view for a tab, after the
+# recording has finished, as the recording 'start → stop' range in milliseconds.
+profile.tab=%1$S ms → %2$S ms
+
+# LOCALIZATION NOTE (graphs.fps):
+# This string is displayed in the framerate graph of the Profiler,
+# as the unit used to measure frames per second. This label should be kept
+# AS SHORT AS POSSIBLE so it doesn't obstruct important parts of the graph.
+graphs.fps=fps
+
+# LOCALIZATION NOTE (category.*):
+# These strings are displayed in the categories graph of the Profiler,
+# as the legend for each block in every bar. These labels should be kept
+# AS SHORT AS POSSIBLE so they don't obstruct important parts of the graph.
+category.other=Gecko
+category.css=Styles
+category.js=JIT
+category.gc=GC
+category.network=Network
+category.graphics=Graphics
+category.storage=Storage
+category.events=Input & Events
+
+# LOCALIZATION NOTE (table.root):
+# This string is displayed in the call tree for the root node.
+table.root=(root)
+
+# LOCALIZATION NOTE (table.url.tooltiptext):
+# This string is displayed in the call tree as the tooltip text for the url
+# labels which, when clicked, jump to the debugger.
+table.url.tooltiptext=View source in Debugger
+
+# LOCALIZATION NOTE (table.zoom.tooltiptext):
+# This string is displayed in the call tree as the tooltip text for the 'zoom'
+# buttons (small magnifying glass icons) which spawn a new tab.
+table.zoom.tooltiptext=Inspect frame in new tab
+
+# LOCALIZATION NOTE (recordingsList.saveDialogTitle):
+# This string is displayed as a title for saving a recording to disk.
+recordingsList.saveDialogTitle=Save profile…
+
+# LOCALIZATION NOTE (recordingsList.saveDialogJSONFilter):
+# This string is displayed as a filter for saving a recording to disk.
+recordingsList.saveDialogJSONFilter=JSON Files
+
+# LOCALIZATION NOTE (recordingsList.saveDialogAllFilter):
+# This string is displayed as a filter for saving a recording to disk.
+recordingsList.saveDialogAllFilter=All Files
--- a/browser/themes/linux/devtools/profiler.css
+++ b/browser/themes/linux/devtools/profiler.css
@@ -1,5 +1,9 @@
 /* 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/. */
 
-%include ../../shared/devtools/profiler.inc.css
\ No newline at end of file
+%include ../../shared/devtools/profiler.inc.css
+
+#profile-content tab label {
+  margin-bottom: 4px;
+}
--- a/browser/themes/linux/jar.mn
+++ b/browser/themes/linux/jar.mn
@@ -202,16 +202,20 @@ browser.jar:
   skin/classic/browser/translation-16.png             (../shared/translation/translation-16.png)
   skin/classic/browser/translation-16@2x.png          (../shared/translation/translation-16@2x.png)
 * skin/classic/browser/devtools/common.css            (../shared/devtools/common.css)
 * skin/classic/browser/devtools/dark-theme.css        (../shared/devtools/dark-theme.css)
 * skin/classic/browser/devtools/light-theme.css       (../shared/devtools/light-theme.css)
   skin/classic/browser/devtools/filters.svg           (../shared/devtools/filters.svg)
   skin/classic/browser/devtools/controls.png          (../shared/devtools/images/controls.png)
   skin/classic/browser/devtools/controls@2x.png       (../shared/devtools/images/controls@2x.png)
+  skin/classic/browser/devtools/newtab.png             (../shared/devtools/images/newtab.png)
+  skin/classic/browser/devtools/newtab@2x.png          (../shared/devtools/images/newtab@2x.png)
+  skin/classic/browser/devtools/newtab-inverted.png    (../shared/devtools/images/newtab-inverted.png)
+  skin/classic/browser/devtools/newtab-inverted@2x.png (../shared/devtools/images/newtab-inverted@2x.png)
 * skin/classic/browser/devtools/widgets.css           (devtools/widgets.css)
   skin/classic/browser/devtools/filetype-dir-close.svg        (../shared/devtools/images/filetypes/dir-close.svg)
   skin/classic/browser/devtools/filetype-dir-open.svg         (../shared/devtools/images/filetypes/dir-open.svg)
   skin/classic/browser/devtools/filetype-globe.svg            (../shared/devtools/images/filetypes/globe.svg)
   skin/classic/browser/devtools/commandline-icon.png          (../shared/devtools/images/commandline-icon.png)
   skin/classic/browser/devtools/commandline-icon@2x.png       (../shared/devtools/images/commandline-icon@2x.png)
   skin/classic/browser/devtools/command-paintflashing.png     (../shared/devtools/images/command-paintflashing.png)
   skin/classic/browser/devtools/command-paintflashing@2x.png  (../shared/devtools/images/command-paintflashing@2x.png)
--- a/browser/themes/osx/devtools/profiler.css
+++ b/browser/themes/osx/devtools/profiler.css
@@ -1,5 +1,6 @@
 /* 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/. */
 
+%include ../shared.inc
 %include ../../shared/devtools/profiler.inc.css
--- a/browser/themes/osx/jar.mn
+++ b/browser/themes/osx/jar.mn
@@ -323,16 +323,20 @@ browser.jar:
   skin/classic/browser/translation-16.png                   (../shared/translation/translation-16.png)
   skin/classic/browser/translation-16@2x.png                (../shared/translation/translation-16@2x.png)
 * skin/classic/browser/devtools/common.css                  (../shared/devtools/common.css)
 * skin/classic/browser/devtools/dark-theme.css              (../shared/devtools/dark-theme.css)
 * skin/classic/browser/devtools/light-theme.css             (../shared/devtools/light-theme.css)
   skin/classic/browser/devtools/filters.svg                 (../shared/devtools/filters.svg)
   skin/classic/browser/devtools/controls.png                (../shared/devtools/images/controls.png)
   skin/classic/browser/devtools/controls@2x.png             (../shared/devtools/images/controls@2x.png)
+  skin/classic/browser/devtools/newtab.png                  (../shared/devtools/images/newtab.png)
+  skin/classic/browser/devtools/newtab@2x.png               (../shared/devtools/images/newtab@2x.png)
+  skin/classic/browser/devtools/newtab-inverted.png         (../shared/devtools/images/newtab-inverted.png)
+  skin/classic/browser/devtools/newtab-inverted@2x.png      (../shared/devtools/images/newtab-inverted@2x.png)
 * skin/classic/browser/devtools/widgets.css                 (devtools/widgets.css)
   skin/classic/browser/devtools/filetype-dir-close.svg      (../shared/devtools/images/filetypes/dir-close.svg)
   skin/classic/browser/devtools/filetype-dir-open.svg       (../shared/devtools/images/filetypes/dir-open.svg)
   skin/classic/browser/devtools/filetype-globe.svg          (../shared/devtools/images/filetypes/globe.svg)
   skin/classic/browser/devtools/commandline-icon.png        (../shared/devtools/images/commandline-icon.png)
   skin/classic/browser/devtools/commandline-icon@2x.png     (../shared/devtools/images/commandline-icon@2x.png)
   skin/classic/browser/devtools/command-paintflashing.png     (../shared/devtools/images/command-paintflashing.png)
   skin/classic/browser/devtools/command-paintflashing@2x.png  (../shared/devtools/images/command-paintflashing@2x.png)
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2d29c2cbea688fdf44ba7d1617bd9edd8a04a4c4
GIT binary patch
literal 470
zc$@*&0V)28P)<h;3K|Lk000e1NJLTq001@s000yS1^@s6#v#t&0004<Nkl<ZSi|j>
zze)o^5XRF8d4v=~g5Vz%q9WJjB~l(@CHMq+iCBmr7NU)fosG2!Dp)8&1k+iV1cOmg
ziKpO+jPo6Hu*GrfIY$C=13&l}mM=SOzTL~^vdSu}VOE|g2>D_AjEpYinS!9%Pp(Kr
zuK-%<6O_h>hbgXH_ynLegE_pHE`V10d;^US597=qcm$v|gH?DlEdZ_b`2rdr9;Pz8
z-zNb5mn@WoDsScnKpTBqR-$}9K@uM$QzfA~v-d&(+UQ#|SCqeAM`$zGLfhzfEm?t&
z9}XjY2xiZZ^FfZ5kKbwO$O~9J^k{77Apkn;V~s6yW&W`yb0svGOQB)(n^<e{@t8M*
zzF_veI3M0@+1Y)h>Fr*?<W`SLoA&|GVIOO-Hul6Am#GPL{_%4FZS-B+iADH?x9hKc
za0-me)P(x@#vK7@qwm^IEGipkAcc%Q18v5p);k1}W-RJ9I{_)&icdg$D^9H55=eS0
zM!i3$Acc1<98C3&&8@WvXy36gE4EAx#K*{-tNrr$x8I6cWi_Ds0Rs}Qp0*qeUjP6A
M07*qoM6N<$f{_}|djJ3c
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..6feba0e83eaad385ae512fc611187c10dec6e9d4
GIT binary patch
literal 866
zc$@)Z1D*VdP)<h;3K|Lk000e1NJLTq003+N001Zm1^@s68?PU?0009iNkl<ZcmeI2
z!D|yy5XRpowv7c7FNGXD<bUwLP;a6Lg5ZD9KcJp0p6$(xB7!JV^d@-HcqxjC^$;iu
z8;g)?1+AcNy4m-f4;H&V_U#r&7#rq;AAF4CWXQ+LPOxDPlvEm+2By-$R2rBDrqaMv
z8kh#AI#_|Vh%V6s@Oh1)>Dg!I*zp$8C3*lpubIE4!THOiqt3bK#A5)DnvoOs84)pn
zm)_^%gSFB5)wLMFqbB);b%==vYEtSD&O>yc-OQOJvj)JUCK*_p*de|E_`D{iwjY@E
zKI@-S^I}90@Tf@!Hb+<w)TA`$2PVCbv(6MjP5Ld-6~cm-mh%vm%iJG8(t+6`8ki?M
zRhb3w8dyN`VM#n;>4%psqD%D3pq2$9D})7)S}%Zi7qRawp9R8t@b}s8o#lbgo}?KC
zWetGm?$g-EUDGEX23SU}gaz=ZRrL2UlX+)t5*q>T=6g5*k6K0lCU>6;x1WyA-Rj3>
zoW0q<pNuK6i3J88wNO81VJ0WAP9Df^fHWrqxv%T*Fz+n;k74aIPObSlE(d=$N%#Ia
zgs<zzOio~J;xqBF0_#!Li&<^vot4&LJ<=Lj`@D*Om}l?42f>q7q^cLQFq8Yz(IjR9
zG`$y(`u-_<OPoJs%#qosl*3J98V*mF1@Nea`Z0_BmiR4-qE+HefFbonL<ZndtLR_F
zOd_oMeU^wD0q*&CA!-%<OWb|dFP&I?d->!_8S#}<*JmU5`{{y5E!2-$n8^ujhu9!q
z2Z&3ausGb0S(sTb<~}Y*e4nP<d$ot>?NcAv*L={E00Y8$48r}Gg_&VZ-99!yngKj>
zpGM7{kq}$NuncP1Caec)!vI@E!uWy`%&3^ZO*jw!KH)nxA-0HN8Tf2RK^0-{qd!gQ
zZ;A5K!1UL8?ziIrym(+^Vuome+@mI?F%fx)?lX@Yi;gb<kD6p)xQiYULjaGOkrVb=
zChh=u>3s%QPcOc|c4h^@qb3=ce}{1I8l(I01FOG7!gmB;x(_d~nmY|1H6z4-wU2){
shVLv+U@HBu+A0l915;^WDh*8a3#yD0{Rz(vssI2007*qoM6N<$f;Om`IsgCw
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..32e42b04cce15cb654b44bc882b264bec5efa963
GIT binary patch
literal 568
zc$@(^0>}M{P)<h;3K|Lk000e1NJLTq001@s000yS1^@s6#v#t&00061Nkl<Zc-rmP
z&npCB7zgm3ooQRvVYe);a!__J%f-RT!ByKv5mFSZ<YFT~qSTh8b`SQjNjWLIaiElg
zTqp-gE|OBNF8+zf^Q|YnZ8W3zEmo#J_4K^&wDWD>d1uB)M88Jv(wtfeW-&jrnImlJ
zS+yF>k{|(A$3h@=K*lVMU_TblW<l1#BCJV7(33H{7K1qwAj(=<9HfrQnDgymd{h9n
z4n7{iV=#-^N~jo1u&(;<3;}BC#guS1v%Cgd0RzDf!DXI$PX(1wN!G(s;ejFnYUw4F
za6viO@^V27U?A85cnIca0cH6mBtSo#jE?OSphi!|63Vg+Fu#`zngs*F_P|3hTXwt%
zYn3IJk|0YhnPURf(o12E%#Fyo_E#6ME%Izs<dy!OhamUtT3oRLpjP_#p9FJQNOrz>
zxMzkM`_>6!!zF58z6I1DeayjJIVZ%N&n{?@WyAy<1k_4@(c`AGBZ_QO6qSC(Em(6Z
zM{Uzb1o63Z>ezS&)E|A!aSH~$D*o{iKwZV>BCWzd4%L0PrFi#BYZot+KY60v@&lDS
zR&NMUqlYofab1elunfV#JyrnS%_EsTBQUXf41s}QTcEq$cV<rsOl<d4U?A8I=suw1
z<0S&^2ef)mX-b3cE7j8*%|rDo)&EQI&-k;SEr0$)Q{OvBF!wjVOs#+b0000<MNUMn
GLSTZZM*$T8
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ffde5f05086c366c5873e0d5fa383f45982ae002
GIT binary patch
literal 1742
zc$@*w1~K`GP)<h;3K|Lk000e1NJLTq003+N001Zm1^@s68?PU?000J+Nkl<Zc-rlo
z-)kII6vyw*?Cj3|NKB1_Z4LO~iwb?QFTU+R;ftV%V8MV73N8Aew4x9F1y(5v72=Bq
zjMN_w4G3bzh))qo)nFy4wG}0eZMvKNF*`pzXL4tIa&zy_taGzjbOyfc>76roF8j&%
z-g|CRBM1VPX}gYoF`6=1cJ0KKOQ?ab448gMh7ijn%guo8qN7AWCYK6aVrUsKy|84E
z@`_jmE(12?=}?4)vbgdJXqm%?42mUy6$gza0ha;Oa~Z^8CIMC8Dsn~twg@!(Z04{b
zPluwjC@cz1bXwwRF)c8B`6OsB*CcNn-nW&@^`sgP%@L((lVM6A*<58oQF~G0w!WK?
zw7|xy)#?Z1<Kyq3FvhlB{AnKp2=kyhu6d2)$;H5;ZYps&mE+Gn_Tz~c4t<Zp7~A;0
zlHj_2&~n-wRxB2;RVtOAP#6!}VG_r$f_~##)ifTl&^7zGDsmEnshSh}n03#Cp)kfa
z0<GM~ZKkZaPQ8X1hH<M<D6F9{9)UGw1GLVyu>;OB70$AWv#MNU`H3l3oO(DE#@zAI
z{m5yy!fB@XejS?*9}Y`B6viU}%on6{a}l>@m8iQyhn4PN*2Ml$7!z0(1u)f`re~Mv
z`@TyxBEUQ@e+L{U0<$E*B2^9WglTNb*z`wL$f3Y2#g<>6!@ybSFqiAU=CE9XlZ>dg
zihxm<l?^k;Ow$O3F}+9fZPHKQlaS0|>FO=9*0M!#29yrB9o7RzLD`BLO<JGD0Tb&Q
zQIp^*CxV4>Tz<-3+qUt*JA2~yx&&C4k8!U;NygJ}D=&jqqR{fiO3z^!3$RKF3Tk7J
zDRB(8>gR0RJ|$DRT%Lwn=N7=~qA<obRX+BWvM$0p>pbXPg^oc^lK^XCTc7;%1B^!o
z%{qPL@$X)Ld3pgf51IqbfX;$Wo<05QD~0j9d%!TpHoV7kX81Gs>;m@1p(ziBV;yFi
z<_c`t7iF?6YZ<okHzHsdW1A`;`_kaBtDwsYT>&)+um*2a<zG!W9@ScA*mq8`>b(b~
zs_c7!6{>q!9^Hk&AjURTKK8}rzjk4X*D&}g{2RXLKV<?yYcQ>}$duT^HdQ|MrC}|p
zgVq$n+N(o=b$FX9zn*q5_Fea}@%tZ<s=EInR;cWO^~(%}F}A7lv9GjeiH_%aR=3-=
zWpZ6N5BL4ZVHjhZDj)m8K2v-@4xcUx3gNu~<Z=ZG@{^1wAfEx<g$A7l3tBg%Von2I
z;Po7aF}A7lu`gkV8wl@?1y*mj+Y60G<C;wMdc6&RiV-l3u}zhaeWm=Vu7itbL0>9#
ziLU{X!z^s;lRrDactlv?rsk73o`3P&v$dmdK2tk>>QL>>YfshQKK5j7=A|cU?;n2j
z;GA1uh4sq}g)z3_J<P#ed<LJzKCmycX_7&UcNm{it}C<-t|<|RmE!U>I4rJzn*g&l
zj3-dQ3SFExm}`M`;IoT@IQ#@dITn0+t((0~2gVk^M{{|MeYj+HkU<NQ)M1N#v;?3G
zQioxGOEJ%+y|(lR>tav*-8ljb<0YCSYLGJm0xbg8{b-qeJpVKM^y05<{+r*~XN!NZ
z&zJsU7nfJrSC`k=*R0#>0mB&E@E+!1E<S_LVjtKScNhgWki(Lkba>>X!yAFZIUv9S
z4SlJm!w8zg)dsb=n*89o$pW`mIAgr!-tGK0>Bkic|1HpT9RAW%j!`-3(0TY9NT$V}
z!?-#GSd*v`o^(V<Q=2Cp4Iw82)*=1L9F}-Wj60h0$&@$>XNjI5ozC?oz#Iv%4GyMZ
z807HRFd|?L!DSIJM*_^(IwfMq<ku_S|G;6M08Iugf>K9Q85|b>i-bD4F9YTZ@6hTn
zd_fUlRZ;v*%-Ka)4mr;&X&euo4g+Bg6J6G%E^A%C!fyWZZ77TdcQyOC%M;$IWeuyt
zEVu$PxmI+X9;B?(E`WaEx}b49LCh9Eg4Ur^<J^hq!*4x(@CXWHVJhq>oaIa2r)5eM
z>!S7DbQo;Yw~dFl>E49~3D>lC$zmO<s6D}&707nEI@U1Vb=II|#TJR<iTi(Gnx{*T
z6uu$6_m<e)>?QUg3S$A1vflc)XNlRBe4`U6+~x>VpQG5iC)yxo!1TC_DT-=Zt92#L
z@`T-NtjjWBdcnm(bK_1jGKUQhRv^@W6q+pdlWa0FU^@>jkQDDSNXo5y25k7#q6(ZZ
kxt|zT25fkaQ)%GqALCVo4dAKT@c;k-07*qoM6N<$g0bmK6aWAK
--- a/browser/themes/shared/devtools/profiler.inc.css
+++ b/browser/themes/shared/devtools/profiler.inc.css
@@ -1,4 +1,450 @@
 /* vim:set ts=2 sw=2 sts=2 et: */
 /* 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/. */
+ * 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/. */
+
+/* Reload and waiting notices */
+
+.notice-container {
+  margin-top: -50vh;
+  font-size: 120%;
+}
+
+.theme-dark .notice-container {
+  background: #343c45; /* Toolbars */
+  color: #f5f7fa; /* Light foreground text */
+}
+
+.theme-light .notice-container {
+  background: #f0f1f2; /* Toolbars */
+  color: #585959; /* Grey foreground text */
+}
+
+#empty-notice button,
+#recording-notice button {
+  min-width: 30px;
+  min-height: 28px;
+  margin: 0;
+  list-style-image: url(profiler-stopwatch.svg);
+}
+
+#empty-notice button[checked],
+#recording-notice button[checked] {
+  list-style-image: url(profiler-stopwatch-checked.svg);
+}
+
+#empty-notice button .button-text,
+#recording-notice button .button-text {
+  display: none;
+}
+
+.theme-dark #loading-notice {
+  font-size: 250%;
+  color: rgba(255,255,255,0.2);
+}
+
+.theme-light #loading-notice {
+  font-size: 250%;
+  color: rgba(0,0,0,0.2);
+}
+
+/* Recordings pane */
+
+#recordings-pane > tabs {
+  -moz-border-end: 1px solid;
+}
+
+#recordings-pane .devtools-toolbar {
+  -moz-border-end: 1px solid;
+}
+
+.theme-dark #recordings-pane > tabs,
+.theme-dark #recordings-pane .devtools-toolbar {
+  -moz-border-end-color: #000; /* Splitters */
+}
+
+.theme-light #recordings-pane > tabs,
+.theme-light #recordings-pane .devtools-toolbar {
+  -moz-border-end-color: #aaa; /* Splitters */
+}
+
+#record-button {
+  list-style-image: url(profiler-stopwatch.svg);
+}
+
+#record-button[checked] {
+  list-style-image: url(profiler-stopwatch-checked.svg);
+}
+
+#record-button[locked] {
+  pointer-events: none;
+}
+
+/* Recording items */
+
+.recording-item {
+  padding: 4px;
+}
+
+.recording-item-title {
+  font-size: 110%;
+}
+
+.recording-item-footer {
+  padding-top: 4px;
+  font-size: 90%;
+}
+
+.recording-item-save {
+  text-decoration: underline;
+  cursor: pointer;
+}
+
+.theme-dark .recording-item-duration,
+.theme-dark .recording-item-save {
+  color: #b6babf; /* Foreground (Text) - Grey */
+}
+
+.theme-light .recording-item-duration,
+.theme-light .recording-item-save {
+  color: #585959; /* Foreground (Text) - Grey */
+}
+
+#recordings-list .selected label {
+  /* Text inside a selected item should not be custom colored. */
+  color: inherit !important;
+}
+
+/* Profile pane */
+
+#profile-content tabs {
+  -moz-box-align: stretch;
+  height: 24px;
+  font: inherit;
+}
+
+#profile-content tab {
+  -moz-box-flex: 0;
+  background-color: transparent;
+  border: none;
+  border-radius: 0;
+  padding: 0;
+  text-shadow: none;
+  transition-duration: 0.25s;
+  transition-timing-function: ease-in-out;
+  transition-property: opacity, transform;
+}
+
+.theme-dark #profile-content tab {
+  color: #8fa1b2; /* Body Text */
+}
+
+.theme-light #profile-content tab {
+  color: #18191a; /* Body Text */
+}
+
+#profile-content tab:not([selected]) {
+  cursor: pointer;
+}
+
+#profile-content tab[covered] {
+  opacity: 0;
+  transform: translateY(100%);
+}
+
+.theme-dark #profile-content tab {
+  -moz-appearance: none;
+  -moz-border-end: 1px solid #000; /* Splitters */
+}
+
+.theme-light #profile-content tab {
+  -moz-appearance: none;
+  -moz-border-end: 1px solid #aaa; /* Splitters */
+}
+
+.theme-dark #profile-content tab:hover {
+  background-color: rgba(0,0,0,0.3);
+}
+
+.theme-light #profile-content tab:hover {
+  background-color: rgba(255,255,255,0.8);
+}
+
+.theme-dark #profile-content tab[selected] {
+  background-color: #1d4f73; /* Select Highlight Blue */
+  color: #f5f7fa; /* Light foreground text */
+}
+
+.theme-light #profile-content tab[selected] {
+  background-color: #4c9ed9; /* Select Highlight Blue */
+  color: #f5f7fa; /* Light foreground text */
+}
+
+#profile-content tabpanel {
+  -moz-box-orient: vertical;
+  transform: translateZ(1px); /* Make sure the tabpanel appears above the tab */
+}
+
+#profile-newtab-button {
+  -moz-appearance: none;
+  background-color: transparent;
+  background-position: 4px 2px;
+  background-size: 54px 20px;
+  min-width: 26px;
+  margin: 0;
+  border: none;
+  cursor: pointer;
+}
+
+.theme-dark #profile-newtab-button {
+  background-color: rgba(112,191,83,0.2);
+}
+
+.theme-light #profile-newtab-button {
+  background-color: rgba(44,187,15,0.2);
+}
+
+.theme-dark #profile-newtab-button {
+  background-image: url(newtab-inverted.png);
+}
+
+.theme-light #profile-newtab-button {
+  background-image: url(newtab.png);
+}
+
+@media (min-resolution: 2dppx) {
+  .theme-dark #profile-newtab-button {
+    background-image: url(newtab-inverted@2x.png);
+  }
+
+  .theme-light #profile-newtab-button {
+    background-image: url(newtab@2x.png);
+  }
+}
+
+#profile-newtab-button:hover {
+  background-position: 40px 2px;
+}
+
+#profile-newtab-button:hover:active {
+  background-position: 22px 2px;
+}
+
+/* Profile call tree */
+
+.theme-dark .call-tree-headers-container {
+  border-top: 1px solid #000;
+}
+
+.theme-light .call-tree-headers-container {
+  border-top: 1px solid #aaa;
+}
+
+.call-tree-cells-container {
+  /* Hack: force hardware acceleration */
+  transform: translateZ(1px);
+  overflow: auto;
+}
+
+.call-tree-cells-container[categories-hidden] .call-tree-category {
+  display: none;
+}
+
+.call-tree-header[type="duration"],
+.call-tree-cell[type="duration"] {
+  width: 7em;
+}
+
+.call-tree-header[type="percentage"],
+.call-tree-cell[type="percentage"] {
+  width: 5em;
+}
+
+.call-tree-header[type="invocations"],
+.call-tree-cell[type="invocations"] {
+  width: 5em;
+}
+
+.call-tree-header[type="function"],
+.call-tree-cell[type="function"] {
+  -moz-box-flex: 1;
+}
+
+.call-tree-header,
+.call-tree-cell {
+  -moz-box-align: center;
+  overflow: hidden;
+  padding: 1px 4px;
+}
+
+.call-tree-header:not(:last-child),
+.call-tree-cell:not(:last-child) {
+  -moz-border-end: 1px solid;
+}
+
+.theme-dark .call-tree-header,
+.theme-dark .call-tree-cell {
+  -moz-border-end-color: rgba(255,255,255,0.15);
+  color: #8fa1b2; /* Body Text */
+}
+
+.theme-light .call-tree-header,
+.theme-light .call-tree-cell {
+  -moz-border-end-color: rgba(0,0,0,0.15);
+  color: #18191a; /* Body Text */
+}
+
+.call-tree-header:not(:last-child) {
+  text-align: center;
+}
+
+.call-tree-cell:not(:last-child) {
+  text-align: end;
+}
+
+.theme-dark .call-tree-header {
+  background-color: #252c33; /* Tab Toolbar */
+}
+
+.theme-light .call-tree-header {
+  background-color: #ebeced; /* Tab Toolbar */
+}
+
+.theme-dark .call-tree-item:last-child:not(:focus) {
+  border-bottom: 1px solid rgba(255,255,255,0.15);
+}
+
+.theme-light .call-tree-item:last-child:not(:focus) {
+  border-bottom: 1px solid rgba(0,0,0,0.15);
+}
+
+.theme-dark .call-tree-item:nth-child(2n) {
+  background-color: rgba(29,79,115,0.15);
+}
+
+.theme-light .call-tree-item:nth-child(2n) {
+  background-color: rgba(76,158,217,0.1);
+}
+
+.theme-dark .call-tree-item:hover {
+  background-color: rgba(29,79,115,0.25);
+}
+
+.theme-light .call-tree-item:hover {
+  background-color: rgba(76,158,217,0.2);
+}
+
+.theme-dark .call-tree-item:focus {
+  background-color: #1d4f73; /* Select Highlight Blue */
+}
+
+.theme-light .call-tree-item:focus {
+  background-color: #4c9ed9; /* Select Highlight Blue */
+}
+
+.call-tree-item:focus label {
+  color: #f5f7fa !important; /* Light foreground text */
+}
+
+.theme-dark .call-tree-item:focus .call-tree-cell {
+  -moz-border-end-color: rgba(0,0,0,0.3);
+}
+
+.theme-light .call-tree-item:focus .call-tree-cell {
+  -moz-border-end-color: rgba(255,255,255,0.5);
+}
+
+.call-tree-item:not([origin="content"]) .call-tree-name,
+.call-tree-item:not([origin="content"]) .call-tree-url,
+.call-tree-item:not([origin="content"]) .call-tree-line {
+  /* Style chrome and non-JS nodes differently. */
+  opacity: 0.6;
+}
+
+.call-tree-url {
+  -moz-margin-start: 4px !important;
+  cursor: pointer;
+}
+
+.call-tree-url:hover {
+  text-decoration: underline;
+}
+
+.theme-dark .call-tree-url {
+  color: #46afe3;
+}
+
+.theme-light .call-tree-url {
+  color: #0088cc;
+}
+
+.theme-dark .call-tree-line {
+  color: #d96629;
+}
+
+.theme-light .call-tree-line {
+  color: #f13c00;
+}
+
+.call-tree-host {
+  -moz-margin-start: 8px !important;
+  font-size: 90%;
+}
+
+.theme-dark .call-tree-host {
+  color: #8fa1b2;
+}
+
+.theme-light .call-tree-host {
+  color: #8fa1b2;
+}
+
+.call-tree-url[value=""],
+.call-tree-line[value=""],
+.call-tree-host[value=""] {
+  display: none;
+}
+
+.call-tree-zoom {
+  -moz-appearance: none;
+  background-color: transparent;
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: 11px;
+  min-width: 11px;
+  -moz-margin-start: 8px !important;
+  cursor: zoom-in;
+  opacity: 0;
+}
+
+.theme-dark .call-tree-zoom {
+  background-image: url(magnifying-glass.png);
+}
+
+.theme-light .call-tree-zoom {
+  background-image: url(magnifying-glass-light.png);
+}
+
+@media (min-resolution: 2dppx) {
+  .theme-dark .call-tree-zoom {
+    background-image: url(magnifying-glass@2x.png);
+  }
+
+  .theme-light .call-tree-zoom {
+    background-image: url(magnifying-glass-light@2x.png);
+  }
+}
+
+.call-tree-item:hover .call-tree-zoom {
+  transition: opacity 0.3s ease-in;
+  opacity: 1;
+}
+
+.call-tree-item:hover .call-tree-zoom:hover {
+  opacity: 0;
+}
+
+.call-tree-category {
+  transform: scale(0.75);
+  transform-origin: center right;
+}
--- a/browser/themes/shared/devtools/toolbars.inc.css
+++ b/browser/themes/shared/devtools/toolbars.inc.css
@@ -820,16 +820,17 @@
 .theme-light .command-button-invertable:active > image,
 .theme-light .devtools-closebutton > image,
 .theme-light .devtools-toolbarbutton > image,
 .theme-light .devtools-option-toolbarbutton > image,
 .theme-light #breadcrumb-separator-normal,
 .theme-light .scrollbutton-up > .toolbarbutton-icon,
 .theme-light .scrollbutton-down > .toolbarbutton-icon,
 .theme-light #black-boxed-message-button .button-icon,
+.theme-light #profiling-notice-button .button-icon,
 .theme-light #canvas-debugging-empty-notice-button .button-icon,
 .theme-light #requests-menu-perf-notice-button .button-icon,
 .theme-light #requests-menu-network-summary-button .button-icon,
 .theme-light .event-tooltip-debugger-icon {
   filter: url(filters.svg#invert);
 }
 
 /* Since selected backgrounds are blue, we want to use the normal
--- a/browser/themes/windows/jar.mn
+++ b/browser/themes/windows/jar.mn
@@ -237,16 +237,20 @@ browser.jar:
         skin/classic/browser/translation-16.png                     (../shared/translation/translation-16.png)
         skin/classic/browser/translation-16@2x.png                  (../shared/translation/translation-16@2x.png)
 *       skin/classic/browser/devtools/common.css                    (../shared/devtools/common.css)
 *       skin/classic/browser/devtools/dark-theme.css                (../shared/devtools/dark-theme.css)
 *       skin/classic/browser/devtools/light-theme.css               (../shared/devtools/light-theme.css)
         skin/classic/browser/devtools/filters.svg                   (../shared/devtools/filters.svg)
         skin/classic/browser/devtools/controls.png                  (../shared/devtools/images/controls.png)
         skin/classic/browser/devtools/controls@2x.png               (../shared/devtools/images/controls@2x.png)
+        skin/classic/browser/devtools/newtab.png                    (../shared/devtools/images/newtab.png)
+        skin/classic/browser/devtools/newtab@2x.png                 (../shared/devtools/images/newtab@2x.png)
+        skin/classic/browser/devtools/newtab-inverted.png           (../shared/devtools/images/newtab-inverted.png)
+        skin/classic/browser/devtools/newtab-inverted@2x.png        (../shared/devtools/images/newtab-inverted@2x.png)
 *       skin/classic/browser/devtools/widgets.css                   (devtools/widgets.css)
         skin/classic/browser/devtools/filetype-dir-close.svg        (../shared/devtools/images/filetypes/dir-close.svg)
         skin/classic/browser/devtools/filetype-dir-open.svg         (../shared/devtools/images/filetypes/dir-open.svg)
         skin/classic/browser/devtools/filetype-globe.svg            (../shared/devtools/images/filetypes/globe.svg)
         skin/classic/browser/devtools/commandline-icon.png          (../shared/devtools/images/commandline-icon.png)
         skin/classic/browser/devtools/commandline-icon@2x.png          (../shared/devtools/images/commandline-icon@2x.png)
         skin/classic/browser/devtools/alerticon-warning.png         (../shared/devtools/images/alerticon-warning.png)
         skin/classic/browser/devtools/alerticon-warning@2x.png      (../shared/devtools/images/alerticon-warning@2x.png)
@@ -657,16 +661,20 @@ browser.jar:
         skin/classic/aero/browser/translation-16.png                 (../shared/translation/translation-16.png)
         skin/classic/aero/browser/translation-16@2x.png              (../shared/translation/translation-16@2x.png)
 *       skin/classic/aero/browser/devtools/common.css                (../shared/devtools/common.css)
 *       skin/classic/aero/browser/devtools/dark-theme.css            (../shared/devtools/dark-theme.css)
 *       skin/classic/aero/browser/devtools/light-theme.css           (../shared/devtools/light-theme.css)
         skin/classic/aero/browser/devtools/filters.svg               (../shared/devtools/filters.svg)
         skin/classic/aero/browser/devtools/controls.png              (../shared/devtools/images/controls.png)
         skin/classic/aero/browser/devtools/controls@2x.png           (../shared/devtools/images/controls@2x.png)
+        skin/classic/aero/browser/devtools/newtab.png                (../shared/devtools/images/newtab.png)
+        skin/classic/aero/browser/devtools/newtab@2x.png             (../shared/devtools/images/newtab@2x.png)
+        skin/classic/aero/browser/devtools/newtab-inverted.png       (../shared/devtools/images/newtab-inverted.png)
+        skin/classic/aero/browser/devtools/newtab-inverted@2x.png    (../shared/devtools/images/newtab-inverted@2x.png)
 *       skin/classic/aero/browser/devtools/widgets.css               (devtools/widgets.css)
         skin/classic/aero/browser/devtools/filetype-dir-close.svg    (../shared/devtools/images/filetypes/dir-close.svg)
         skin/classic/aero/browser/devtools/filetype-dir-open.svg     (../shared/devtools/images/filetypes/dir-open.svg)
         skin/classic/aero/browser/devtools/filetype-globe.svg        (../shared/devtools/images/filetypes/globe.svg)
         skin/classic/aero/browser/devtools/commandline-icon.png      (../shared/devtools/images/commandline-icon.png)
         skin/classic/aero/browser/devtools/commandline-icon@2x.png      (../shared/devtools/images/commandline-icon@2x.png)
         skin/classic/aero/browser/devtools/command-paintflashing.png    (../shared/devtools/images/command-paintflashing.png)
         skin/classic/aero/browser/devtools/command-paintflashing@2x.png (../shared/devtools/images/command-paintflashing@2x.png)
--- a/js/public/ProfilingStack.h
+++ b/js/public/ProfilingStack.h
@@ -63,16 +63,17 @@ class ProfileEntry
 
         // This ProfileEntry was pushed immediately before calling into asm.js.
         ASMJS = 0x04,
 
         // Mask for removing all flags except the category information.
         CATEGORY_MASK = ~IS_CPP_ENTRY & ~FRAME_LABEL_COPY & ~ASMJS
     };
 
+    // Keep these in sync with browser/devtools/profiler/utils/global.js
     MOZ_BEGIN_NESTED_ENUM_CLASS(Category, uint32_t)
         OTHER    = 0x08,
         CSS      = 0x10,
         JS       = 0x20,
         GC       = 0x40,
         CC       = 0x80,
         NETWORK  = 0x100,
         GRAPHICS = 0x200,
--- a/toolkit/devtools/Loader.jsm
+++ b/toolkit/devtools/Loader.jsm
@@ -45,19 +45,20 @@ let Timer = Cu.import("resource://gre/mo
 let loaderGlobals = {
   isWorker: false,
   reportError: Cu.reportError,
 
   btoa: btoa,
   console: console,
   _Iterator: Iterator,
   loader: {
-    lazyGetter: XPCOMUtils.defineLazyGetter.bind(XPCOMUtils),
-    lazyImporter: XPCOMUtils.defineLazyModuleGetter.bind(XPCOMUtils),
-    lazyServiceGetter: XPCOMUtils.defineLazyServiceGetter.bind(XPCOMUtils)
+    lazyGetter: (...args) => devtools.lazyGetter.apply(devtools, args),
+    lazyImporter: (...args) => devtools.lazyImporter.apply(devtools, args),
+    lazyServiceGetter: (...args) => devtools.lazyServiceGetter.apply(devtools, args),
+    lazyRequireGetter: (...args) => devtools.lazyRequireGetter.apply(devtools, args)
   },
 };
 
 let loaderModules = {
   "Debugger": Debugger,
   "Services": Object.create(Services),
   "Timer": Object.create(Timer),
   "toolkit/loader": loader,
@@ -271,16 +272,19 @@ SrcdirProvider.prototype = {
  * The main devtools API.
  * In addition to a few loader-related details, this object will also include all
  * exports from the main module.  The standard instance of this loader is
  * exported as |devtools| below, but if a fresh copy of the loader is needed,
  * then a new one can also be created.
  */
 this.DevToolsLoader = function DevToolsLoader() {
   this.require = this.require.bind(this);
+  this.lazyGetter = XPCOMUtils.defineLazyGetter.bind(XPCOMUtils);
+  this.lazyImporter = XPCOMUtils.defineLazyModuleGetter.bind(XPCOMUtils);
+  this.lazyServiceGetter = XPCOMUtils.defineLazyServiceGetter.bind(XPCOMUtils);
   this.lazyRequireGetter = this.lazyRequireGetter.bind(this);
 };
 
 DevToolsLoader.prototype = {
   get provider() {
     if (!this._provider) {
       this._chooseProvider();
     }
@@ -305,20 +309,24 @@ DevToolsLoader.prototype = {
    * actually used.
    *
    * @param Object obj
    *    The object to define the property on.
    * @param String property
    *    The property name.
    * @param String module
    *    The module path.
+   * @param Boolean destructure
+   *    Pass true if the property name is a member of the module's exports.
    */
-  lazyRequireGetter: function (obj, property, module) {
+  lazyRequireGetter: function (obj, property, module, destructure) {
     Object.defineProperty(obj, property, {
-      get: () => this.require(module)
+      get: () => destructure
+        ? this.require(module)[property]
+        : this.require(module || property)
     });
   },
 
   /**
    * Add a URI to the loader.
    * @param string id
    *    The module id that can be used within the loader to refer to this module.
    * @param string uri
--- a/toolkit/devtools/server/actors/framerate.js
+++ b/toolkit/devtools/server/actors/framerate.js
@@ -78,16 +78,25 @@ let FramerateActor = exports.FramerateAc
     this._chromeWin.cancelAnimationFrame(this._rafID);
     this._recording = false;
     this._ticks = null;
     this._rafID = -1;
   }, {
   }),
 
   /**
+   * Returns whether this actor is currently active.
+   */
+  isRecording: method(function() {
+    return !!this._recording;
+  }, {
+    response: { recording: RetVal("boolean") }
+  }),
+
+  /**
    * Gets the refresh driver ticks recorded so far.
    */
   getPendingTicks: method(function(beginAt = 0, endAt = Number.MAX_SAFE_INTEGER) {
     if (!this._ticks) {
       return [];
     }
     return this._ticks.filter(e => e >= beginAt && e <= endAt);
   }, {
--- a/toolkit/devtools/server/actors/profiler.js
+++ b/toolkit/devtools/server/actors/profiler.js
@@ -1,4 +1,313 @@
 /* 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";
+
+const {Cc, Ci, Cu, Cr} = require("chrome");
+const DevToolsUtils = require("devtools/toolkit/DevToolsUtils.js");
+
+let DEFAULT_PROFILER_ENTRIES = 1000000;
+let DEFAULT_PROFILER_INTERVAL = 1;
+let DEFAULT_PROFILER_FEATURES = ["js"];
+
+/**
+ * The nsIProfiler is target agnostic and interacts with the whole platform.
+ * Therefore, special care needs to be given to make sure different actor
+ * consumers (i.e. "toolboxes") don't interfere with each other.
+ */
+let gProfilerConsumers = 0;
+let gProfilingStartTime = -1;
+Services.obs.addObserver(() => gProfilingStartTime = Date.now(), "profiler-started", false);
+Services.obs.addObserver(() => gProfilingStartTime = -1, "profiler-stopped", false);
+
+loader.lazyGetter(this, "nsIProfilerModule", () => {
+  return Cc["@mozilla.org/tools/profiler;1"].getService(Ci.nsIProfiler);
+});
+
+/**
+ * The profiler actor provides remote access to the built-in nsIProfiler module.
+ */
+function ProfilerActor() {
+  gProfilerConsumers++;
+  this._observedEvents = new Set();
+}
+
+ProfilerActor.prototype = {
+  actorPrefix: "profiler",
+  disconnect: function() {
+    for (let event of this._observedEvents) {
+      Services.obs.removeObserver(this, event);
+    }
+    this._observedEvents = null;
+    this.onStopProfiler();
+
+    gProfilerConsumers--;
+    checkProfilerConsumers();
+  },
+
+  /**
+   * Starts the nsIProfiler module. Doing so will discard any samples
+   * that might have been accumulated so far.
+   *
+   * @param number entries [optional]
+   * @param number interval [optional]
+   * @param array:string features [optional]
+   */
+  onStartProfiler: function(request = {}) {
+    nsIProfilerModule.StartProfiler(
+      (request.entries || DEFAULT_PROFILER_ENTRIES),
+      (request.interval || DEFAULT_PROFILER_INTERVAL),
+      (request.features || DEFAULT_PROFILER_FEATURES),
+      (request.features || DEFAULT_PROFILER_FEATURES).length);
+
+    return { started: true };
+  },
+
+  /**
+   * Stops the nsIProfiler module, if no other client is using it.
+   */
+  onStopProfiler: function() {
+    // Actually stop the profiler only if the last client has stopped profiling.
+    // Since this is a root actor, and the profiler module interacts with the
+    // whole platform, we need to avoid a case in which the profiler is stopped
+    // when there might be other clients still profiling.
+    if (gProfilerConsumers == 1) {
+      nsIProfilerModule.StopProfiler();
+    }
+    return { started: false };
+  },
+
+  /**
+   * Verifies whether or not the nsIProfiler module has started.
+   * If already active, the current time is also returned.
+   */
+  onIsActive: function() {
+    let isActive = nsIProfilerModule.IsActive();
+    let elapsedTime = isActive ? getElapsedTime() : undefined;
+    return { isActive: isActive, currentTime: elapsedTime };
+  },
+
+  /**
+   * Returns all the samples accumulated since the profiler was started,
+   * along with the current time. The data has the following format:
+   * {
+   *   libs: string,
+   *   meta: {
+   *     interval: number,
+   *     platform: string,
+   *     ...
+   *   },
+   *   threads: [{
+   *     samples: [{
+   *       frames: [{
+   *         line: number,
+   *         location: string,
+   *         category: number
+   *       } ... ],
+   *       name: string
+   *       responsiveness: number
+   *       time: number
+   *     } ... ]
+   *   } ... ]
+   * }
+   */
+  onGetProfile: function() {
+    let profile = nsIProfilerModule.getProfileData();
+    return { profile: profile, currentTime: getElapsedTime() };
+  },
+
+  /**
+   * Registers for certain event notifications.
+   * Currently supported events:
+   *   - "console-api-profiler"
+   *   - "profiler-started"
+   *   - "profiler-stopped"
+   */
+  onRegisterEventNotifications: function(request) {
+    let response = [];
+    for (let event of request.events) {
+      if (this._observedEvents.has(event)) {
+        continue;
+      }
+      Services.obs.addObserver(this, event, false);
+      this._observedEvents.add(event);
+      response.push(event);
+    }
+    return { registered: response };
+  },
+
+  /**
+   * Unregisters from certain event notifications.
+   * Currently supported events:
+   *   - "console-api-profiler"
+   *   - "profiler-started"
+   *   - "profiler-stopped"
+   */
+  onUnregisterEventNotifications: function(request) {
+    let response = [];
+    for (let event of request.events) {
+      if (!this._observedEvents.has(event)) {
+        continue;
+      }
+      Services.obs.removeObserver(this, event);
+      this._observedEvents.delete(event);
+      response.push(event);
+    }
+    return { unregistered: response };
+  },
+
+  /**
+   * Callback for all observed notifications.
+   * @param object subject
+   * @param string topic
+   * @param object data
+   */
+  observe: DevToolsUtils.makeInfallible(function(subject, topic, data) {
+    // Create JSON objects suitable for transportation across the RDP,
+    // by breaking cycles and making a copy of the `subject` and `data` via
+    // JSON.stringifying those values with a replacer that omits properties
+    // known to introduce cycles, and then JSON.parsing the result.
+    // This spends some CPU cycles, but it's simple.
+    subject = (subject && !Cu.isXrayWrapper(subject) && subject.wrappedJSObject) || subject;
+    subject = JSON.parse(JSON.stringify(subject, cycleBreaker));
+    data = (data && !Cu.isXrayWrapper(data) && data.wrappedJSObject) || data;
+    data = JSON.parse(JSON.stringify(data, cycleBreaker));
+
+    // Sends actor, type and other additional information over the remote
+    // debugging protocol to any profiler clients.
+    let reply = details => {
+      this.conn.send({
+        from: this.actorID,
+        type: "eventNotification",
+        subject: subject,
+        topic: topic,
+        data: data,
+        details: details
+      });
+    };
+
+    switch (topic) {
+      case "console-api-profiler":
+        return void reply(this._handleConsoleEvent(subject, data));
+      case "profiler-started":
+      case "profiler-stopped":
+      default:
+        return void reply();
+    }
+  }, "ProfilerActor.prototype.observe"),
+
+  /**
+   * Handles `console.profile` and `console.profileEnd` invocations and
+   * creates an appropriate response sent over the protocol.
+   * @param object subject
+   * @param object data
+   * @return object
+   */
+  _handleConsoleEvent: function(subject, data) {
+    // An optional label may be specified when calling `console.profile`.
+    // If that's the case, stringify it and send it over with the response.
+    let args = subject.arguments;
+    let profileLabel = args.length > 0 ? args[0] + "" : undefined;
+
+    // If the event was generated from `console.profile` or `console.profileEnd`
+    // we need to start the profiler right away and then just notify the client.
+    // Otherwise, we'll lose precious samples.
+
+    if (subject.action == "profile") {
+      let { isActive, currentTime } = this.onIsActive();
+
+      // Start the profiler only if it wasn't already active. Otherwise, any
+      // samples that might have been accumulated so far will be discarded.
+      if (!isActive) {
+        this.onStartProfiler();
+        return {
+          profileLabel: profileLabel,
+          currentTime: 0
+        };
+      }
+      return {
+        profileLabel: profileLabel,
+        currentTime: currentTime
+      };
+    }
+
+    if (subject.action == "profileEnd") {
+      let details = this.onGetProfile();
+      details.profileLabel = profileLabel;
+      return details;
+    }
+  }
+};
+
+/**
+ * JSON.stringify callback used in ProfilerActor.prototype.observe.
+ */
+function cycleBreaker(key, value) {
+  if (key == "wrappedJSObject") {
+    return undefined;
+  }
+  return value;
+}
+
+/**
+ * Gets the time elapsed since the profiler was last started.
+ * @return number
+ */
+function getElapsedTime() {
+  // Assign `gProfilingStartTime` now if no client of this actor has actually
+  // started it yet, but the built-in profiler module is somehow already active
+  // (it could happen if the MOZ_PROFILER_STARTUP environment variable is set,
+  // or the Gecko Profiler add-on is installed and isn't using this actor).
+  // Otherwise, the returned value is bogus and messes up the samples filtering.
+  if (gProfilingStartTime == -1) {
+    let profile = nsIProfilerModule.getProfileData();
+    let lastSampleTime = findOldestSampleTime(profile);
+    gProfilingStartTime = Date.now() - lastSampleTime;
+  }
+  return Date.now() - gProfilingStartTime;
+}
+
+/**
+ * Finds the oldest sample time in the provided profile.
+ * @param object profile
+ * @return number
+ */
+function findOldestSampleTime(profile) {
+  let firstThreadSamples = profile.threads[0].samples;
+
+  for (let i = firstThreadSamples.length - 1; i >= 0; i--) {
+    if ("time" in firstThreadSamples[i]) {
+      return firstThreadSamples[i].time;
+    }
+  }
+}
+
+/**
+ * Asserts the value sanity of `gProfilerConsumers`.
+ */
+function checkProfilerConsumers() {
+  if (gProfilerConsumers < 0) {
+    let msg = "Somehow the number of started profilers is now negative.";
+    DevToolsUtils.reportException("ProfilerActor", msg);
+  }
+}
+
+/**
+ * The request types this actor can handle.
+ */
+ProfilerActor.prototype.requestTypes = {
+  "startProfiler": ProfilerActor.prototype.onStartProfiler,
+  "stopProfiler": ProfilerActor.prototype.onStopProfiler,
+  "isActive": ProfilerActor.prototype.onIsActive,
+  "getProfile": ProfilerActor.prototype.onGetProfile,
+  "registerEventNotifications": ProfilerActor.prototype.onRegisterEventNotifications,
+  "unregisterEventNotifications": ProfilerActor.prototype.onUnregisterEventNotifications
+};
+
+exports.register = function(handle) {
+  handle.addGlobalActor(ProfilerActor, "profilerActor");
+};
+
+exports.unregister = function(handle) {
+  handle.removeGlobalActor(ProfilerActor, "profilerActor");
+};
--- a/toolkit/devtools/server/main.js
+++ b/toolkit/devtools/server/main.js
@@ -401,17 +401,17 @@ var DebuggerServer = {
     this.registerModule("devtools/server/actors/tracer");
     this.registerModule("devtools/server/actors/memory");
     this.registerModule("devtools/server/actors/framerate");
     this.registerModule("devtools/server/actors/eventlooplag");
     this.registerModule("devtools/server/actors/layout");
     this.registerModule("devtools/server/actors/csscoverage");
     this.registerModule("devtools/server/actors/monitor");
     if ("nsIProfiler" in Ci) {
-      this.addActors("resource://gre/modules/devtools/server/actors/profiler.js");
+      this.registerModule("devtools/server/actors/profiler");
     }
   },
 
   /**
    * Passes a set of options to the BrowserAddonActors for the given ID.
    *
    * @param aId string
    *        The ID of the add-on to pass the options to