diff options
Diffstat (limited to 'toolkit/devtools/profiler/ui-recordings.js')
-rw-r--r-- | toolkit/devtools/profiler/ui-recordings.js | 369 |
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; +} |