summaryrefslogtreecommitdiff
path: root/toolkit/devtools/profiler/ui-recordings.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/devtools/profiler/ui-recordings.js')
-rw-r--r--toolkit/devtools/profiler/ui-recordings.js369
1 files changed, 369 insertions, 0 deletions
diff --git a/toolkit/devtools/profiler/ui-recordings.js b/toolkit/devtools/profiler/ui-recordings.js
new file mode 100644
index 000000000..2365c6fab
--- /dev/null
+++ b/toolkit/devtools/profiler/ui-recordings.js
@@ -0,0 +1,369 @@
+/* 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();
+
+ // Only clear the checked state if there's nothing recording.
+ if (!this.getItemForPredicate(e => e.isRecording)) {
+ $("#record-button").removeAttribute("checked");
+ }
+
+ // But don't leave it locked in any case.
+ $("#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.newChannel2(file,
+ null,
+ null,
+ window.document,
+ null, // aLoadingPrincipal
+ null, // aTriggeringPrincipal
+ Ci.nsILoadInfo.SEC_NORMAL,
+ Ci.nsIContentPolicy.TYPE_OTHER);
+ channel.contentType = "text/plain";
+
+ NetUtil.asyncFetch2(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;
+}