diff options
Diffstat (limited to 'browser/devtools/shared')
50 files changed, 17083 insertions, 0 deletions
diff --git a/browser/devtools/shared/AppCacheUtils.jsm b/browser/devtools/shared/AppCacheUtils.jsm new file mode 100644 index 000000000..0c3579084 --- /dev/null +++ b/browser/devtools/shared/AppCacheUtils.jsm @@ -0,0 +1,630 @@ +/* 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/. */ + +/** + * validateManifest() warns of the following errors: + * - No manifest specified in page + * - Manifest is not utf-8 + * - Manifest mimetype not text/cache-manifest + * - Manifest does not begin with "CACHE MANIFEST" + * - Page modified since appcache last changed + * - Duplicate entries + * - Conflicting entries e.g. in both CACHE and NETWORK sections or in cache + * but blocked by FALLBACK namespace + * - Detect referenced files that are not available + * - Detect referenced files that have cache-control set to no-store + * - Wildcards used in a section other than NETWORK + * - Spaces in URI not replaced with %20 + * - Completely invalid URIs + * - Too many dot dot slash operators + * - SETTINGS section is valid + * - Invalid section name + * - etc. + */ + +"use strict"; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +let { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); +let { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); +let { Promise } = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); + +this.EXPORTED_SYMBOLS = ["AppCacheUtils"]; + +function AppCacheUtils(documentOrUri) { + this._parseManifest = this._parseManifest.bind(this); + + if (documentOrUri) { + if (typeof documentOrUri == "string") { + this.uri = documentOrUri; + } + if (/HTMLDocument/.test(documentOrUri.toString())) { + this.doc = documentOrUri; + } + } +} + +AppCacheUtils.prototype = { + get cachePath() { + return ""; + }, + + validateManifest: function ACU_validateManifest() { + let deferred = Promise.defer(); + this.errors = []; + // Check for missing manifest. + this._getManifestURI().then(manifestURI => { + this.manifestURI = manifestURI; + + if (!this.manifestURI) { + this._addError(0, "noManifest"); + deferred.resolve(this.errors); + } + + this._getURIInfo(this.manifestURI).then(uriInfo => { + this._parseManifest(uriInfo).then(() => { + // Sort errors by line number. + this.errors.sort(function(a, b) { + return a.line - b.line; + }); + deferred.resolve(this.errors); + }); + }); + }); + + return deferred.promise; + }, + + _parseManifest: function ACU__parseManifest(uriInfo) { + let deferred = Promise.defer(); + let manifestName = uriInfo.name; + let manifestLastModified = new Date(uriInfo.responseHeaders["Last-Modified"]); + + if (uriInfo.charset.toLowerCase() != "utf-8") { + this._addError(0, "notUTF8", uriInfo.charset); + } + + if (uriInfo.mimeType != "text/cache-manifest") { + this._addError(0, "badMimeType", uriInfo.mimeType); + } + + let parser = new ManifestParser(uriInfo.text, this.manifestURI); + let parsed = parser.parse(); + + if (parsed.errors.length > 0) { + this.errors.push.apply(this.errors, parsed.errors); + } + + // Check for duplicate entries. + let dupes = {}; + for (let parsedUri of parsed.uris) { + dupes[parsedUri.uri] = dupes[parsedUri.uri] || []; + dupes[parsedUri.uri].push({ + line: parsedUri.line, + section: parsedUri.section, + original: parsedUri.original + }); + } + for (let [uri, value] of Iterator(dupes)) { + if (value.length > 1) { + this._addError(0, "duplicateURI", uri, JSON.stringify(value)); + } + } + + // Loop through network entries making sure that fallback and cache don't + // contain uris starting with the network uri. + for (let neturi of parsed.uris) { + if (neturi.section == "NETWORK") { + for (let parsedUri of parsed.uris) { + if (parsedUri.uri.startsWith(neturi.uri)) { + this._addError(neturi.line, "networkBlocksURI", neturi.line, + neturi.original, parsedUri.line, parsedUri.original, + parsedUri.section); + } + } + } + } + + // Loop through fallback entries making sure that fallback and cache don't + // contain uris starting with the network uri. + for (let fb of parsed.fallbacks) { + for (let parsedUri of parsed.uris) { + if (parsedUri.uri.startsWith(fb.namespace)) { + this._addError(fb.line, "fallbackBlocksURI", fb.line, + fb.original, parsedUri.line, parsedUri.original, + parsedUri.section); + } + } + } + + // Check that all resources exist and that their cach-control headers are + // not set to no-store. + let current = -1; + for (let i = 0, len = parsed.uris.length; i < len; i++) { + let parsedUri = parsed.uris[i]; + this._getURIInfo(parsedUri.uri).then(uriInfo => { + current++; + + if (uriInfo.success) { + // Check that the resource was not modified after the manifest was last + // modified. If it was then the manifest file should be refreshed. + let resourceLastModified = + new Date(uriInfo.responseHeaders["Last-Modified"]); + + if (manifestLastModified < resourceLastModified) { + this._addError(parsedUri.line, "fileChangedButNotManifest", + uriInfo.name, manifestName, parsedUri.line); + } + + // If cache-control: no-store the file will not be added to the + // appCache. + if (uriInfo.nocache) { + this._addError(parsedUri.line, "cacheControlNoStore", + parsedUri.original, parsedUri.line); + } + } else { + this._addError(parsedUri.line, "notAvailable", + parsedUri.original, parsedUri.line); + } + + if (current == len - 1) { + deferred.resolve(); + } + }); + } + + return deferred.promise; + }, + + _getURIInfo: function ACU__getURIInfo(uri) { + let inputStream = Cc["@mozilla.org/scriptableinputstream;1"] + .createInstance(Ci.nsIScriptableInputStream); + let deferred = Promise.defer(); + let channelCharset = ""; + let buffer = ""; + let channel = Services.io.newChannel(uri, null, null); + + // Avoid the cache: + channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + + channel.asyncOpen({ + onStartRequest: function (request, context) { + // This empty method is needed in order for onDataAvailable to be + // called. + }, + + onDataAvailable: function (request, context, stream, offset, count) { + request.QueryInterface(Ci.nsIHttpChannel); + inputStream.init(stream); + buffer = buffer.concat(inputStream.read(count)); + }, + + onStopRequest: function onStartRequest(request, context, statusCode) { + if (statusCode == 0) { + request.QueryInterface(Ci.nsIHttpChannel); + + let result = { + name: request.name, + success: request.requestSucceeded, + status: request.responseStatus + " - " + request.responseStatusText, + charset: request.contentCharset || "utf-8", + mimeType: request.contentType, + contentLength: request.contentLength, + nocache: request.isNoCacheResponse() || request.isNoStoreResponse(), + prePath: request.URI.prePath + "/", + text: buffer + }; + + result.requestHeaders = {}; + request.visitRequestHeaders(function(header, value) { + result.requestHeaders[header] = value; + }); + + result.responseHeaders = {}; + request.visitResponseHeaders(function(header, value) { + result.responseHeaders[header] = value; + }); + + deferred.resolve(result); + } else { + deferred.resolve({ + name: request.name, + success: false + }); + } + } + }, null); + return deferred.promise; + }, + + listEntries: function ACU_show(searchTerm) { + if (!Services.prefs.getBoolPref("browser.cache.disk.enable")) { + throw new Error(l10n.GetStringFromName("cacheDisabled")); + } + + let entries = []; + + Services.cache.visitEntries({ + visitDevice: function(deviceID, deviceInfo) { + return true; + }, + + visitEntry: function(deviceID, entryInfo) { + if (entryInfo.deviceID == "offline") { + let entry = {}; + let lowerKey = entryInfo.key.toLowerCase(); + + if (searchTerm && lowerKey.indexOf(searchTerm.toLowerCase()) == -1) { + return true; + } + + for (let [key, value] of Iterator(entryInfo)) { + if (key == "QueryInterface") { + continue; + } + if (key == "clientID") { + entry.key = entryInfo.key; + } + if (key == "expirationTime" || key == "lastFetched" || key == "lastModified") { + value = new Date(value * 1000); + } + entry[key] = value; + } + entries.push(entry); + } + return true; + } + }); + + if (entries.length == 0) { + throw new Error(l10n.GetStringFromName("noResults")); + } + return entries; + }, + + viewEntry: function ACU_viewEntry(key) { + let uri; + + Services.cache.visitEntries({ + visitDevice: function(deviceID, deviceInfo) { + return true; + }, + + visitEntry: function(deviceID, entryInfo) { + if (entryInfo.deviceID == "offline" && entryInfo.key == key) { + uri = "about:cache-entry?client=" + entryInfo.clientID + + "&sb=1&key=" + entryInfo.key; + return false; + } + return true; + } + }); + + if (uri) { + let wm = Cc["@mozilla.org/appshell/window-mediator;1"] + .getService(Ci.nsIWindowMediator); + let win = wm.getMostRecentWindow("navigator:browser"); + win.gBrowser.selectedTab = win.gBrowser.addTab(uri); + } else { + return l10n.GetStringFromName("entryNotFound"); + } + }, + + clearAll: function ACU_clearAll() { + Services.cache.evictEntries(Ci.nsICache.STORE_OFFLINE); + }, + + _getManifestURI: function ACU__getManifestURI() { + let deferred = Promise.defer(); + + let getURI = node => { + let htmlNode = this.doc.querySelector("html[manifest]"); + if (htmlNode) { + let pageUri = this.doc.location ? this.doc.location.href : this.uri; + let origin = pageUri.substr(0, pageUri.lastIndexOf("/") + 1); + return origin + htmlNode.getAttribute("manifest"); + } + }; + + if (this.doc) { + let uri = getURI(this.doc); + return Promise.resolve(uri); + } else { + this._getURIInfo(this.uri).then(uriInfo => { + if (uriInfo.success) { + let html = uriInfo.text; + let parser = _DOMParser; + this.doc = parser.parseFromString(html, "text/html"); + let uri = getURI(this.doc); + deferred.resolve(uri); + } else { + this.errors.push({ + line: 0, + msg: l10n.GetStringFromName("invalidURI") + }); + } + }); + } + return deferred.promise; + }, + + _addError: function ACU__addError(line, l10nString, ...params) { + let msg; + + if (params) { + msg = l10n.formatStringFromName(l10nString, params, params.length); + } else { + msg = l10n.GetStringFromName(l10nString); + } + + this.errors.push({ + line: line, + msg: msg + }); + }, +}; + +/** + * We use our own custom parser because we need far more detailed information + * than the system manifest parser provides. + * + * @param {String} manifestText + * The text content of the manifest file. + * @param {String} manifestURI + * The URI of the manifest file. This is used in calculating the path of + * relative URIs. + */ +function ManifestParser(manifestText, manifestURI) { + this.manifestText = manifestText; + this.origin = manifestURI.substr(0, manifestURI.lastIndexOf("/") + 1) + .replace(" ", "%20"); +} + +ManifestParser.prototype = { + parse: function OCIMP_parse() { + let lines = this.manifestText.split(/\r?\n/); + let fallbacks = this.fallbacks = []; + let settings = this.settings = []; + let errors = this.errors = []; + let uris = this.uris = []; + + this.currSection = "CACHE"; + + for (let i = 0; i < lines.length; i++) { + let text = this.text = lines[i].replace(/^\s+|\s+$/g); + this.currentLine = i + 1; + + if (i == 0 && text != "CACHE MANIFEST") { + this._addError(1, "firstLineMustBeCacheManifest", 1); + } + + // Ignore comments + if (/^#/.test(text) || !text.length) { + continue; + } + + if (text == "CACHE MANIFEST") { + if (this.currentLine != 1) { + this._addError(this.currentLine, "cacheManifestOnlyFirstLine2", + this.currentLine); + } + continue; + } + + if (this._maybeUpdateSectionName()) { + continue; + } + + switch (this.currSection) { + case "CACHE": + case "NETWORK": + this.parseLine(); + break; + case "FALLBACK": + this.parseFallbackLine(); + break; + case "SETTINGS": + this.parseSettingsLine(); + break; + } + } + + return { + uris: uris, + fallbacks: fallbacks, + settings: settings, + errors: errors + }; + }, + + parseLine: function OCIMP_parseLine() { + let text = this.text; + + if (text.indexOf("*") != -1) { + if (this.currSection != "NETWORK" || text.length != 1) { + this._addError(this.currentLine, "asteriskInWrongSection2", + this.currSection, this.currentLine); + return; + } + } + + if (/\s/.test(text)) { + this._addError(this.currentLine, "escapeSpaces", this.currentLine); + text = text.replace(/\s/g, "%20") + } + + if (text[0] == "/") { + if (text.substr(0, 4) == "/../") { + this._addError(this.currentLine, "slashDotDotSlashBad", this.currentLine); + } else { + this.uris.push(this._wrapURI(this.origin + text.substring(1))); + } + } else if (text.substr(0, 2) == "./") { + this.uris.push(this._wrapURI(this.origin + text.substring(2))); + } else if (text.substr(0, 4) == "http") { + this.uris.push(this._wrapURI(text)); + } else { + let origin = this.origin; + let path = text; + + while (path.substr(0, 3) == "../" && /^https?:\/\/.*?\/.*?\//.test(origin)) { + let trimIdx = origin.substr(0, origin.length - 1).lastIndexOf("/") + 1; + origin = origin.substr(0, trimIdx); + path = path.substr(3); + } + + if (path.substr(0, 3) == "../") { + this._addError(this.currentLine, "tooManyDotDotSlashes", this.currentLine); + return; + } + + if (/^https?:\/\//.test(path)) { + this.uris.push(this._wrapURI(path)); + return; + } + this.uris.push(this._wrapURI(origin + path)); + } + }, + + parseFallbackLine: function OCIMP_parseFallbackLine() { + let split = this.text.split(/\s+/); + let origURI = this.text; + + if (split.length != 2) { + this._addError(this.currentLine, "fallbackUseSpaces", this.currentLine); + return; + } + + let [ namespace, fallback ] = split; + + if (namespace.indexOf("*") != -1) { + this._addError(this.currentLine, "fallbackAsterisk2", this.currentLine); + } + + if (/\s/.test(namespace)) { + this._addError(this.currentLine, "escapeSpaces", this.currentLine); + namespace = namespace.replace(/\s/g, "%20") + } + + if (namespace.substr(0, 4) == "/../") { + this._addError(this.currentLine, "slashDotDotSlashBad", this.currentLine); + } + + if (namespace.substr(0, 2) == "./") { + namespace = this.origin + namespace.substring(2); + } + + if (namespace.substr(0, 4) != "http") { + let origin = this.origin; + let path = namespace; + + while (path.substr(0, 3) == "../" && /^https?:\/\/.*?\/.*?\//.test(origin)) { + let trimIdx = origin.substr(0, origin.length - 1).lastIndexOf("/") + 1; + origin = origin.substr(0, trimIdx); + path = path.substr(3); + } + + if (path.substr(0, 3) == "../") { + this._addError(this.currentLine, "tooManyDotDotSlashes", this.currentLine); + } + + if (/^https?:\/\//.test(path)) { + namespace = path; + } else { + if (path[0] == "/") { + path = path.substring(1); + } + namespace = origin + path; + } + } + + this.text = fallback; + this.parseLine(); + + this.fallbacks.push({ + line: this.currentLine, + original: origURI, + namespace: namespace, + fallback: fallback + }); + }, + + parseSettingsLine: function OCIMP_parseSettingsLine() { + let text = this.text; + + if (this.settings.length == 1 || !/prefer-online|fast/.test(text)) { + this._addError(this.currentLine, "settingsBadValue", this.currentLine); + return; + } + + switch (text) { + case "prefer-online": + this.settings.push(this._wrapURI(text)); + break; + case "fast": + this.settings.push(this._wrapURI(text)); + break; + } + }, + + _wrapURI: function OCIMP__wrapURI(uri) { + return { + section: this.currSection, + line: this.currentLine, + uri: uri, + original: this.text + }; + }, + + _addError: function OCIMP__addError(line, l10nString, ...params) { + let msg; + + if (params) { + msg = l10n.formatStringFromName(l10nString, params, params.length); + } else { + msg = l10n.GetStringFromName(l10nString); + } + + this.errors.push({ + line: line, + msg: msg + }); + }, + + _maybeUpdateSectionName: function OCIMP__maybeUpdateSectionName() { + let text = this.text; + + if (text == text.toUpperCase() && text.charAt(text.length - 1) == ":") { + text = text.substr(0, text.length - 1); + + switch (text) { + case "CACHE": + case "NETWORK": + case "FALLBACK": + case "SETTINGS": + this.currSection = text; + return true; + default: + this._addError(this.currentLine, + "invalidSectionName", text, this.currentLine); + return false; + } + } + }, +}; + +XPCOMUtils.defineLazyGetter(this, "l10n", function() Services.strings + .createBundle("chrome://browser/locale/devtools/appcacheutils.properties")); + +XPCOMUtils.defineLazyGetter(this, "appcacheservice", function() { + return Cc["@mozilla.org/network/application-cache-service;1"] + .getService(Ci.nsIApplicationCacheService); + +}); + +XPCOMUtils.defineLazyGetter(this, "_DOMParser", function() { + return Cc["@mozilla.org/xmlextras/domparser;1"].createInstance(Ci.nsIDOMParser); +}); diff --git a/browser/devtools/shared/AutocompletePopup.jsm b/browser/devtools/shared/AutocompletePopup.jsm new file mode 100644 index 000000000..523a13e6c --- /dev/null +++ b/browser/devtools/shared/AutocompletePopup.jsm @@ -0,0 +1,500 @@ +/* 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/. */ + +const Cu = Components.utils; + +// The XUL and XHTML namespace. +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +this.EXPORTED_SYMBOLS = ["AutocompletePopup"]; + +/** + * Autocomplete popup UI implementation. + * + * @constructor + * @param nsIDOMDocument aDocument + * The document you want the popup attached to. + * @param Object aOptions + * An object consiting any of the following options: + * - panelId {String} The id for the popup panel. + * - listBoxId {String} The id for the richlistbox inside the panel. + * - position {String} The position for the popup panel. + * - theme {String} String related to the theme of the popup. + * - autoSelect {Boolean} Boolean to allow the first entry of the popup + * panel to be automatically selected when the popup shows. + * - fixedWidth {Boolean} Boolean to control dynamic width of the popup. + * - direction {String} The direction of the text in the panel. rtl or ltr + * - onSelect {String} The select event handler for the richlistbox + * - onClick {String} The click event handler for the richlistbox. + * - onKeypress {String} The keypress event handler for the richlistitems. + */ +this.AutocompletePopup = +function AutocompletePopup(aDocument, aOptions = {}) +{ + this._document = aDocument; + + this.fixedWidth = aOptions.fixedWidth || false; + this.autoSelect = aOptions.autoSelect || false; + this.position = aOptions.position || "after_start"; + this.direction = aOptions.direction || "ltr"; + + this.onSelect = aOptions.onSelect; + this.onClick = aOptions.onClick; + this.onKeypress = aOptions.onKeypress; + + let id = aOptions.panelId || "devtools_autoCompletePopup"; + let theme = aOptions.theme || "dark"; + // Reuse the existing popup elements. + this._panel = this._document.getElementById(id); + if (!this._panel) { + this._panel = this._document.createElementNS(XUL_NS, "panel"); + this._panel.setAttribute("id", id); + this._panel.className = "devtools-autocomplete-popup " + theme + "-theme"; + + this._panel.setAttribute("noautofocus", "true"); + this._panel.setAttribute("level", "top"); + if (!aOptions.onKeypress) { + this._panel.setAttribute("ignorekeys", "true"); + } + + let mainPopupSet = this._document.getElementById("mainPopupSet"); + if (mainPopupSet) { + mainPopupSet.appendChild(this._panel); + } + else { + this._document.documentElement.appendChild(this._panel); + } + this._list = null; + } + else { + this._list = this._panel.firstChild; + } + + if (!this._list) { + this._list = this._document.createElementNS(XUL_NS, "richlistbox"); + this._panel.appendChild(this._list); + + // Open and hide the panel, so we initialize the API of the richlistbox. + this._panel.openPopup(null, this.position, 0, 0); + this._panel.hidePopup(); + } + + this._list.setAttribute("flex", "1"); + this._list.setAttribute("seltype", "single"); + + if (aOptions.listBoxId) { + this._list.setAttribute("id", aOptions.listBoxId); + } + this._list.className = "devtools-autocomplete-listbox " + theme + "-theme"; + + if (this.onSelect) { + this._list.addEventListener("select", this.onSelect, false); + } + + if (this.onClick) { + this._list.addEventListener("click", this.onClick, false); + } + + if (this.onKeypress) { + this._list.addEventListener("keypress", this.onKeypress, false); + } +} + +AutocompletePopup.prototype = { + _document: null, + _panel: null, + _list: null, + + // Event handlers. + onSelect: null, + onClick: null, + onKeypress: null, + + /** + * Open the autocomplete popup panel. + * + * @param nsIDOMNode aAnchor + * Optional node to anchor the panel to. + */ + openPopup: function AP_openPopup(aAnchor) + { + this._panel.openPopup(aAnchor, this.position, 0, 0); + + if (this.autoSelect) { + this.selectFirstItem(); + } + if (!this.fixedWidth) { + this._updateSize(); + } + }, + + /** + * Hide the autocomplete popup panel. + */ + hidePopup: function AP_hidePopup() + { + this._panel.hidePopup(); + }, + + /** + * Check if the autocomplete popup is open. + */ + get isOpen() { + return this._panel.state == "open"; + }, + + /** + * Destroy the object instance. Please note that the panel DOM elements remain + * in the DOM, because they might still be in use by other instances of the + * same code. It is the responsability of the client code to perform DOM + * cleanup. + */ + destroy: function AP_destroy() + { + if (this.isOpen) { + this.hidePopup(); + } + this.clearItems(); + + if (this.onSelect) { + this._list.removeEventListener("select", this.onSelect, false); + } + + if (this.onClick) { + this._list.removeEventListener("click", this.onClick, false); + } + + if (this.onKeypress) { + this._list.removeEventListener("keypress", this.onKeypress, false); + } + + this._document = null; + this._list = null; + this._panel = null; + }, + + /** + * Get the autocomplete items array. + * + * @param Number aIndex The index of the item what is wanted. + * + * @return The autocomplete item at index aIndex. + */ + getItemAtIndex: function AP_getItemAtIndex(aIndex) + { + return this._list.getItemAtIndex(aIndex)._autocompleteItem; + }, + + /** + * Get the autocomplete items array. + * + * @return array + * The array of autocomplete items. + */ + getItems: function AP_getItems() + { + let items = []; + + Array.forEach(this._list.childNodes, function(aItem) { + items.push(aItem._autocompleteItem); + }); + + return items; + }, + + /** + * Set the autocomplete items list, in one go. + * + * @param array aItems + * The list of items you want displayed in the popup list. + */ + setItems: function AP_setItems(aItems) + { + this.clearItems(); + aItems.forEach(this.appendItem, this); + + // Make sure that the new content is properly fitted by the XUL richlistbox. + if (this.isOpen) { + if (this.autoSelect) { + this.selectFirstItem(); + } + if (!this.fixedWidth) { + this._updateSize(); + } + } + }, + + /** + * Selects the first item of the richlistbox. Note that first item here is the + * item closes to the input element, which means that 0th index if position is + * below, and last index if position is above. + */ + selectFirstItem: function AP_selectFirstItem() + { + if (this.position.contains("before")) { + this.selectedIndex = this.itemCount - 1; + } + else { + this.selectedIndex = 0; + } + }, + + /** + * Update the panel size to fit the content. + * + * @private + */ + _updateSize: function AP__updateSize() + { + // We need the dispatch to allow the content to reflow. Attempting to + // update the richlistbox size too early does not work. + Services.tm.currentThread.dispatch({ run: () => { + if (!this._panel) { + return; + } + this._list.width = this._panel.clientWidth + this._scrollbarWidth; + // Height change is required, otherwise the panel is drawn at an offset + // the first time. + this._list.height = this._list.clientHeight; + // This brings the panel back at right position. + this._list.top = 0; + // Changing panel height might make the selected item out of view, so + // bring it back to view. + this._list.ensureIndexIsVisible(this._list.selectedIndex); + }}, 0); + }, + + /** + * Clear all the items from the autocomplete list. + */ + clearItems: function AP_clearItems() + { + // Reset the selectedIndex to -1 before clearing the list + this.selectedIndex = -1; + + while (this._list.hasChildNodes()) { + this._list.removeChild(this._list.firstChild); + } + + if (!this.fixedWidth) { + // Reset the panel and list dimensions. New dimensions are calculated when + // a new set of items is added to the autocomplete popup. + this._list.width = ""; + this._list.height = ""; + this._panel.width = ""; + this._panel.height = ""; + this._panel.top = ""; + this._panel.left = ""; + } + }, + + /** + * Getter for the index of the selected item. + * + * @type number + */ + get selectedIndex() { + return this._list.selectedIndex; + }, + + /** + * Setter for the selected index. + * + * @param number aIndex + * The number (index) of the item you want to select in the list. + */ + set selectedIndex(aIndex) { + this._list.selectedIndex = aIndex; + if (this.isOpen && this._list.ensureIndexIsVisible) { + this._list.ensureIndexIsVisible(this._list.selectedIndex); + } + }, + + /** + * Getter for the selected item. + * @type object + */ + get selectedItem() { + return this._list.selectedItem ? + this._list.selectedItem._autocompleteItem : null; + }, + + /** + * Setter for the selected item. + * + * @param object aItem + * The object you want selected in the list. + */ + set selectedItem(aItem) { + this._list.selectedItem = this._findListItem(aItem); + if (this.isOpen) { + this._list.ensureIndexIsVisible(this._list.selectedIndex); + } + }, + + /** + * Append an item into the autocomplete list. + * + * @param object aItem + * The item you want appended to the list. + * The item object can have the following properties: + * - label {String} Property which is used as the displayed value. + * - preLabel {String} [Optional] The String that will be displayed + * before the label indicating that this is the already + * present text in the input box, and label is the text + * that will be auto completed. When this property is + * present, |preLabel.length| starting characters will be + * removed from label. + * - count {Number} [Optional] The number to represent the count of + * autocompleted label. + */ + appendItem: function AP_appendItem(aItem) + { + let listItem = this._document.createElementNS(XUL_NS, "richlistitem"); + if (this.direction) { + listItem.setAttribute("dir", this.direction); + } + let label = this._document.createElementNS(XUL_NS, "label"); + label.setAttribute("value", aItem.label); + label.setAttribute("class", "autocomplete-value"); + if (aItem.preLabel) { + let preDesc = this._document.createElementNS(XUL_NS, "label"); + preDesc.setAttribute("value", aItem.preLabel); + preDesc.setAttribute("class", "initial-value"); + listItem.appendChild(preDesc); + label.setAttribute("value", aItem.label.slice(aItem.preLabel.length)); + } + listItem.appendChild(label); + if (aItem.count && aItem.count > 1) { + let countDesc = this._document.createElementNS(XUL_NS, "label"); + countDesc.setAttribute("value", aItem.count); + countDesc.setAttribute("flex", "1"); + countDesc.setAttribute("class", "autocomplete-count"); + listItem.appendChild(countDesc); + } + listItem._autocompleteItem = aItem; + + this._list.appendChild(listItem); + }, + + /** + * Find the richlistitem element that belongs to an item. + * + * @private + * + * @param object aItem + * The object you want found in the list. + * + * @return nsIDOMNode|null + * The nsIDOMNode that belongs to the given item object. This node is + * the richlistitem element. + */ + _findListItem: function AP__findListItem(aItem) + { + for (let i = 0; i < this._list.childNodes.length; i++) { + let child = this._list.childNodes[i]; + if (child._autocompleteItem == aItem) { + return child; + } + } + return null; + }, + + /** + * Remove an item from the popup list. + * + * @param object aItem + * The item you want removed. + */ + removeItem: function AP_removeItem(aItem) + { + let item = this._findListItem(aItem); + if (!item) { + throw new Error("Item not found!"); + } + this._list.removeChild(item); + }, + + /** + * Getter for the number of items in the popup. + * @type number + */ + get itemCount() { + return this._list.childNodes.length; + }, + + /** + * Select the next item in the list. + * + * @return object + * The newly selected item object. + */ + selectNextItem: function AP_selectNextItem() + { + if (this.selectedIndex < (this.itemCount - 1)) { + this.selectedIndex++; + } + else { + this.selectedIndex = -1; + } + + return this.selectedItem; + }, + + /** + * Select the previous item in the list. + * + * @return object + * The newly selected item object. + */ + selectPreviousItem: function AP_selectPreviousItem() + { + if (this.selectedIndex > -1) { + this.selectedIndex--; + } + else { + this.selectedIndex = this.itemCount - 1; + } + + return this.selectedItem; + }, + + /** + * Focuses the richlistbox. + */ + focus: function AP_focus() + { + this._list.focus(); + }, + + /** + * Determine the scrollbar width in the current document. + * + * @private + */ + get _scrollbarWidth() + { + if (this.__scrollbarWidth) { + return this.__scrollbarWidth; + } + + let hbox = this._document.createElementNS(XUL_NS, "hbox"); + hbox.setAttribute("style", "height: 0%; overflow: hidden"); + + let scrollbar = this._document.createElementNS(XUL_NS, "scrollbar"); + scrollbar.setAttribute("orient", "vertical"); + hbox.appendChild(scrollbar); + + this._document.documentElement.appendChild(hbox); + this.__scrollbarWidth = scrollbar.clientWidth; + this._document.documentElement.removeChild(hbox); + + return this.__scrollbarWidth; + }, +}; + diff --git a/browser/devtools/shared/DOMHelpers.jsm b/browser/devtools/shared/DOMHelpers.jsm new file mode 100644 index 000000000..754632ff9 --- /dev/null +++ b/browser/devtools/shared/DOMHelpers.jsm @@ -0,0 +1,124 @@ +/* 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/. */ + +this.EXPORTED_SYMBOLS = ["DOMHelpers"]; + +/** + * DOMHelpers + * Makes DOM traversal easier. Goes through iframes. + * + * @constructor + * @param nsIDOMWindow aWindow + * The content window, owning the document to traverse. + */ +this.DOMHelpers = function DOMHelpers(aWindow) { + this.window = aWindow; +}; + +DOMHelpers.prototype = { + getParentObject: function Helpers_getParentObject(node) + { + let parentNode = node ? node.parentNode : null; + + if (!parentNode) { + // Documents have no parentNode; Attr, Document, DocumentFragment, Entity, + // and Notation. top level windows have no parentNode + if (node && node == this.window.Node.DOCUMENT_NODE) { + // document type + if (node.defaultView) { + let embeddingFrame = node.defaultView.frameElement; + if (embeddingFrame) + return embeddingFrame.parentNode; + } + } + // a Document object without a parentNode or window + return null; // top level has no parent + } + + if (parentNode.nodeType == this.window.Node.DOCUMENT_NODE) { + if (parentNode.defaultView) { + return parentNode.defaultView.frameElement; + } + // parent is document element, but no window at defaultView. + return null; + } + + if (!parentNode.localName) + return null; + + return parentNode; + }, + + getChildObject: function Helpers_getChildObject(node, index, previousSibling, + showTextNodesWithWhitespace) + { + if (!node) + return null; + + if (node.contentDocument) { + // then the node is a frame + if (index == 0) { + return node.contentDocument.documentElement; // the node's HTMLElement + } + return null; + } + + if (node.getSVGDocument) { + let svgDocument = node.getSVGDocument(); + if (svgDocument) { + // then the node is a frame + if (index == 0) { + return svgDocument.documentElement; // the node's SVGElement + } + return null; + } + } + + let child = null; + if (previousSibling) // then we are walking + child = this.getNextSibling(previousSibling); + else + child = this.getFirstChild(node); + + if (showTextNodesWithWhitespace) + return child; + + for (; child; child = this.getNextSibling(child)) { + if (!this.isWhitespaceText(child)) + return child; + } + + return null; // we have no children worth showing. + }, + + getFirstChild: function Helpers_getFirstChild(node) + { + let SHOW_ALL = Components.interfaces.nsIDOMNodeFilter.SHOW_ALL; + this.treeWalker = node.ownerDocument.createTreeWalker(node, + SHOW_ALL, null); + return this.treeWalker.firstChild(); + }, + + getNextSibling: function Helpers_getNextSibling(node) + { + let next = this.treeWalker.nextSibling(); + + if (!next) + delete this.treeWalker; + + return next; + }, + + isWhitespaceText: function Helpers_isWhitespaceText(node) + { + return node.nodeType == this.window.Node.TEXT_NODE && + !/[^\s]/.exec(node.nodeValue); + }, + + destroy: function Helpers_destroy() + { + delete this.window; + delete this.treeWalker; + } +}; diff --git a/browser/devtools/shared/DeveloperToolbar.jsm b/browser/devtools/shared/DeveloperToolbar.jsm new file mode 100644 index 000000000..f5b19139c --- /dev/null +++ b/browser/devtools/shared/DeveloperToolbar.jsm @@ -0,0 +1,1248 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ "DeveloperToolbar", "CommandUtils" ]; + +const NS_XHTML = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource:///modules/devtools/Commands.jsm"); + +const Node = Ci.nsIDOMNode; + +XPCOMUtils.defineLazyModuleGetter(this, "console", + "resource://gre/modules/devtools/Console.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "gcli", + "resource://gre/modules/devtools/gcli.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "CmdCommands", + "resource:///modules/devtools/BuiltinCommands.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "ConsoleServiceListener", + "resource://gre/modules/devtools/WebConsoleUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "devtools", + "resource://gre/modules/devtools/Loader.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "require", + "resource://gre/modules/devtools/Require.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter", + "resource:///modules/devtools/shared/event-emitter.js"); + +XPCOMUtils.defineLazyGetter(this, "prefBranch", function() { + let prefService = Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefService); + return prefService.getBranch(null) + .QueryInterface(Ci.nsIPrefBranch2); +}); + +XPCOMUtils.defineLazyGetter(this, "toolboxStrings", function () { + return Services.strings.createBundle("chrome://browser/locale/devtools/toolbox.properties"); +}); + +let Telemetry = devtools.require("devtools/shared/telemetry"); + +const converters = require("gcli/converters"); + +/** + * A collection of utilities to help working with commands + */ +let CommandUtils = { + /** + * Read a toolbarSpec from preferences + * @param aPref The name of the preference to read + */ + getCommandbarSpec: function CU_getCommandbarSpec(aPref) { + let value = prefBranch.getComplexValue(aPref, Ci.nsISupportsString).data; + return JSON.parse(value); + }, + + /** + * A toolbarSpec is an array of buttonSpecs. A buttonSpec is an array of + * strings each of which is a GCLI command (including args if needed). + * + * Warning: this method uses the unload event of the window that owns the + * buttons that are of type checkbox. this means that we don't properly + * unregister event handlers until the window is destroyed. + */ + createButtons: function CU_createButtons(toolbarSpec, target, document, requisition) { + let reply = []; + + toolbarSpec.forEach(function(buttonSpec) { + let button = document.createElement("toolbarbutton"); + reply.push(button); + + if (typeof buttonSpec == "string") { + buttonSpec = { typed: buttonSpec }; + } + // Ask GCLI to parse the typed string (doesn't execute it) + requisition.update(buttonSpec.typed); + + // Ignore invalid commands + let command = requisition.commandAssignment.value; + if (command == null) { + // TODO: Have a broken icon + // button.icon = 'Broken'; + button.setAttribute("label", "X"); + button.setAttribute("tooltip", "Unknown command: " + buttonSpec.typed); + button.setAttribute("disabled", "true"); + } + else { + if (command.buttonId != null) { + button.id = command.buttonId; + } + if (command.buttonClass != null) { + button.className = command.buttonClass; + } + if (command.tooltipText != null) { + button.setAttribute("tooltiptext", command.tooltipText); + } + else if (command.description != null) { + button.setAttribute("tooltiptext", command.description); + } + + button.addEventListener("click", function() { + requisition.update(buttonSpec.typed); + //if (requisition.getStatus() == Status.VALID) { + requisition.exec(); + /* + } + else { + console.error('incomplete commands not yet supported'); + } + */ + }, false); + + // Allow the command button to be toggleable + if (command.state) { + button.setAttribute("autocheck", false); + let onChange = function(event, eventTab) { + if (eventTab == target.tab) { + if (command.state.isChecked(target)) { + button.setAttribute("checked", true); + } + else if (button.hasAttribute("checked")) { + button.removeAttribute("checked"); + } + } + }; + command.state.onChange(target, onChange); + onChange(null, target.tab); + document.defaultView.addEventListener("unload", function() { + command.state.offChange(target, onChange); + }, false); + } + } + }); + + requisition.update(''); + + return reply; + }, + + /** + * A helper function to create the environment object that is passed to + * GCLI commands. + */ + createEnvironment: function(chromeDocument, contentDocument) { + let environment = { + chromeDocument: chromeDocument, + chromeWindow: chromeDocument.defaultView, + + document: contentDocument, + window: contentDocument != null ? contentDocument.defaultView : undefined + }; + + Object.defineProperty(environment, "target", { + get: function() { + let tab = chromeDocument.defaultView.getBrowser().selectedTab; + return devtools.TargetFactory.forTab(tab); + }, + enumerable: true + }); + + return environment; + }, +}; + +this.CommandUtils = CommandUtils; + +/** + * Due to a number of panel bugs we need a way to check if we are running on + * Linux. See the comments for TooltipPanel and OutputPanel for further details. + * + * When bug 780102 is fixed all isLinux checks can be removed and we can revert + * to using panels. + */ +XPCOMUtils.defineLazyGetter(this, "isLinux", function () { + return OS == "Linux"; +}); + +XPCOMUtils.defineLazyGetter(this, "OS", function () { + let os = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).OS; + return os; +}); + +/** + * A component to manage the global developer toolbar, which contains a GCLI + * and buttons for various developer tools. + * @param aChromeWindow The browser window to which this toolbar is attached + * @param aToolbarElement See browser.xul:<toolbar id="developer-toolbar"> + */ +this.DeveloperToolbar = function DeveloperToolbar(aChromeWindow, aToolbarElement) +{ + this._chromeWindow = aChromeWindow; + + this._element = aToolbarElement; + this._element.hidden = true; + this._doc = this._element.ownerDocument; + + this._telemetry = new Telemetry(); + this._lastState = NOTIFICATIONS.HIDE; + this._pendingShowCallback = undefined; + this._pendingHide = false; + this._errorsCount = {}; + this._warningsCount = {}; + this._errorListeners = {}; + this._errorCounterButton = this._doc + .getElementById("developer-toolbar-toolbox-button"); + this._errorCounterButton._defaultTooltipText = + this._errorCounterButton.getAttribute("tooltiptext"); + + EventEmitter.decorate(this); + + try { + CmdCommands.refreshAutoCommands(aChromeWindow); + } + catch (ex) { + console.error(ex); + } +} + +/** + * Inspector notifications dispatched through the nsIObserverService + */ +const NOTIFICATIONS = { + /** DeveloperToolbar.show() has been called, and we're working on it */ + LOAD: "developer-toolbar-load", + + /** DeveloperToolbar.show() has completed */ + SHOW: "developer-toolbar-show", + + /** DeveloperToolbar.hide() has been called */ + HIDE: "developer-toolbar-hide" +}; + +/** + * Attach notification constants to the object prototype so tests etc can + * use them without needing to import anything + */ +DeveloperToolbar.prototype.NOTIFICATIONS = NOTIFICATIONS; + +/** + * Is the toolbar open? + */ +Object.defineProperty(DeveloperToolbar.prototype, 'visible', { + get: function DT_visible() { + return !this._element.hidden; + }, + enumerable: true +}); + +let _gSequenceId = 0; + +/** + * Getter for a unique ID. + */ +Object.defineProperty(DeveloperToolbar.prototype, 'sequenceId', { + get: function DT_visible() { + return _gSequenceId++; + }, + enumerable: true +}); + +/** + * Called from browser.xul in response to menu-click or keyboard shortcut to + * toggle the toolbar + */ +DeveloperToolbar.prototype.toggle = function DT_toggle() +{ + if (this.visible) { + this.hide(); + } else { + this.show(true); + } +}; + +/** + * Called from browser.xul in response to menu-click or keyboard shortcut to + * toggle the toolbar + */ +DeveloperToolbar.prototype.focus = function DT_focus() +{ + if (this.visible) { + this._input.focus(); + } else { + this.show(true); + } +}; + +/** + * Called from browser.xul in response to menu-click or keyboard shortcut to + * toggle the toolbar + */ +DeveloperToolbar.prototype.focusToggle = function DT_focusToggle() +{ + if (this.visible) { + // If we have focus then the active element is the HTML input contained + // inside the xul input element + let active = this._chromeWindow.document.activeElement; + let position = this._input.compareDocumentPosition(active); + if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) { + this.hide(); + } + else { + this._input.focus(); + } + } else { + this.show(true); + } +}; + +/** + * Even if the user has not clicked on 'Got it' in the intro, we only show it + * once per session. + * Warning this is slightly messed up because this.DeveloperToolbar is not the + * same as this.DeveloperToolbar when in browser.js context. + */ +DeveloperToolbar.introShownThisSession = false; + +/** + * Show the developer toolbar + * @param aCallback show events can be asynchronous. If supplied aCallback will + * be called when the DeveloperToolbar is visible + */ +DeveloperToolbar.prototype.show = function DT_show(aFocus, aCallback) +{ + if (this._lastState != NOTIFICATIONS.HIDE) { + return; + } + + Services.prefs.setBoolPref("devtools.toolbar.visible", true); + + this._telemetry.toolOpened("developertoolbar"); + + this._notify(NOTIFICATIONS.LOAD); + this._pendingShowCallback = aCallback; + this._pendingHide = false; + + let checkLoad = function() { + if (this.tooltipPanel && this.tooltipPanel.loaded && + this.outputPanel && this.outputPanel.loaded) { + this._onload(aFocus); + } + }.bind(this); + + this._input = this._doc.querySelector(".gclitoolbar-input-node"); + this.tooltipPanel = new TooltipPanel(this._doc, this._input, checkLoad); + this.outputPanel = new OutputPanel(this, checkLoad); +}; + +/** + * Initializing GCLI can only be done when we've got content windows to write + * to, so this needs to be done asynchronously. + */ +DeveloperToolbar.prototype._onload = function DT_onload(aFocus) +{ + this._doc.getElementById("Tools:DevToolbar").setAttribute("checked", "true"); + + let contentDocument = this._chromeWindow.getBrowser().contentDocument; + + this.display = gcli.createDisplay({ + contentDocument: contentDocument, + chromeDocument: this._doc, + chromeWindow: this._chromeWindow, + hintElement: this.tooltipPanel.hintElement, + inputElement: this._input, + completeElement: this._doc.querySelector(".gclitoolbar-complete-node"), + backgroundElement: this._doc.querySelector(".gclitoolbar-stack-node"), + outputDocument: this.outputPanel.document, + environment: CommandUtils.createEnvironment(this._doc, contentDocument), + tooltipClass: 'gcliterm-tooltip', + eval: null, + scratchpad: null + }); + + this.display.focusManager.addMonitoredElement(this.outputPanel._frame); + this.display.focusManager.addMonitoredElement(this._element); + + this.display.onVisibilityChange.add(this.outputPanel._visibilityChanged, + this.outputPanel); + this.display.onVisibilityChange.add(this.tooltipPanel._visibilityChanged, + this.tooltipPanel); + this.display.onOutput.add(this.outputPanel._outputChanged, this.outputPanel); + + let tabbrowser = this._chromeWindow.getBrowser(); + tabbrowser.tabContainer.addEventListener("TabSelect", this, false); + tabbrowser.tabContainer.addEventListener("TabClose", this, false); + tabbrowser.addEventListener("load", this, true); + tabbrowser.addEventListener("beforeunload", this, true); + + this._initErrorsCount(tabbrowser.selectedTab); + + this._element.hidden = false; + + if (aFocus) { + this._input.focus(); + } + + this._notify(NOTIFICATIONS.SHOW); + if (this._pendingShowCallback) { + this._pendingShowCallback.call(); + this._pendingShowCallback = undefined; + } + + // If a hide event happened while we were loading, then we need to hide. + // We could make this check earlier, but then cleanup would be complex so + // we're being inefficient for now. + if (this._pendingHide) { + this.hide(); + return; + } + + if (!DeveloperToolbar.introShownThisSession) { + this.display.maybeShowIntro(); + DeveloperToolbar.introShownThisSession = true; + } +}; + +/** + * Initialize the listeners needed for tracking the number of errors for a given + * tab. + * + * @private + * @param nsIDOMNode aTab the xul:tab for which you want to track the number of + * errors. + */ +DeveloperToolbar.prototype._initErrorsCount = function DT__initErrorsCount(aTab) +{ + let tabId = aTab.linkedPanel; + if (tabId in this._errorsCount) { + this._updateErrorsCount(); + return; + } + + let window = aTab.linkedBrowser.contentWindow; + let listener = new ConsoleServiceListener(window, { + onConsoleServiceMessage: this._onPageError.bind(this, tabId), + }); + listener.init(); + + this._errorListeners[tabId] = listener; + this._errorsCount[tabId] = 0; + this._warningsCount[tabId] = 0; + + let messages = listener.getCachedMessages(); + messages.forEach(this._onPageError.bind(this, tabId)); + + this._updateErrorsCount(); +}; + +/** + * Stop the listeners needed for tracking the number of errors for a given + * tab. + * + * @private + * @param nsIDOMNode aTab the xul:tab for which you want to stop tracking the + * number of errors. + */ +DeveloperToolbar.prototype._stopErrorsCount = function DT__stopErrorsCount(aTab) +{ + let tabId = aTab.linkedPanel; + if (!(tabId in this._errorsCount) || !(tabId in this._warningsCount)) { + this._updateErrorsCount(); + return; + } + + this._errorListeners[tabId].destroy(); + delete this._errorListeners[tabId]; + delete this._errorsCount[tabId]; + delete this._warningsCount[tabId]; + + this._updateErrorsCount(); +}; + +/** + * Hide the developer toolbar. + */ +DeveloperToolbar.prototype.hide = function DT_hide() +{ + if (this._lastState == NOTIFICATIONS.HIDE) { + return; + } + + if (this._lastState == NOTIFICATIONS.LOAD) { + this._pendingHide = true; + return; + } + + this._element.hidden = true; + + Services.prefs.setBoolPref("devtools.toolbar.visible", false); + + this._doc.getElementById("Tools:DevToolbar").setAttribute("checked", "false"); + this.destroy(); + + this._telemetry.toolClosed("developertoolbar"); + this._notify(NOTIFICATIONS.HIDE); +}; + +/** + * Hide the developer toolbar + */ +DeveloperToolbar.prototype.destroy = function DT_destroy() +{ + if (this._lastState == NOTIFICATIONS.HIDE) { + return; + } + + let tabbrowser = this._chromeWindow.getBrowser(); + tabbrowser.tabContainer.removeEventListener("TabSelect", this, false); + tabbrowser.tabContainer.removeEventListener("TabClose", this, false); + tabbrowser.removeEventListener("load", this, true); + tabbrowser.removeEventListener("beforeunload", this, true); + + Array.prototype.forEach.call(tabbrowser.tabs, this._stopErrorsCount, this); + + this.display.focusManager.removeMonitoredElement(this.outputPanel._frame); + this.display.focusManager.removeMonitoredElement(this._element); + + this.display.onVisibilityChange.remove(this.outputPanel._visibilityChanged, this.outputPanel); + this.display.onVisibilityChange.remove(this.tooltipPanel._visibilityChanged, this.tooltipPanel); + this.display.onOutput.remove(this.outputPanel._outputChanged, this.outputPanel); + this.display.destroy(); + this.outputPanel.destroy(); + this.tooltipPanel.destroy(); + delete this._input; + + // We could "delete this.display" etc if we have hard-to-track-down memory + // leaks as a belt-and-braces approach, however this prevents our DOM node + // hunter from looking in all the nooks and crannies, so it's better if we + // can be leak-free without + /* + delete this.display; + delete this.outputPanel; + delete this.tooltipPanel; + */ + + this._lastState = NOTIFICATIONS.HIDE; +}; + +/** + * Utility for sending notifications + * @param aTopic a NOTIFICATION constant + */ +DeveloperToolbar.prototype._notify = function DT_notify(aTopic) +{ + this._lastState = aTopic; + + let data = { toolbar: this }; + data.wrappedJSObject = data; + Services.obs.notifyObservers(data, aTopic, null); +}; + +/** + * Update various parts of the UI when the current tab changes + * @param aEvent + */ +DeveloperToolbar.prototype.handleEvent = function DT_handleEvent(aEvent) +{ + if (aEvent.type == "TabSelect" || aEvent.type == "load") { + if (this.visible) { + let contentDocument = this._chromeWindow.getBrowser().contentDocument; + + this.display.reattach({ + contentDocument: contentDocument, + chromeWindow: this._chromeWindow, + environment: CommandUtils.createEnvironment(this._doc, contentDocument), + }); + + if (aEvent.type == "TabSelect") { + this._initErrorsCount(aEvent.target); + } + } + } + else if (aEvent.type == "TabClose") { + this._stopErrorsCount(aEvent.target); + } + else if (aEvent.type == "beforeunload") { + this._onPageBeforeUnload(aEvent); + } +}; + +/** + * Count a page error received for the currently selected tab. This + * method counts the JavaScript exceptions received and CSS errors/warnings. + * + * @private + * @param string aTabId the ID of the tab from where the page error comes. + * @param object aPageError the page error object received from the + * PageErrorListener. + */ +DeveloperToolbar.prototype._onPageError = +function DT__onPageError(aTabId, aPageError) +{ + if (aPageError.category == "CSS Parser" || + aPageError.category == "CSS Loader") { + return; + } + if ((aPageError.flags & aPageError.warningFlag) || + (aPageError.flags & aPageError.strictFlag)) { + this._warningsCount[aTabId]++; + } else { + this._errorsCount[aTabId]++; + } + this._updateErrorsCount(aTabId); +}; + +/** + * The |beforeunload| event handler. This function resets the errors count when + * a different page starts loading. + * + * @private + * @param nsIDOMEvent aEvent the beforeunload DOM event. + */ +DeveloperToolbar.prototype._onPageBeforeUnload = +function DT__onPageBeforeUnload(aEvent) +{ + let window = aEvent.target.defaultView; + if (window.top !== window) { + return; + } + + let tabs = this._chromeWindow.getBrowser().tabs; + Array.prototype.some.call(tabs, function(aTab) { + if (aTab.linkedBrowser.contentWindow === window) { + let tabId = aTab.linkedPanel; + if (tabId in this._errorsCount || tabId in this._warningsCount) { + this._errorsCount[tabId] = 0; + this._warningsCount[tabId] = 0; + this._updateErrorsCount(tabId); + } + return true; + } + return false; + }, this); +}; + +/** + * Update the page errors count displayed in the Web Console button for the + * currently selected tab. + * + * @private + * @param string [aChangedTabId] Optional. The tab ID that had its page errors + * count changed. If this is provided and it doesn't match the currently + * selected tab, then the button is not updated. + */ +DeveloperToolbar.prototype._updateErrorsCount = +function DT__updateErrorsCount(aChangedTabId) +{ + let tabId = this._chromeWindow.getBrowser().selectedTab.linkedPanel; + if (aChangedTabId && tabId != aChangedTabId) { + return; + } + + let errors = this._errorsCount[tabId]; + let warnings = this._warningsCount[tabId]; + let btn = this._errorCounterButton; + if (errors) { + let errorsText = toolboxStrings + .GetStringFromName("toolboxToggleButton.errors"); + errorsText = PluralForm.get(errors, errorsText).replace("#1", errors); + + let warningsText = toolboxStrings + .GetStringFromName("toolboxToggleButton.warnings"); + warningsText = PluralForm.get(warnings, warningsText).replace("#1", warnings); + + let tooltiptext = toolboxStrings + .formatStringFromName("toolboxToggleButton.tooltip", + [errorsText, warningsText], 2); + + btn.setAttribute("error-count", errors); + btn.setAttribute("tooltiptext", tooltiptext); + } else { + btn.removeAttribute("error-count"); + btn.setAttribute("tooltiptext", btn._defaultTooltipText); + } + + this.emit("errors-counter-updated"); +}; + +/** + * Reset the errors counter for the given tab. + * + * @param nsIDOMElement aTab The xul:tab for which you want to reset the page + * errors counters. + */ +DeveloperToolbar.prototype.resetErrorsCount = +function DT_resetErrorsCount(aTab) +{ + let tabId = aTab.linkedPanel; + if (tabId in this._errorsCount || tabId in this._warningsCount) { + this._errorsCount[tabId] = 0; + this._warningsCount[tabId] = 0; + this._updateErrorsCount(tabId); + } +}; + +/** + * Panel to handle command line output. + * + * There is a tooltip bug on Windows and OSX that prevents tooltips from being + * positioned properly (bug 786975). There is a Gnome panel bug on Linux that + * causes ugly focus issues (https://bugzilla.gnome.org/show_bug.cgi?id=621848). + * We now use a tooltip on Linux and a panel on OSX & Windows. + * + * If a panel has no content and no height it is not shown when openPopup is + * called on Windows and OSX (bug 692348) ... this prevents the panel from + * appearing the first time it is shown. Setting the panel's height to 1px + * before calling openPopup works around this issue as we resize it ourselves + * anyway. + * + * @param aChromeDoc document from which we can pull the parts we need. + * @param aInput the input element that should get focus. + * @param aLoadCallback called when the panel is loaded properly. + */ +function OutputPanel(aDevToolbar, aLoadCallback) +{ + this._devtoolbar = aDevToolbar; + this._input = this._devtoolbar._input; + this._toolbar = this._devtoolbar._doc.getElementById("developer-toolbar"); + + this._loadCallback = aLoadCallback; + + /* + <tooltip|panel id="gcli-output" + noautofocus="true" + noautohide="true" + class="gcli-panel"> + <html:iframe xmlns:html="http://www.w3.org/1999/xhtml" + id="gcli-output-frame" + src="chrome://browser/content/devtools/commandlineoutput.xhtml" + sandbox="allow-same-origin"/> + </tooltip|panel> + */ + + // TODO: Switch back from tooltip to panel when metacity focus issue is fixed: + // https://bugzilla.mozilla.org/show_bug.cgi?id=780102 + this._panel = this._devtoolbar._doc.createElement(isLinux ? "tooltip" : "panel"); + + this._panel.id = "gcli-output"; + this._panel.classList.add("gcli-panel"); + + if (isLinux) { + this.canHide = false; + this._onpopuphiding = this._onpopuphiding.bind(this); + this._panel.addEventListener("popuphiding", this._onpopuphiding, true); + } else { + this._panel.setAttribute("noautofocus", "true"); + this._panel.setAttribute("noautohide", "true"); + + // Bug 692348: On Windows and OSX if a panel has no content and no height + // openPopup fails to display it. Setting the height to 1px alows the panel + // to be displayed before has content or a real height i.e. the first time + // it is displayed. + this._panel.setAttribute("height", "1px"); + } + + this._toolbar.parentElement.insertBefore(this._panel, this._toolbar); + + this._frame = this._devtoolbar._doc.createElementNS(NS_XHTML, "iframe"); + this._frame.id = "gcli-output-frame"; + this._frame.setAttribute("src", "chrome://browser/content/devtools/commandlineoutput.xhtml"); + this._frame.setAttribute("sandbox", "allow-same-origin"); + this._panel.appendChild(this._frame); + + this.displayedOutput = undefined; + + this._onload = this._onload.bind(this); + this._update = this._update.bind(this); + this._frame.addEventListener("load", this._onload, true); + + this.loaded = false; +} + +/** + * Wire up the element from the iframe, and inform the _loadCallback. + */ +OutputPanel.prototype._onload = function OP_onload() +{ + this._frame.removeEventListener("load", this._onload, true); + delete this._onload; + + this.document = this._frame.contentDocument; + + this._div = this.document.getElementById("gcli-output-root"); + this._div.classList.add('gcli-row-out'); + this._div.setAttribute('aria-live', 'assertive'); + + let styles = this._toolbar.ownerDocument.defaultView + .getComputedStyle(this._toolbar); + this._div.setAttribute("dir", styles.direction); + + this.loaded = true; + if (this._loadCallback) { + this._loadCallback(); + delete this._loadCallback; + } +}; + +/** + * Prevent the popup from hiding if it is not permitted via this.canHide. + */ +OutputPanel.prototype._onpopuphiding = function OP_onpopuphiding(aEvent) +{ + // TODO: When we switch back from tooltip to panel we can remove this hack: + // https://bugzilla.mozilla.org/show_bug.cgi?id=780102 + if (isLinux && !this.canHide) { + aEvent.preventDefault(); + } +}; + +/** + * Display the OutputPanel. + */ +OutputPanel.prototype.show = function OP_show() +{ + if (isLinux) { + this.canHide = false; + } + + // We need to reset the iframe size in order for future size calculations to + // be correct + this._frame.style.minHeight = this._frame.style.maxHeight = 0; + this._frame.style.minWidth = 0; + + this._panel.openPopup(this._input, "before_start", 0, 0, false, false, null); + this._resize(); + + this._input.focus(); +}; + +/** + * Internal helper to set the height of the output panel to fit the available + * content; + */ +OutputPanel.prototype._resize = function CLP_resize() +{ + if (this._panel == null || this.document == null || !this._panel.state == "closed") { + return + } + + // Set max panel width to match any content with a max of the width of the + // browser window. + let maxWidth = this._panel.ownerDocument.documentElement.clientWidth; + + // Adjust max width according to OS. + // We'd like to put this in CSS but we can't: + // body { width: calc(min(-5px, max-content)); } + // #_panel { max-width: -5px; } + switch(OS) { + case "Linux": + maxWidth -= 5; + break; + case "Darwin": + maxWidth -= 25; + break; + case "WINNT": + maxWidth -= 5; + break; + } + + this.document.body.style.width = "-moz-max-content"; + let style = this._frame.contentWindow.getComputedStyle(this.document.body); + let frameWidth = parseInt(style.width, 10); + let width = Math.min(maxWidth, frameWidth); + this.document.body.style.width = width + "px"; + + // Set the width of the iframe. + this._frame.style.minWidth = width + "px"; + this._panel.style.maxWidth = maxWidth + "px"; + + // browserAdjustment is used to correct the panel height according to the + // browsers borders etc. + const browserAdjustment = 15; + + // Set max panel height to match any content with a max of the height of the + // browser window. + let maxHeight = + this._panel.ownerDocument.documentElement.clientHeight - browserAdjustment; + let height = Math.min(maxHeight, this.document.documentElement.scrollHeight); + + // Set the height of the iframe. Setting iframe.height does not work. + this._frame.style.minHeight = this._frame.style.maxHeight = height + "px"; + + // Set the height and width of the panel to match the iframe. + this._panel.sizeTo(width, height); + + // Move the panel to the correct position in the case that it has been + // positioned incorrectly. + let screenX = this._input.boxObject.screenX; + let screenY = this._toolbar.boxObject.screenY; + this._panel.moveTo(screenX, screenY - height); +}; + +/** + * Called by GCLI when a command is executed. + */ +OutputPanel.prototype._outputChanged = function OP_outputChanged(aEvent) +{ + if (aEvent.output.hidden) { + return; + } + + this.remove(); + + this.displayedOutput = aEvent.output; + this.displayedOutput.onClose.add(this.remove, this); + + if (this.displayedOutput.completed) { + this._update(); + } + else { + this.displayedOutput.promise.then(this._update, this._update) + .then(null, console.error); + } +}; + +/** + * Called when displayed Output says it's changed or from outputChanged, which + * happens when there is a new displayed Output. + */ +OutputPanel.prototype._update = function OP_update() +{ + // destroy has been called, bail out + if (this._div == null) { + return; + } + + // Empty this._div + while (this._div.hasChildNodes()) { + this._div.removeChild(this._div.firstChild); + } + + if (this.displayedOutput.data != null) { + let requisition = this._devtoolbar.display.requisition; + let nodePromise = converters.convert(this.displayedOutput.data, + this.displayedOutput.type, 'dom', + requisition.conversionContext); + nodePromise.then(function(node) { + while (this._div.hasChildNodes()) { + this._div.removeChild(this._div.firstChild); + } + + var links = node.ownerDocument.querySelectorAll('*[href]'); + for (var i = 0; i < links.length; i++) { + links[i].setAttribute('target', '_blank'); + } + + this._div.appendChild(node); + }.bind(this)); + this.show(); + } +}; + +/** + * Detach listeners from the currently displayed Output. + */ +OutputPanel.prototype.remove = function OP_remove() +{ + if (isLinux) { + this.canHide = true; + } + + if (this._panel && this._panel.hidePopup) { + this._panel.hidePopup(); + } + + if (this.displayedOutput) { + this.displayedOutput.onClose.remove(this.remove, this); + delete this.displayedOutput; + } +}; + +/** + * Detach listeners from the currently displayed Output. + */ +OutputPanel.prototype.destroy = function OP_destroy() +{ + this.remove(); + + this._panel.removeEventListener("popuphiding", this._onpopuphiding, true); + + this._panel.removeChild(this._frame); + this._toolbar.parentElement.removeChild(this._panel); + + delete this._devtoolbar; + delete this._input; + delete this._toolbar; + delete this._onload; + delete this._onpopuphiding; + delete this._panel; + delete this._frame; + delete this._content; + delete this._div; + delete this.document; +}; + +/** + * Called by GCLI to indicate that we should show or hide one either the + * tooltip panel or the output panel. + */ +OutputPanel.prototype._visibilityChanged = function OP_visibilityChanged(aEvent) +{ + if (aEvent.outputVisible === true) { + // this.show is called by _outputChanged + } else { + if (isLinux) { + this.canHide = true; + } + this._panel.hidePopup(); + } +}; + + +/** + * Panel to handle tooltips. + * + * There is a tooltip bug on Windows and OSX that prevents tooltips from being + * positioned properly (bug 786975). There is a Gnome panel bug on Linux that + * causes ugly focus issues (https://bugzilla.gnome.org/show_bug.cgi?id=621848). + * We now use a tooltip on Linux and a panel on OSX & Windows. + * + * If a panel has no content and no height it is not shown when openPopup is + * called on Windows and OSX (bug 692348) ... this prevents the panel from + * appearing the first time it is shown. Setting the panel's height to 1px + * before calling openPopup works around this issue as we resize it ourselves + * anyway. + * + * @param aChromeDoc document from which we can pull the parts we need. + * @param aInput the input element that should get focus. + * @param aLoadCallback called when the panel is loaded properly. + */ +function TooltipPanel(aChromeDoc, aInput, aLoadCallback) +{ + this._input = aInput; + this._toolbar = aChromeDoc.getElementById("developer-toolbar"); + this._dimensions = { start: 0, end: 0 }; + + this._onload = this._onload.bind(this); + this._loadCallback = aLoadCallback; + /* + <tooltip|panel id="gcli-tooltip" + type="arrow" + noautofocus="true" + noautohide="true" + class="gcli-panel"> + <html:iframe xmlns:html="http://www.w3.org/1999/xhtml" + id="gcli-tooltip-frame" + src="chrome://browser/content/devtools/commandlinetooltip.xhtml" + flex="1" + sandbox="allow-same-origin"/> + </tooltip|panel> + */ + + // TODO: Switch back from tooltip to panel when metacity focus issue is fixed: + // https://bugzilla.mozilla.org/show_bug.cgi?id=780102 + this._panel = aChromeDoc.createElement(isLinux ? "tooltip" : "panel"); + + this._panel.id = "gcli-tooltip"; + this._panel.classList.add("gcli-panel"); + + if (isLinux) { + this.canHide = false; + this._onpopuphiding = this._onpopuphiding.bind(this); + this._panel.addEventListener("popuphiding", this._onpopuphiding, true); + } else { + this._panel.setAttribute("noautofocus", "true"); + this._panel.setAttribute("noautohide", "true"); + + // Bug 692348: On Windows and OSX if a panel has no content and no height + // openPopup fails to display it. Setting the height to 1px alows the panel + // to be displayed before has content or a real height i.e. the first time + // it is displayed. + this._panel.setAttribute("height", "1px"); + } + + this._toolbar.parentElement.insertBefore(this._panel, this._toolbar); + + this._frame = aChromeDoc.createElementNS(NS_XHTML, "iframe"); + this._frame.id = "gcli-tooltip-frame"; + this._frame.setAttribute("src", "chrome://browser/content/devtools/commandlinetooltip.xhtml"); + this._frame.setAttribute("flex", "1"); + this._frame.setAttribute("sandbox", "allow-same-origin"); + this._panel.appendChild(this._frame); + + this._frame.addEventListener("load", this._onload, true); + + this.loaded = false; +} + +/** + * Wire up the element from the iframe, and inform the _loadCallback. + */ +TooltipPanel.prototype._onload = function TP_onload() +{ + this._frame.removeEventListener("load", this._onload, true); + + this.document = this._frame.contentDocument; + this.hintElement = this.document.getElementById("gcli-tooltip-root"); + this._connector = this.document.getElementById("gcli-tooltip-connector"); + + let styles = this._toolbar.ownerDocument.defaultView + .getComputedStyle(this._toolbar); + this.hintElement.setAttribute("dir", styles.direction); + + this.loaded = true; + + if (this._loadCallback) { + this._loadCallback(); + delete this._loadCallback; + } +}; + +/** + * Prevent the popup from hiding if it is not permitted via this.canHide. + */ +TooltipPanel.prototype._onpopuphiding = function TP_onpopuphiding(aEvent) +{ + // TODO: When we switch back from tooltip to panel we can remove this hack: + // https://bugzilla.mozilla.org/show_bug.cgi?id=780102 + if (isLinux && !this.canHide) { + aEvent.preventDefault(); + } +}; + +/** + * Display the TooltipPanel. + */ +TooltipPanel.prototype.show = function TP_show(aDimensions) +{ + if (!aDimensions) { + aDimensions = { start: 0, end: 0 }; + } + this._dimensions = aDimensions; + + // This is nasty, but displaying the panel causes it to re-flow, which can + // change the size it should be, so we need to resize the iframe after the + // panel has displayed + this._panel.ownerDocument.defaultView.setTimeout(function() { + this._resize(); + }.bind(this), 0); + + if (isLinux) { + this.canHide = false; + } + + this._resize(); + this._panel.openPopup(this._input, "before_start", aDimensions.start * 10, 0, false, false, null); + this._input.focus(); +}; + +/** + * One option is to spend lots of time taking an average width of characters + * in the current font, dynamically, and weighting for the frequency of use of + * various characters, or even to render the given string off screen, and then + * measure the width. + * Or we could do this... + */ +const AVE_CHAR_WIDTH = 4.5; + +/** + * Display the TooltipPanel. + */ +TooltipPanel.prototype._resize = function TP_resize() +{ + if (this._panel == null || this.document == null || !this._panel.state == "closed") { + return + } + + let offset = 10 + Math.floor(this._dimensions.start * AVE_CHAR_WIDTH); + this._panel.style.marginLeft = offset + "px"; + + /* + // Bug 744906: UX review - Not sure if we want this code to fatten connector + // with param width + let width = Math.floor(this._dimensions.end * AVE_CHAR_WIDTH); + width = Math.min(width, 100); + width = Math.max(width, 10); + this._connector.style.width = width + "px"; + */ + + this._frame.height = this.document.body.scrollHeight; +}; + +/** + * Hide the TooltipPanel. + */ +TooltipPanel.prototype.remove = function TP_remove() +{ + if (isLinux) { + this.canHide = true; + } + if (this._panel && this._panel.hidePopup) { + this._panel.hidePopup(); + } +}; + +/** + * Hide the TooltipPanel. + */ +TooltipPanel.prototype.destroy = function TP_destroy() +{ + this.remove(); + + this._panel.removeEventListener("popuphiding", this._onpopuphiding, true); + + this._panel.removeChild(this._frame); + this._toolbar.parentElement.removeChild(this._panel); + + delete this._connector; + delete this._dimensions; + delete this._input; + delete this._onload; + delete this._onpopuphiding; + delete this._panel; + delete this._frame; + delete this._toolbar; + delete this._content; + delete this.document; + delete this.hintElement; +}; + +/** + * Called by GCLI to indicate that we should show or hide one either the + * tooltip panel or the output panel. + */ +TooltipPanel.prototype._visibilityChanged = function TP_visibilityChanged(aEvent) +{ + if (aEvent.tooltipVisible === true) { + this.show(aEvent.dimensions); + } else { + if (isLinux) { + this.canHide = true; + } + this._panel.hidePopup(); + } +}; diff --git a/browser/devtools/shared/FloatingScrollbars.jsm b/browser/devtools/shared/FloatingScrollbars.jsm new file mode 100644 index 000000000..a47a784bd --- /dev/null +++ b/browser/devtools/shared/FloatingScrollbars.jsm @@ -0,0 +1,126 @@ +/* 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 } = Components; + +this.EXPORTED_SYMBOLS = [ "switchToFloatingScrollbars", "switchToNativeScrollbars" ]; + +Cu.import("resource://gre/modules/Services.jsm"); + +let URL = Services.io.newURI("chrome://browser/skin/devtools/floating-scrollbars.css", null, null); + +let trackedTabs = new WeakMap(); + +/** + * Switch to floating scrollbars, à la mobile. + * + * @param aTab the targeted tab. + * + */ +this.switchToFloatingScrollbars = function switchToFloatingScrollbars(aTab) { + let mgr = trackedTabs.get(aTab); + if (!mgr) { + mgr = new ScrollbarManager(aTab); + } + mgr.switchToFloating(); +} + +/** + * Switch to original native scrollbars. + * + * @param aTab the targeted tab. + * + */ +this.switchToNativeScrollbars = function switchToNativeScrollbars(aTab) { + let mgr = trackedTabs.get(aTab); + if (mgr) { + mgr.reset(); + } +} + +function ScrollbarManager(aTab) { + trackedTabs.set(aTab, this); + + this.attachedTab = aTab; + this.attachedBrowser = aTab.linkedBrowser; + + this.reset = this.reset.bind(this); + this.switchToFloating = this.switchToFloating.bind(this); + + this.attachedTab.addEventListener("TabClose", this.reset, true); + this.attachedBrowser.addEventListener("DOMContentLoaded", this.switchToFloating, true); +} + +ScrollbarManager.prototype = { + get win() { + return this.attachedBrowser.contentWindow; + }, + + /* + * Change the look of the scrollbars. + */ + switchToFloating: function() { + let windows = this.getInnerWindows(this.win); + windows.forEach(this.injectStyleSheet); + this.forceStyle(); + }, + + + /* + * Reset the look of the scrollbars. + */ + reset: function() { + let windows = this.getInnerWindows(this.win); + windows.forEach(this.removeStyleSheet); + this.forceStyle(this.attachedBrowser); + this.attachedBrowser.removeEventListener("DOMContentLoaded", this.switchToFloating, true); + this.attachedTab.removeEventListener("TabClose", this.reset, true); + trackedTabs.delete(this.attachedTab); + }, + + /* + * Toggle the display property of the window to force the style to be applied. + */ + forceStyle: function() { + let parentWindow = this.attachedBrowser.ownerDocument.defaultView; + let display = parentWindow.getComputedStyle(this.attachedBrowser).display; // Save display value + this.attachedBrowser.style.display = "none"; + parentWindow.getComputedStyle(this.attachedBrowser).display; // Flush + this.attachedBrowser.style.display = display; // Restore + }, + + /* + * return all the window objects present in the hiearchy of a window. + */ + getInnerWindows: function(win) { + let iframes = win.document.querySelectorAll("iframe"); + let innerWindows = []; + for (let iframe of iframes) { + innerWindows = innerWindows.concat(this.getInnerWindows(iframe.contentWindow)); + } + return [win].concat(innerWindows); + }, + + /* + * Append the new scrollbar style. + */ + injectStyleSheet: function(win) { + let winUtils = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + try { + winUtils.loadSheet(URL, win.AGENT_SHEET); + }catch(e) {} + }, + + /* + * Remove the injected stylesheet. + */ + removeStyleSheet: function(win) { + let winUtils = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils); + try { + winUtils.removeSheet(URL, win.AGENT_SHEET); + }catch(e) {} + }, +} diff --git a/browser/devtools/shared/Jsbeautify.jsm b/browser/devtools/shared/Jsbeautify.jsm new file mode 100644 index 000000000..4171d5746 --- /dev/null +++ b/browser/devtools/shared/Jsbeautify.jsm @@ -0,0 +1,1303 @@ +/*jslint onevar: false, plusplus: false */ +/*jshint curly:true, eqeqeq:true, laxbreak:true, noempty:false */ +/* + + JS Beautifier +--------------- + + + Written by Einar Lielmanis, <einar@jsbeautifier.org> + http://jsbeautifier.org/ + + Originally converted to javascript by Vital, <vital76@gmail.com> + "End braces on own line" added by Chris J. Shull, <chrisjshull@gmail.com> + + You are free to use this in any way you want, in case you find this useful or working for you. + + Usage: + js_beautify(js_source_text); + js_beautify(js_source_text, options); + + The options are: + indent_size (default 4) - indentation size, + indent_char (default space) - character to indent with, + preserve_newlines (default true) - whether existing line breaks should be preserved, + max_preserve_newlines (default unlimited) - maximum number of line breaks to be preserved in one chunk, + + jslint_happy (default false) - if true, then jslint-stricter mode is enforced. + + jslint_happy !jslint_happy + --------------------------------- + function () function() + + brace_style (default "collapse") - "collapse" | "expand" | "end-expand" | "expand-strict" + put braces on the same line as control statements (default), or put braces on own line (Allman / ANSI style), or just put end braces on own line. + + expand-strict: put brace on own line even in such cases: + + var a = + { + a: 5, + b: 6 + } + This mode may break your scripts - e.g "return { a: 1 }" will be broken into two lines, so beware. + + space_before_conditional (default true) - should the space before conditional statement be added, "if(true)" vs "if (true)", + + unescape_strings (default false) - should printable characters in strings encoded in \xNN notation be unescaped, "example" vs "\x65\x78\x61\x6d\x70\x6c\x65" + + e.g + + js_beautify(js_source_text, { + 'indent_size': 1, + 'indent_char': '\t' + }); + + +*/ + +this.EXPORTED_SYMBOLS = ["js_beautify"]; + +this.js_beautify = function js_beautify(js_source_text, options) { + + var input, output, token_text, last_type, last_text, last_last_text, last_word, flags, flag_store, indent_string; + var whitespace, wordchar, punct, parser_pos, line_starters, digits; + var prefix, token_type, do_block_just_closed; + var wanted_newline, just_added_newline, n_newlines; + var preindent_string = ''; + + + // Some interpreters have unexpected results with foo = baz || bar; + options = options ? options : {}; + + var opt_brace_style; + + // compatibility + if (options.space_after_anon_function !== undefined && options.jslint_happy === undefined) { + options.jslint_happy = options.space_after_anon_function; + } + if (options.braces_on_own_line !== undefined) { //graceful handling of deprecated option + opt_brace_style = options.braces_on_own_line ? "expand" : "collapse"; + } + opt_brace_style = options.brace_style ? options.brace_style : (opt_brace_style ? opt_brace_style : "collapse"); + + + var opt_indent_size = options.indent_size ? options.indent_size : 4; + var opt_indent_char = options.indent_char ? options.indent_char : ' '; + var opt_preserve_newlines = typeof options.preserve_newlines === 'undefined' ? true : options.preserve_newlines; + var opt_max_preserve_newlines = typeof options.max_preserve_newlines === 'undefined' ? false : options.max_preserve_newlines; + var opt_jslint_happy = options.jslint_happy === 'undefined' ? false : options.jslint_happy; + var opt_keep_array_indentation = typeof options.keep_array_indentation === 'undefined' ? false : options.keep_array_indentation; + var opt_space_before_conditional = typeof options.space_before_conditional === 'undefined' ? true : options.space_before_conditional; + var opt_indent_case = typeof options.indent_case === 'undefined' ? false : options.indent_case; + var opt_unescape_strings = typeof options.unescape_strings === 'undefined' ? false : options.unescape_strings; + + just_added_newline = false; + + // cache the source's length. + var input_length = js_source_text.length; + + function trim_output(eat_newlines) { + eat_newlines = typeof eat_newlines === 'undefined' ? false : eat_newlines; + while (output.length && (output[output.length - 1] === ' ' + || output[output.length - 1] === indent_string + || output[output.length - 1] === preindent_string + || (eat_newlines && (output[output.length - 1] === '\n' || output[output.length - 1] === '\r')))) { + output.pop(); + } + } + + function trim(s) { + return s.replace(/^\s\s*|\s\s*$/, ''); + } + + // we could use just string.split, but + // IE doesn't like returning empty strings + function split_newlines(s) { + return s.split(/\x0d\x0a|\x0a/); + } + + function force_newline() { + var old_keep_array_indentation = opt_keep_array_indentation; + opt_keep_array_indentation = false; + print_newline(); + opt_keep_array_indentation = old_keep_array_indentation; + } + + function print_newline(ignore_repeated) { + + flags.eat_next_space = false; + if (opt_keep_array_indentation && is_array(flags.mode)) { + return; + } + + ignore_repeated = typeof ignore_repeated === 'undefined' ? true : ignore_repeated; + + flags.if_line = false; + trim_output(); + + if (!output.length) { + return; // no newline on start of file + } + + if (output[output.length - 1] !== "\n" || !ignore_repeated) { + just_added_newline = true; + output.push("\n"); + } + if (preindent_string) { + output.push(preindent_string); + } + for (var i = 0; i < flags.indentation_level; i += 1) { + output.push(indent_string); + } + if (flags.var_line && flags.var_line_reindented) { + output.push(indent_string); // skip space-stuffing, if indenting with a tab + } + if (flags.case_body) { + output.push(indent_string); + } + } + + + + function print_single_space() { + + if (last_type === 'TK_COMMENT') { + return print_newline(); + } + if (flags.eat_next_space) { + flags.eat_next_space = false; + return; + } + var last_output = ' '; + if (output.length) { + last_output = output[output.length - 1]; + } + if (last_output !== ' ' && last_output !== '\n' && last_output !== indent_string) { // prevent occassional duplicate space + output.push(' '); + } + } + + + function print_token() { + just_added_newline = false; + flags.eat_next_space = false; + output.push(token_text); + } + + function indent() { + flags.indentation_level += 1; + } + + + function remove_indent() { + if (output.length && output[output.length - 1] === indent_string) { + output.pop(); + } + } + + function set_mode(mode) { + if (flags) { + flag_store.push(flags); + } + flags = { + previous_mode: flags ? flags.mode : 'BLOCK', + mode: mode, + var_line: false, + var_line_tainted: false, + var_line_reindented: false, + in_html_comment: false, + if_line: false, + in_case_statement: false, // switch(..){ INSIDE HERE } + in_case: false, // we're on the exact line with "case 0:" + case_body: false, // the indented case-action block + eat_next_space: false, + indentation_baseline: -1, + indentation_level: (flags ? flags.indentation_level + (flags.case_body ? 1 : 0) + ((flags.var_line && flags.var_line_reindented) ? 1 : 0) : 0), + ternary_depth: 0 + }; + } + + function is_array(mode) { + return mode === '[EXPRESSION]' || mode === '[INDENTED-EXPRESSION]'; + } + + function is_expression(mode) { + return in_array(mode, ['[EXPRESSION]', '(EXPRESSION)', '(FOR-EXPRESSION)', '(COND-EXPRESSION)']); + } + + function restore_mode() { + do_block_just_closed = flags.mode === 'DO_BLOCK'; + if (flag_store.length > 0) { + var mode = flags.mode; + flags = flag_store.pop(); + flags.previous_mode = mode; + } + } + + function all_lines_start_with(lines, c) { + for (var i = 0; i < lines.length; i++) { + var line = trim(lines[i]); + if (line.charAt(0) !== c) { + return false; + } + } + return true; + } + + function is_special_word(word) { + return in_array(word, ['case', 'return', 'do', 'if', 'throw', 'else']); + } + + function in_array(what, arr) { + for (var i = 0; i < arr.length; i += 1) { + if (arr[i] === what) { + return true; + } + } + return false; + } + + function look_up(exclude) { + var local_pos = parser_pos; + var c = input.charAt(local_pos); + while (in_array(c, whitespace) && c !== exclude) { + local_pos++; + if (local_pos >= input_length) { + return 0; + } + c = input.charAt(local_pos); + } + return c; + } + + function get_next_token() { + var i; + var resulting_string; + + n_newlines = 0; + + if (parser_pos >= input_length) { + return ['', 'TK_EOF']; + } + + wanted_newline = false; + + var c = input.charAt(parser_pos); + parser_pos += 1; + + + var keep_whitespace = opt_keep_array_indentation && is_array(flags.mode); + + if (keep_whitespace) { + + // + // slight mess to allow nice preservation of array indentation and reindent that correctly + // first time when we get to the arrays: + // var a = [ + // ....'something' + // we make note of whitespace_count = 4 into flags.indentation_baseline + // so we know that 4 whitespaces in original source match indent_level of reindented source + // + // and afterwards, when we get to + // 'something, + // .......'something else' + // we know that this should be indented to indent_level + (7 - indentation_baseline) spaces + // + var whitespace_count = 0; + + while (in_array(c, whitespace)) { + + if (c === "\n") { + trim_output(); + output.push("\n"); + just_added_newline = true; + whitespace_count = 0; + } else { + if (c === '\t') { + whitespace_count += 4; + } else if (c === '\r') { + // nothing + } else { + whitespace_count += 1; + } + } + + if (parser_pos >= input_length) { + return ['', 'TK_EOF']; + } + + c = input.charAt(parser_pos); + parser_pos += 1; + + } + if (flags.indentation_baseline === -1) { + flags.indentation_baseline = whitespace_count; + } + + if (just_added_newline) { + for (i = 0; i < flags.indentation_level + 1; i += 1) { + output.push(indent_string); + } + if (flags.indentation_baseline !== -1) { + for (i = 0; i < whitespace_count - flags.indentation_baseline; i++) { + output.push(' '); + } + } + } + + } else { + while (in_array(c, whitespace)) { + + if (c === "\n") { + n_newlines += ((opt_max_preserve_newlines) ? (n_newlines <= opt_max_preserve_newlines) ? 1 : 0 : 1); + } + + + if (parser_pos >= input_length) { + return ['', 'TK_EOF']; + } + + c = input.charAt(parser_pos); + parser_pos += 1; + + } + + if (opt_preserve_newlines) { + if (n_newlines > 1) { + for (i = 0; i < n_newlines; i += 1) { + print_newline(i === 0); + just_added_newline = true; + } + } + } + wanted_newline = n_newlines > 0; + } + + + if (in_array(c, wordchar)) { + if (parser_pos < input_length) { + while (in_array(input.charAt(parser_pos), wordchar)) { + c += input.charAt(parser_pos); + parser_pos += 1; + if (parser_pos === input_length) { + break; + } + } + } + + // small and surprisingly unugly hack for 1E-10 representation + if (parser_pos !== input_length && c.match(/^[0-9]+[Ee]$/) && (input.charAt(parser_pos) === '-' || input.charAt(parser_pos) === '+')) { + + var sign = input.charAt(parser_pos); + parser_pos += 1; + + var t = get_next_token(); + c += sign + t[0]; + return [c, 'TK_WORD']; + } + + if (c === 'in') { // hack for 'in' operator + return [c, 'TK_OPERATOR']; + } + if (wanted_newline && last_type !== 'TK_OPERATOR' + && last_type !== 'TK_EQUALS' + && !flags.if_line && (opt_preserve_newlines || last_text !== 'var')) { + print_newline(); + } + return [c, 'TK_WORD']; + } + + if (c === '(' || c === '[') { + return [c, 'TK_START_EXPR']; + } + + if (c === ')' || c === ']') { + return [c, 'TK_END_EXPR']; + } + + if (c === '{') { + return [c, 'TK_START_BLOCK']; + } + + if (c === '}') { + return [c, 'TK_END_BLOCK']; + } + + if (c === ';') { + return [c, 'TK_SEMICOLON']; + } + + if (c === '/') { + var comment = ''; + // peek for comment /* ... */ + var inline_comment = true; + if (input.charAt(parser_pos) === '*') { + parser_pos += 1; + if (parser_pos < input_length) { + while (parser_pos < input_length && + ! (input.charAt(parser_pos) === '*' && input.charAt(parser_pos + 1) && input.charAt(parser_pos + 1) === '/')) { + c = input.charAt(parser_pos); + comment += c; + if (c === "\n" || c === "\r") { + inline_comment = false; + } + parser_pos += 1; + if (parser_pos >= input_length) { + break; + } + } + } + parser_pos += 2; + if (inline_comment && n_newlines === 0) { + return ['/*' + comment + '*/', 'TK_INLINE_COMMENT']; + } else { + return ['/*' + comment + '*/', 'TK_BLOCK_COMMENT']; + } + } + // peek for comment // ... + if (input.charAt(parser_pos) === '/') { + comment = c; + while (input.charAt(parser_pos) !== '\r' && input.charAt(parser_pos) !== '\n') { + comment += input.charAt(parser_pos); + parser_pos += 1; + if (parser_pos >= input_length) { + break; + } + } + if (wanted_newline) { + print_newline(); + } + return [comment, 'TK_COMMENT']; + } + + } + + if (c === "'" || // string + c === '"' || // string + (c === '/' && + ((last_type === 'TK_WORD' && is_special_word(last_text)) || + (last_text === ')' && in_array(flags.previous_mode, ['(COND-EXPRESSION)', '(FOR-EXPRESSION)'])) || + (last_type === 'TK_COMMA' || last_type === 'TK_COMMENT' || last_type === 'TK_START_EXPR' || last_type === 'TK_START_BLOCK' || last_type === 'TK_END_BLOCK' || last_type === 'TK_OPERATOR' || last_type === 'TK_EQUALS' || last_type === 'TK_EOF' || last_type === 'TK_SEMICOLON')))) { // regexp + var sep = c; + var esc = false; + var esc1 = 0; + var esc2 = 0; + resulting_string = c; + + if (parser_pos < input_length) { + if (sep === '/') { + // + // handle regexp separately... + // + var in_char_class = false; + while (esc || in_char_class || input.charAt(parser_pos) !== sep) { + resulting_string += input.charAt(parser_pos); + if (!esc) { + esc = input.charAt(parser_pos) === '\\'; + if (input.charAt(parser_pos) === '[') { + in_char_class = true; + } else if (input.charAt(parser_pos) === ']') { + in_char_class = false; + } + } else { + esc = false; + } + parser_pos += 1; + if (parser_pos >= input_length) { + // incomplete string/rexp when end-of-file reached. + // bail out with what had been received so far. + return [resulting_string, 'TK_STRING']; + } + } + + } else { + // + // and handle string also separately + // + while (esc || input.charAt(parser_pos) !== sep) { + resulting_string += input.charAt(parser_pos); + if (esc1 && esc1 >= esc2) { + esc1 = parseInt(resulting_string.substr(-esc2), 16); + if (esc1 && esc1 >= 0x20 && esc1 <= 0x7e) { + esc1 = String.fromCharCode(esc1); + resulting_string = resulting_string.substr(0, resulting_string.length - esc2 - 2) + (((esc1 === sep) || (esc1 === '\\')) ? '\\' : '') + esc1; + } + esc1 = 0; + } + if (esc1) { + esc1++; + } else if (!esc) { + esc = input.charAt(parser_pos) === '\\'; + } else { + esc = false; + if (opt_unescape_strings) { + if (input.charAt(parser_pos) === 'x') { + esc1++; + esc2 = 2; + } else if (input.charAt(parser_pos) === 'u') { + esc1++; + esc2 = 4; + } + } + } + parser_pos += 1; + if (parser_pos >= input_length) { + // incomplete string/rexp when end-of-file reached. + // bail out with what had been received so far. + return [resulting_string, 'TK_STRING']; + } + } + } + + + + } + + parser_pos += 1; + + resulting_string += sep; + + if (sep === '/') { + // regexps may have modifiers /regexp/MOD , so fetch those, too + while (parser_pos < input_length && in_array(input.charAt(parser_pos), wordchar)) { + resulting_string += input.charAt(parser_pos); + parser_pos += 1; + } + } + return [resulting_string, 'TK_STRING']; + } + + if (c === '#') { + + + if (output.length === 0 && input.charAt(parser_pos) === '!') { + // shebang + resulting_string = c; + while (parser_pos < input_length && c !== '\n') { + c = input.charAt(parser_pos); + resulting_string += c; + parser_pos += 1; + } + output.push(trim(resulting_string) + '\n'); + print_newline(); + return get_next_token(); + } + + + + // Spidermonkey-specific sharp variables for circular references + // https://developer.mozilla.org/En/Sharp_variables_in_JavaScript + // http://mxr.mozilla.org/mozilla-central/source/js/src/jsscan.cpp around line 1935 + var sharp = '#'; + if (parser_pos < input_length && in_array(input.charAt(parser_pos), digits)) { + do { + c = input.charAt(parser_pos); + sharp += c; + parser_pos += 1; + } while (parser_pos < input_length && c !== '#' && c !== '='); + if (c === '#') { + // + } else if (input.charAt(parser_pos) === '[' && input.charAt(parser_pos + 1) === ']') { + sharp += '[]'; + parser_pos += 2; + } else if (input.charAt(parser_pos) === '{' && input.charAt(parser_pos + 1) === '}') { + sharp += '{}'; + parser_pos += 2; + } + return [sharp, 'TK_WORD']; + } + } + + if (c === '<' && input.substring(parser_pos - 1, parser_pos + 3) === '<!--') { + parser_pos += 3; + c = '<!--'; + while (input.charAt(parser_pos) !== '\n' && parser_pos < input_length) { + c += input.charAt(parser_pos); + parser_pos++; + } + flags.in_html_comment = true; + return [c, 'TK_COMMENT']; + } + + if (c === '-' && flags.in_html_comment && input.substring(parser_pos - 1, parser_pos + 2) === '-->') { + flags.in_html_comment = false; + parser_pos += 2; + if (wanted_newline) { + print_newline(); + } + return ['-->', 'TK_COMMENT']; + } + + if (in_array(c, punct)) { + while (parser_pos < input_length && in_array(c + input.charAt(parser_pos), punct)) { + c += input.charAt(parser_pos); + parser_pos += 1; + if (parser_pos >= input_length) { + break; + } + } + + if (c === ',') { + return [c, 'TK_COMMA']; + } else if (c === '=') { + return [c, 'TK_EQUALS']; + } else { + return [c, 'TK_OPERATOR']; + } + } + + return [c, 'TK_UNKNOWN']; + } + + //---------------------------------- + indent_string = ''; + while (opt_indent_size > 0) { + indent_string += opt_indent_char; + opt_indent_size -= 1; + } + + while (js_source_text && (js_source_text.charAt(0) === ' ' || js_source_text.charAt(0) === '\t')) { + preindent_string += js_source_text.charAt(0); + js_source_text = js_source_text.substring(1); + } + input = js_source_text; + + last_word = ''; // last 'TK_WORD' passed + last_type = 'TK_START_EXPR'; // last token type + last_text = ''; // last token text + last_last_text = ''; // pre-last token text + output = []; + + do_block_just_closed = false; + + whitespace = "\n\r\t ".split(''); + wordchar = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$'.split(''); + digits = '0123456789'.split(''); + + punct = '+ - * / % & ++ -- = += -= *= /= %= == === != !== > < >= <= >> << >>> >>>= >>= <<= && &= | || ! !! , : ? ^ ^= |= ::'; + punct += ' <%= <% %> <?= <? ?>'; // try to be a good boy and try not to break the markup language identifiers + punct = punct.split(' '); + + // words which should always start on new line. + line_starters = 'continue,try,throw,return,var,if,switch,case,default,for,while,break,function'.split(','); + + // states showing if we are currently in expression (i.e. "if" case) - 'EXPRESSION', or in usual block (like, procedure), 'BLOCK'. + // some formatting depends on that. + flag_store = []; + set_mode('BLOCK'); + + parser_pos = 0; + while (true) { + var t = get_next_token(); + token_text = t[0]; + token_type = t[1]; + if (token_type === 'TK_EOF') { + break; + } + + switch (token_type) { + + case 'TK_START_EXPR': + + if (token_text === '[') { + + if (last_type === 'TK_WORD' || last_text === ')') { + // this is array index specifier, break immediately + // a[x], fn()[x] + if (in_array(last_text, line_starters)) { + print_single_space(); + } + set_mode('(EXPRESSION)'); + print_token(); + break; + } + + if (flags.mode === '[EXPRESSION]' || flags.mode === '[INDENTED-EXPRESSION]') { + if (last_last_text === ']' && last_text === ',') { + // ], [ goes to new line + if (flags.mode === '[EXPRESSION]') { + flags.mode = '[INDENTED-EXPRESSION]'; + if (!opt_keep_array_indentation) { + indent(); + } + } + set_mode('[EXPRESSION]'); + if (!opt_keep_array_indentation) { + print_newline(); + } + } else if (last_text === '[') { + if (flags.mode === '[EXPRESSION]') { + flags.mode = '[INDENTED-EXPRESSION]'; + if (!opt_keep_array_indentation) { + indent(); + } + } + set_mode('[EXPRESSION]'); + + if (!opt_keep_array_indentation) { + print_newline(); + } + } else { + set_mode('[EXPRESSION]'); + } + } else { + set_mode('[EXPRESSION]'); + } + + + + } else { + if (last_word === 'for') { + set_mode('(FOR-EXPRESSION)'); + } else if (in_array(last_word, ['if', 'while'])) { + set_mode('(COND-EXPRESSION)'); + } else { + set_mode('(EXPRESSION)'); + } + } + + if (last_text === ';' || last_type === 'TK_START_BLOCK') { + print_newline(); + } else if (last_type === 'TK_END_EXPR' || last_type === 'TK_START_EXPR' || last_type === 'TK_END_BLOCK' || last_text === '.') { + if (wanted_newline) { + print_newline(); + } + // do nothing on (( and )( and ][ and ]( and .( + } else if (last_type !== 'TK_WORD' && last_type !== 'TK_OPERATOR') { + print_single_space(); + } else if (last_word === 'function' || last_word === 'typeof') { + // function() vs function () + if (opt_jslint_happy) { + print_single_space(); + } + } else if (in_array(last_text, line_starters) || last_text === 'catch') { + if (opt_space_before_conditional) { + print_single_space(); + } + } + print_token(); + + break; + + case 'TK_END_EXPR': + if (token_text === ']') { + if (opt_keep_array_indentation) { + if (last_text === '}') { + // trim_output(); + // print_newline(true); + remove_indent(); + print_token(); + restore_mode(); + break; + } + } else { + if (flags.mode === '[INDENTED-EXPRESSION]') { + if (last_text === ']') { + restore_mode(); + print_newline(); + print_token(); + break; + } + } + } + } + restore_mode(); + print_token(); + break; + + case 'TK_START_BLOCK': + + if (last_word === 'do') { + set_mode('DO_BLOCK'); + } else { + set_mode('BLOCK'); + } + if (opt_brace_style === "expand" || opt_brace_style === "expand-strict") { + var empty_braces = false; + if (opt_brace_style === "expand-strict") { + empty_braces = (look_up() === '}'); + if (!empty_braces) { + print_newline(true); + } + } else { + if (last_type !== 'TK_OPERATOR') { + if (last_text === '=' || (is_special_word(last_text) && last_text !== 'else')) { + print_single_space(); + } else { + print_newline(true); + } + } + } + print_token(); + if (!empty_braces) { + indent(); + } + } else { + if (last_type !== 'TK_OPERATOR' && last_type !== 'TK_START_EXPR') { + if (last_type === 'TK_START_BLOCK') { + print_newline(); + } else { + print_single_space(); + } + } else { + // if TK_OPERATOR or TK_START_EXPR + if (is_array(flags.previous_mode) && last_text === ',') { + if (last_last_text === '}') { + // }, { in array context + print_single_space(); + } else { + print_newline(); // [a, b, c, { + } + } + } + indent(); + print_token(); + } + + break; + + case 'TK_END_BLOCK': + restore_mode(); + if (opt_brace_style === "expand" || opt_brace_style === "expand-strict") { + if (last_text !== '{') { + print_newline(); + } + print_token(); + } else { + if (last_type === 'TK_START_BLOCK') { + // nothing + if (just_added_newline) { + remove_indent(); + } else { + // {} + trim_output(); + } + } else { + if (is_array(flags.mode) && opt_keep_array_indentation) { + // we REALLY need a newline here, but newliner would skip that + opt_keep_array_indentation = false; + print_newline(); + opt_keep_array_indentation = true; + + } else { + print_newline(); + } + } + print_token(); + } + break; + + case 'TK_WORD': + + // no, it's not you. even I have problems understanding how this works + // and what does what. + if (do_block_just_closed) { + // do {} ## while () + print_single_space(); + print_token(); + print_single_space(); + do_block_just_closed = false; + break; + } + + prefix = 'NONE'; + + if (token_text === 'function') { + if (flags.var_line && last_type !== 'TK_EQUALS' ) { + flags.var_line_reindented = true; + } + if ((just_added_newline || last_text === ';') && last_text !== '{' + && last_type !== 'TK_BLOCK_COMMENT' && last_type !== 'TK_COMMENT') { + // make sure there is a nice clean space of at least one blank line + // before a new function definition + n_newlines = just_added_newline ? n_newlines : 0; + if (!opt_preserve_newlines) { + n_newlines = 1; + } + + for (var i = 0; i < 2 - n_newlines; i++) { + print_newline(false); + } + } + if (last_type === 'TK_WORD') { + if (last_text === 'get' || last_text === 'set' || last_text === 'new' || last_text === 'return') { + print_single_space(); + } else { + print_newline(); + } + } else if (last_type === 'TK_OPERATOR' || last_text === '=') { + // foo = function + print_single_space(); + } else if (is_expression(flags.mode)) { + //ää print nothing + } else { + print_newline(); + } + + print_token(); + last_word = token_text; + break; + } + + if (token_text === 'case' || (token_text === 'default' && flags.in_case_statement)) { + if (last_text === ':' || flags.case_body) { + // switch cases following one another + remove_indent(); + } else { + // case statement starts in the same line where switch + if (!opt_indent_case) { + flags.indentation_level--; + } + print_newline(); + if (!opt_indent_case) { + flags.indentation_level++; + } + } + print_token(); + flags.in_case = true; + flags.in_case_statement = true; + flags.case_body = false; + break; + } + + if (last_type === 'TK_END_BLOCK') { + + if (!in_array(token_text.toLowerCase(), ['else', 'catch', 'finally'])) { + prefix = 'NEWLINE'; + } else { + if (opt_brace_style === "expand" || opt_brace_style === "end-expand" || opt_brace_style === "expand-strict") { + prefix = 'NEWLINE'; + } else { + prefix = 'SPACE'; + print_single_space(); + } + } + } else if (last_type === 'TK_SEMICOLON' && (flags.mode === 'BLOCK' || flags.mode === 'DO_BLOCK')) { + prefix = 'NEWLINE'; + } else if (last_type === 'TK_SEMICOLON' && is_expression(flags.mode)) { + prefix = 'SPACE'; + } else if (last_type === 'TK_STRING') { + prefix = 'NEWLINE'; + } else if (last_type === 'TK_WORD') { + if (last_text === 'else') { + // eat newlines between ...else *** some_op... + // won't preserve extra newlines in this place (if any), but don't care that much + trim_output(true); + } + prefix = 'SPACE'; + } else if (last_type === 'TK_START_BLOCK') { + prefix = 'NEWLINE'; + } else if (last_type === 'TK_END_EXPR') { + print_single_space(); + prefix = 'NEWLINE'; + } + + if (in_array(token_text, line_starters) && last_text !== ')') { + if (last_text === 'else') { + prefix = 'SPACE'; + } else { + prefix = 'NEWLINE'; + } + + } + + if (flags.if_line && last_type === 'TK_END_EXPR') { + flags.if_line = false; + } + if (in_array(token_text.toLowerCase(), ['else', 'catch', 'finally'])) { + if (last_type !== 'TK_END_BLOCK' || opt_brace_style === "expand" || opt_brace_style === "end-expand" || opt_brace_style === "expand-strict") { + print_newline(); + } else { + trim_output(true); + print_single_space(); + } + } else if (prefix === 'NEWLINE') { + if (is_special_word(last_text)) { + // no newline between 'return nnn' + print_single_space(); + } else if (last_type !== 'TK_END_EXPR') { + if ((last_type !== 'TK_START_EXPR' || token_text !== 'var') && last_text !== ':') { + // no need to force newline on 'var': for (var x = 0...) + if (token_text === 'if' && last_word === 'else' && last_text !== '{') { + // no newline for } else if { + print_single_space(); + } else { + flags.var_line = false; + flags.var_line_reindented = false; + print_newline(); + } + } + } else if (in_array(token_text, line_starters) && last_text !== ')') { + flags.var_line = false; + flags.var_line_reindented = false; + print_newline(); + } + } else if (is_array(flags.mode) && last_text === ',' && last_last_text === '}') { + print_newline(); // }, in lists get a newline treatment + } else if (prefix === 'SPACE') { + print_single_space(); + } + print_token(); + last_word = token_text; + + if (token_text === 'var') { + flags.var_line = true; + flags.var_line_reindented = false; + flags.var_line_tainted = false; + } + + if (token_text === 'if') { + flags.if_line = true; + } + if (token_text === 'else') { + flags.if_line = false; + } + + break; + + case 'TK_SEMICOLON': + + print_token(); + flags.var_line = false; + flags.var_line_reindented = false; + if (flags.mode === 'OBJECT') { + // OBJECT mode is weird and doesn't get reset too well. + flags.mode = 'BLOCK'; + } + break; + + case 'TK_STRING': + + if (last_type === 'TK_END_EXPR' && in_array(flags.previous_mode, ['(COND-EXPRESSION)', '(FOR-EXPRESSION)'])) { + print_single_space(); + } else if (last_type === 'TK_COMMENT' || last_type === 'TK_STRING' || last_type === 'TK_START_BLOCK' || last_type === 'TK_END_BLOCK' || last_type === 'TK_SEMICOLON') { + print_newline(); + } else if (last_type === 'TK_WORD') { + print_single_space(); + } + print_token(); + break; + + case 'TK_EQUALS': + if (flags.var_line) { + // just got an '=' in a var-line, different formatting/line-breaking, etc will now be done + flags.var_line_tainted = true; + } + print_single_space(); + print_token(); + print_single_space(); + break; + + case 'TK_COMMA': + if (flags.var_line) { + if (is_expression(flags.mode) || last_type === 'TK_END_BLOCK' ) { + // do not break on comma, for(var a = 1, b = 2) + flags.var_line_tainted = false; + } + if (flags.var_line_tainted) { + print_token(); + flags.var_line_reindented = true; + flags.var_line_tainted = false; + print_newline(); + break; + } else { + flags.var_line_tainted = false; + } + + print_token(); + print_single_space(); + break; + } + + if (last_type === 'TK_COMMENT') { + print_newline(); + } + + if (last_type === 'TK_END_BLOCK' && flags.mode !== "(EXPRESSION)") { + print_token(); + if (flags.mode === 'OBJECT' && last_text === '}') { + print_newline(); + } else { + print_single_space(); + } + } else { + if (flags.mode === 'OBJECT') { + print_token(); + print_newline(); + } else { + // EXPR or DO_BLOCK + print_token(); + print_single_space(); + } + } + break; + + + case 'TK_OPERATOR': + + var space_before = true; + var space_after = true; + + if (is_special_word(last_text)) { + // "return" had a special handling in TK_WORD. Now we need to return the favor + print_single_space(); + print_token(); + break; + } + + // hack for actionscript's import .*; + if (token_text === '*' && last_type === 'TK_UNKNOWN' && !last_last_text.match(/^\d+$/)) { + print_token(); + break; + } + + if (token_text === ':' && flags.in_case) { + if (opt_indent_case) { + flags.case_body = true; + } + print_token(); // colon really asks for separate treatment + print_newline(); + flags.in_case = false; + break; + } + + if (token_text === '::') { + // no spaces around exotic namespacing syntax operator + print_token(); + break; + } + + if (in_array(token_text, ['--', '++', '!']) || (in_array(token_text, ['-', '+']) && (in_array(last_type, ['TK_START_BLOCK', 'TK_START_EXPR', 'TK_EQUALS', 'TK_OPERATOR']) || in_array(last_text, line_starters)))) { + // unary operators (and binary +/- pretending to be unary) special cases + + space_before = false; + space_after = false; + + if (last_text === ';' && is_expression(flags.mode)) { + // for (;; ++i) + // ^^^ + space_before = true; + } + if (last_type === 'TK_WORD' && in_array(last_text, line_starters)) { + space_before = true; + } + + if (flags.mode === 'BLOCK' && (last_text === '{' || last_text === ';')) { + // { foo; --i } + // foo(); --bar; + print_newline(); + } + } else if (token_text === '.') { + // decimal digits or object.property + space_before = false; + + } else if (token_text === ':') { + if (flags.ternary_depth === 0) { + if (flags.mode === 'BLOCK') { + flags.mode = 'OBJECT'; + } + space_before = false; + } else { + flags.ternary_depth -= 1; + } + } else if (token_text === '?') { + flags.ternary_depth += 1; + } + if (space_before) { + print_single_space(); + } + + print_token(); + + if (space_after) { + print_single_space(); + } + + break; + + case 'TK_BLOCK_COMMENT': + + var lines = split_newlines(token_text); + var j; // iterator for this case + + if (all_lines_start_with(lines.slice(1), '*')) { + // javadoc: reformat and reindent + print_newline(); + output.push(lines[0]); + for (j = 1; j < lines.length; j++) { + print_newline(); + output.push(' '); + output.push(trim(lines[j])); + } + + } else { + + // simple block comment: leave intact + if (lines.length > 1) { + // multiline comment block starts with a new line + print_newline(); + } else { + // single-line /* comment */ stays where it is + if (last_type === 'TK_END_BLOCK') { + print_newline(); + } else { + print_single_space(); + } + + } + + for (j = 0; j < lines.length; j++) { + output.push(lines[j]); + output.push("\n"); + } + + } + if (look_up('\n') !== '\n') { + print_newline(); + } + break; + + case 'TK_INLINE_COMMENT': + print_single_space(); + print_token(); + if (is_expression(flags.mode)) { + print_single_space(); + } else { + force_newline(); + } + break; + + case 'TK_COMMENT': + + if (last_text === ',' && !wanted_newline) { + trim_output(true); + } + if (last_type !== 'TK_COMMENT') { + if (wanted_newline) { + print_newline(); + } else { + print_single_space(); + } + } + print_token(); + print_newline(); + break; + + case 'TK_UNKNOWN': + if (is_special_word(last_text)) { + print_single_space(); + } + print_token(); + break; + } + + last_last_text = last_text; + last_type = token_type; + last_text = token_text; + } + + var sweet_code = preindent_string + output.join('').replace(/[\r\n ]+$/, ''); + return sweet_code; + +} diff --git a/browser/devtools/shared/LayoutHelpers.jsm b/browser/devtools/shared/LayoutHelpers.jsm new file mode 100644 index 000000000..0c174b92e --- /dev/null +++ b/browser/devtools/shared/LayoutHelpers.jsm @@ -0,0 +1,384 @@ +/* -*- Mode: C++; tab-width: 8; 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/. */ + +const Cu = Components.utils; +const Ci = Components.interfaces; +const Cr = Components.results; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyGetter(this, "PlatformKeys", function() { + return Services.strings.createBundle( + "chrome://global-platform/locale/platformKeys.properties"); +}); + +this.EXPORTED_SYMBOLS = ["LayoutHelpers"]; + +this.LayoutHelpers = LayoutHelpers = { + + /** + * Compute the position and the dimensions for the visible portion + * of a node, relativalely to the root window. + * + * @param nsIDOMNode aNode + * a DOM element to be highlighted + */ + getDirtyRect: function LH_getDirectyRect(aNode) { + let frameWin = aNode.ownerDocument.defaultView; + let clientRect = aNode.getBoundingClientRect(); + + // Go up in the tree of frames to determine the correct rectangle. + // clientRect is read-only, we need to be able to change properties. + rect = {top: clientRect.top, + left: clientRect.left, + width: clientRect.width, + height: clientRect.height}; + + // We iterate through all the parent windows. + while (true) { + + // Does the selection overflow on the right of its window? + let diffx = frameWin.innerWidth - (rect.left + rect.width); + if (diffx < 0) { + rect.width += diffx; + } + + // Does the selection overflow on the bottom of its window? + let diffy = frameWin.innerHeight - (rect.top + rect.height); + if (diffy < 0) { + rect.height += diffy; + } + + // Does the selection overflow on the left of its window? + if (rect.left < 0) { + rect.width += rect.left; + rect.left = 0; + } + + // Does the selection overflow on the top of its window? + if (rect.top < 0) { + rect.height += rect.top; + rect.top = 0; + } + + // Selection has been clipped to fit in its own window. + + // Are we in the top-level window? + if (frameWin.parent === frameWin || !frameWin.frameElement) { + break; + } + + // We are in an iframe. + // We take into account the parent iframe position and its + // offset (borders and padding). + let frameRect = frameWin.frameElement.getBoundingClientRect(); + + let [offsetTop, offsetLeft] = + this.getIframeContentOffset(frameWin.frameElement); + + rect.top += frameRect.top + offsetTop; + rect.left += frameRect.left + offsetLeft; + + frameWin = frameWin.parent; + } + + return rect; + }, + + /** + * Compute the absolute position and the dimensions of a node, relativalely + * to the root window. + * + * @param nsIDOMNode aNode + * a DOM element to get the bounds for + * @param nsIWindow aContentWindow + * the content window holding the node + */ + getRect: function LH_getRect(aNode, aContentWindow) { + let frameWin = aNode.ownerDocument.defaultView; + let clientRect = aNode.getBoundingClientRect(); + + // Go up in the tree of frames to determine the correct rectangle. + // clientRect is read-only, we need to be able to change properties. + rect = {top: clientRect.top + aContentWindow.pageYOffset, + left: clientRect.left + aContentWindow.pageXOffset, + width: clientRect.width, + height: clientRect.height}; + + // We iterate through all the parent windows. + while (true) { + + // Are we in the top-level window? + if (frameWin.parent === frameWin || !frameWin.frameElement) { + break; + } + + // We are in an iframe. + // We take into account the parent iframe position and its + // offset (borders and padding). + let frameRect = frameWin.frameElement.getBoundingClientRect(); + + let [offsetTop, offsetLeft] = + this.getIframeContentOffset(frameWin.frameElement); + + rect.top += frameRect.top + offsetTop; + rect.left += frameRect.left + offsetLeft; + + frameWin = frameWin.parent; + } + + return rect; + }, + + /** + * Returns iframe content offset (iframe border + padding). + * Note: this function shouldn't need to exist, had the platform provided a + * suitable API for determining the offset between the iframe's content and + * its bounding client rect. Bug 626359 should provide us with such an API. + * + * @param aIframe + * The iframe. + * @returns array [offsetTop, offsetLeft] + * offsetTop is the distance from the top of the iframe and the + * top of the content document. + * offsetLeft is the distance from the left of the iframe and the + * left of the content document. + */ + getIframeContentOffset: function LH_getIframeContentOffset(aIframe) { + let style = aIframe.contentWindow.getComputedStyle(aIframe, null); + + // In some cases, the computed style is null + if (!style) { + return [0, 0]; + } + + let paddingTop = parseInt(style.getPropertyValue("padding-top")); + let paddingLeft = parseInt(style.getPropertyValue("padding-left")); + + let borderTop = parseInt(style.getPropertyValue("border-top-width")); + let borderLeft = parseInt(style.getPropertyValue("border-left-width")); + + return [borderTop + paddingTop, borderLeft + paddingLeft]; + }, + + /** + * Apply the page zoom factor. + */ + getZoomedRect: function LH_getZoomedRect(aWin, aRect) { + // get page zoom factor, if any + let zoom = + aWin.QueryInterface(Components.interfaces.nsIInterfaceRequestor) + .getInterface(Components.interfaces.nsIDOMWindowUtils) + .fullZoom; + + // adjust rect for zoom scaling + let aRectScaled = {}; + for (let prop in aRect) { + aRectScaled[prop] = aRect[prop] * zoom; + } + + return aRectScaled; + }, + + + /** + * Find an element from the given coordinates. This method descends through + * frames to find the element the user clicked inside frames. + * + * @param DOMDocument aDocument the document to look into. + * @param integer aX + * @param integer aY + * @returns Node|null the element node found at the given coordinates. + */ + getElementFromPoint: function LH_elementFromPoint(aDocument, aX, aY) { + let node = aDocument.elementFromPoint(aX, aY); + if (node && node.contentDocument) { + if (node instanceof Ci.nsIDOMHTMLIFrameElement) { + let rect = node.getBoundingClientRect(); + + // Gap between the iframe and its content window. + let [offsetTop, offsetLeft] = LayoutHelpers.getIframeContentOffset(node); + + aX -= rect.left + offsetLeft; + aY -= rect.top + offsetTop; + + if (aX < 0 || aY < 0) { + // Didn't reach the content document, still over the iframe. + return node; + } + } + if (node instanceof Ci.nsIDOMHTMLIFrameElement || + node instanceof Ci.nsIDOMHTMLFrameElement) { + let subnode = this.getElementFromPoint(node.contentDocument, aX, aY); + if (subnode) { + node = subnode; + } + } + } + return node; + }, + + /** + * Scroll the document so that the element "elem" appears in the viewport. + * + * @param Element elem the element that needs to appear in the viewport. + * @param bool centered true if you want it centered, false if you want it to + * appear on the top of the viewport. It is true by default, and that is + * usually what you want. + */ + scrollIntoViewIfNeeded: + function LH_scrollIntoViewIfNeeded(elem, centered) { + // We want to default to centering the element in the page, + // so as to keep the context of the element. + centered = centered === undefined? true: !!centered; + + let win = elem.ownerDocument.defaultView; + let clientRect = elem.getBoundingClientRect(); + + // The following are always from the {top, bottom, left, right} + // of the viewport, to the {top, …} of the box. + // Think of them as geometrical vectors, it helps. + // The origin is at the top left. + + let topToBottom = clientRect.bottom; + let bottomToTop = clientRect.top - win.innerHeight; + let leftToRight = clientRect.right; + let rightToLeft = clientRect.left - win.innerWidth; + let xAllowed = true; // We allow one translation on the x axis, + let yAllowed = true; // and one on the y axis. + + // Whatever `centered` is, the behavior is the same if the box is + // (even partially) visible. + + if ((topToBottom > 0 || !centered) && topToBottom <= elem.offsetHeight) { + win.scrollBy(0, topToBottom - elem.offsetHeight); + yAllowed = false; + } else + if ((bottomToTop < 0 || !centered) && bottomToTop >= -elem.offsetHeight) { + win.scrollBy(0, bottomToTop + elem.offsetHeight); + yAllowed = false; + } + + if ((leftToRight > 0 || !centered) && leftToRight <= elem.offsetWidth) { + if (xAllowed) { + win.scrollBy(leftToRight - elem.offsetWidth, 0); + xAllowed = false; + } + } else + if ((rightToLeft < 0 || !centered) && rightToLeft >= -elem.offsetWidth) { + if (xAllowed) { + win.scrollBy(rightToLeft + elem.offsetWidth, 0); + xAllowed = false; + } + } + + // If we want it centered, and the box is completely hidden, + // then we center it explicitly. + + if (centered) { + + if (yAllowed && (topToBottom <= 0 || bottomToTop >= 0)) { + win.scroll(win.scrollX, + win.scrollY + clientRect.top + - (win.innerHeight - elem.offsetHeight) / 2); + } + + if (xAllowed && (leftToRight <= 0 || rightToLeft <= 0)) { + win.scroll(win.scrollX + clientRect.left + - (win.innerWidth - elem.offsetWidth) / 2, + win.scrollY); + } + } + + if (win.parent !== win) { + // We are inside an iframe. + LH_scrollIntoViewIfNeeded(win.frameElement, centered); + } + }, + + /** + * Check if a node and its document are still alive + * and attached to the window. + * + * @param aNode + */ + isNodeConnected: function LH_isNodeConnected(aNode) + { + try { + let connected = (aNode.ownerDocument && aNode.ownerDocument.defaultView && + !(aNode.compareDocumentPosition(aNode.ownerDocument.documentElement) & + aNode.DOCUMENT_POSITION_DISCONNECTED)); + return connected; + } catch (e) { + // "can't access dead object" error + return false; + } + }, + + /** + * Prettifies the modifier keys for an element. + * + * @param Node aElemKey + * The key element to get the modifiers from. + * @param boolean aAllowCloverleaf + * Pass true to use the cloverleaf symbol instead of a descriptive string. + * @return string + * A prettified and properly separated modifier keys string. + */ + prettyKey: function LH_prettyKey(aElemKey, aAllowCloverleaf) + { + let elemString = ""; + let elemMod = aElemKey.getAttribute("modifiers"); + + if (elemMod.match("accel")) { + if (Services.appinfo.OS == "Darwin") { + // XXX bug 779642 Use "Cmd-" literal vs. cloverleaf meta-key until + // Orion adds variable height lines. + if (!aAllowCloverleaf) { + elemString += "Cmd-"; + } else { + elemString += PlatformKeys.GetStringFromName("VK_META") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + } else { + elemString += PlatformKeys.GetStringFromName("VK_CONTROL") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + } + if (elemMod.match("access")) { + if (Services.appinfo.OS == "Darwin") { + elemString += PlatformKeys.GetStringFromName("VK_CONTROL") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } else { + elemString += PlatformKeys.GetStringFromName("VK_ALT") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + } + if (elemMod.match("shift")) { + elemString += PlatformKeys.GetStringFromName("VK_SHIFT") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + if (elemMod.match("alt")) { + elemString += PlatformKeys.GetStringFromName("VK_ALT") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + if (elemMod.match("ctrl") || elemMod.match("control")) { + elemString += PlatformKeys.GetStringFromName("VK_CONTROL") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + if (elemMod.match("meta")) { + elemString += PlatformKeys.GetStringFromName("VK_META") + + PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR"); + } + + return elemString + + (aElemKey.getAttribute("keycode").replace(/^.*VK_/, "") || + aElemKey.getAttribute("key")).toUpperCase(); + } +}; diff --git a/browser/devtools/shared/Makefile.in b/browser/devtools/shared/Makefile.in new file mode 100644 index 000000000..94eba0663 --- /dev/null +++ b/browser/devtools/shared/Makefile.in @@ -0,0 +1,18 @@ +# +# 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/. + +DEPTH = @DEPTH@ +topsrcdir = @top_srcdir@ +srcdir = @srcdir@ +VPATH = @srcdir@ + +include $(DEPTH)/config/autoconf.mk + +include $(topsrcdir)/config/rules.mk + +libs:: + $(NSINSTALL) $(srcdir)/*.jsm $(FINAL_TARGET)/modules/devtools + $(NSINSTALL) $(srcdir)/widgets/*.jsm $(FINAL_TARGET)/modules/devtools + $(NSINSTALL) $(srcdir)/*.js $(FINAL_TARGET)/modules/devtools/shared diff --git a/browser/devtools/shared/Parser.jsm b/browser/devtools/shared/Parser.jsm new file mode 100644 index 000000000..42b32c07c --- /dev/null +++ b/browser/devtools/shared/Parser.jsm @@ -0,0 +1,2293 @@ +/* -*- 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 Ci = Components.interfaces; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, + "Reflect", "resource://gre/modules/reflect.jsm"); + +this.EXPORTED_SYMBOLS = ["Parser"]; + +/** + * A JS parser using the reflection API. + */ +this.Parser = function Parser() { + this._cache = new Map(); +}; + +Parser.prototype = { + /** + * Gets a collection of parser methods for a specified source. + * + * @param string aUrl [optional] + * The source url. The AST nodes will be cached, so you can use this + * identifier to avoid parsing the whole source again. + * @param string aSource + * The source text content. + */ + get: function P_get(aUrl, aSource) { + // Try to use the cached AST nodes, to avoid useless parsing operations. + if (this._cache.has(aUrl)) { + return this._cache.get(aUrl); + } + + // The source may not necessarily be JS, in which case we need to extract + // all the scripts. Fastest/easiest way is with a regular expression. + // Don't worry, the rules of using a <script> tag are really strict, + // this will work. + let regexp = /<script[^>]*>([^]*?)<\/script\s*>/gim; + let syntaxTrees = []; + let scriptMatches = []; + let scriptMatch; + + if (aSource.match(/^\s*</)) { + // First non whitespace character is <, so most definitely HTML. + while (scriptMatch = regexp.exec(aSource)) { + scriptMatches.push(scriptMatch[1]); // Contents are captured at index 1. + } + } + + // If there are no script matches, send the whole source directly to the + // reflection API to generate the AST nodes. + if (!scriptMatches.length) { + // Reflect.parse throws when encounters a syntax error. + try { + let nodes = Reflect.parse(aSource); + let length = aSource.length; + syntaxTrees.push(new SyntaxTree(nodes, aUrl, length)); + } catch (e) { + log(aUrl, e); + } + } + // Generate the AST nodes for each script. + else { + for (let script of scriptMatches) { + // Reflect.parse throws when encounters a syntax error. + try { + let nodes = Reflect.parse(script); + let offset = aSource.indexOf(script); + let length = script.length; + syntaxTrees.push(new SyntaxTree(nodes, aUrl, length, offset)); + } catch (e) { + log(aUrl, e); + } + } + } + + let pool = new SyntaxTreesPool(syntaxTrees); + this._cache.set(aUrl, pool); + return pool; + }, + + /** + * Clears all the parsed sources from cache. + */ + clearCache: function P_clearCache() { + this._cache.clear(); + }, + + _cache: null +}; + +/** + * A pool handling a collection of AST nodes generated by the reflection API. + * + * @param object aSyntaxTrees + * A collection of AST nodes generated for a source. + */ +function SyntaxTreesPool(aSyntaxTrees) { + this._trees = aSyntaxTrees; + this._cache = new Map(); +} + +SyntaxTreesPool.prototype = { + /** + * @see SyntaxTree.prototype.getNamedFunctionDefinitions + */ + getNamedFunctionDefinitions: function STP_getNamedFunctionDefinitions(aSubstring) { + return this._call("getNamedFunctionDefinitions", aSubstring); + }, + + /** + * @see SyntaxTree.prototype.getFunctionAtLocation + */ + getFunctionAtLocation: function STP_getFunctionAtLocation(aLine, aColumn) { + return this._call("getFunctionAtLocation", [aLine, aColumn]); + }, + + /** + * Finds the offset and length of the script containing the specified offset + * relative to its parent source. + * + * @param number aOffset + * The offset relative to the parent source. + * @return array + * The offset and length relative to the enclosing script. + */ + getScriptInfo: function STP_getScriptInfo(aOffset) { + for (let { offset, length } of this._trees) { + if (offset <= aOffset && offset + length >= aOffset) { + return [offset, length]; + } + } + return [-1, -1]; + }, + + /** + * Handles a request for all known syntax trees. + * + * @param string aFunction + * The function name to call on the SyntaxTree instances. + * @param any aParams + * Any kind params to pass to the request function. + * @return array + * The results given by all known syntax trees. + */ + _call: function STP__call(aFunction, aParams) { + let results = []; + let requestId = aFunction + aParams; // Cache all the things! + + if (this._cache.has(requestId)) { + return this._cache.get(requestId); + } + for (let syntaxTree of this._trees) { + try { + results.push({ + sourceUrl: syntaxTree.url, + scriptLength: syntaxTree.length, + scriptOffset: syntaxTree.offset, + parseResults: syntaxTree[aFunction](aParams) + }); + } catch (e) { + // Can't guarantee that the tree traversal logic is forever perfect :) + // Language features may be added, in which case the recursive methods + // need to be updated. If an exception is thrown here, file a bug. + log("syntax tree", e); + } + } + this._cache.set(requestId, results); + return results; + }, + + _trees: null, + _cache: null +}; + +/** + * A collection of AST nodes generated by the reflection API. + * + * @param object aNodes + * The AST nodes. + * @param string aUrl + * The source url. + * @param number aLength + * The total number of chars of the parsed script in the parent source. + * @param number aOffset [optional] + * The char offset of the parsed script in the parent source. + */ +function SyntaxTree(aNodes, aUrl, aLength, aOffset = 0) { + this.AST = aNodes; + this.url = aUrl; + this.length = aLength; + this.offset = aOffset; +}; + +SyntaxTree.prototype = { + /** + * Searches for all function definitions (declarations and expressions) + * whose names (or inferred names) contain a string. + * + * @param string aSubstring + * The string to be contained in the function name (or inferred name). + * Can be an empty string to match all functions. + * @return array + * All the matching function declarations and expressions, as + * { functionName, functionLocation ... } object hashes. + */ + getNamedFunctionDefinitions: function ST_getNamedFunctionDefinitions(aSubstring) { + let lowerCaseToken = aSubstring.toLowerCase(); + let store = []; + + SyntaxTreeVisitor.walk(this.AST, { + /** + * Callback invoked for each function declaration node. + * @param Node aNode + */ + onFunctionDeclaration: function STW_onFunctionDeclaration(aNode) { + let functionName = aNode.id.name; + if (functionName.toLowerCase().contains(lowerCaseToken)) { + store.push({ + functionName: functionName, + functionLocation: aNode.loc + }); + } + }, + + /** + * Callback invoked for each function expression node. + * @param Node aNode + */ + onFunctionExpression: function STW_onFunctionExpression(aNode) { + let parent = aNode._parent; + let functionName, inferredName, inferredChain, inferredLocation; + + // Function expressions don't necessarily have a name. + if (aNode.id) { + functionName = aNode.id.name; + } + // Infer the function's name from an enclosing syntax tree node. + if (parent) { + let inferredInfo = ParserHelpers.inferFunctionExpressionInfo(aNode); + inferredName = inferredInfo.name; + inferredChain = inferredInfo.chain; + inferredLocation = inferredInfo.loc; + } + // Current node may be part of a larger assignment expression stack. + if (parent.type == "AssignmentExpression") { + this.onFunctionExpression(parent); + } + + if ((functionName && functionName.toLowerCase().contains(lowerCaseToken)) || + (inferredName && inferredName.toLowerCase().contains(lowerCaseToken))) { + store.push({ + functionName: functionName, + functionLocation: aNode.loc, + inferredName: inferredName, + inferredChain: inferredChain, + inferredLocation: inferredLocation + }); + } + }, + + /** + * Callback invoked for each arrow expression node. + * @param Node aNode + */ + onArrowExpression: function STW_onArrowExpression(aNode) { + let parent = aNode._parent; + let inferredName, inferredChain, inferredLocation; + + // Infer the function's name from an enclosing syntax tree node. + let inferredInfo = ParserHelpers.inferFunctionExpressionInfo(aNode); + inferredName = inferredInfo.name; + inferredChain = inferredInfo.chain; + inferredLocation = inferredInfo.loc; + + // Current node may be part of a larger assignment expression stack. + if (parent.type == "AssignmentExpression") { + this.onFunctionExpression(parent); + } + + if (inferredName && inferredName.toLowerCase().contains(lowerCaseToken)) { + store.push({ + inferredName: inferredName, + inferredChain: inferredChain, + inferredLocation: inferredLocation + }); + } + } + }); + + return store; + }, + + /** + * Gets the "new" or "call" expression at the specified location. + * + * @param number aLine + * The line in the source. + * @param number aColumn + * The column in the source. + * @return object + * An { functionName, functionLocation } object hash, + * or null if nothing is found at the specified location. + */ + getFunctionAtLocation: function STW_getFunctionAtLocation([aLine, aColumn]) { + let self = this; + let func = null; + + SyntaxTreeVisitor.walk(this.AST, { + /** + * Callback invoked for each node. + * @param Node aNode + */ + onNode: function STW_onNode(aNode) { + // Make sure the node is part of a branch that's guaranteed to be + // hovered. Otherwise, return true to abruptly halt walking this + // syntax tree branch. This is a really efficient optimization. + return ParserHelpers.isWithinLines(aNode, aLine); + }, + + /** + * Callback invoked for each identifier node. + * @param Node aNode + */ + onIdentifier: function STW_onIdentifier(aNode) { + // Make sure the identifier itself is hovered. + let hovered = ParserHelpers.isWithinBounds(aNode, aLine, aColumn); + if (!hovered) { + return; + } + + // Make sure the identifier is part of a "new" expression or + // "call" expression node. + let expression = ParserHelpers.getEnclosingFunctionExpression(aNode); + if (!expression) { + return; + } + + // Found an identifier node that is part of a "new" expression or + // "call" expression node. However, it may be an argument, not a callee. + if (ParserHelpers.isFunctionCalleeArgument(aNode)) { + // It's an argument. + if (self.functionIdentifiersCache.has(aNode.name)) { + // It's a function as an argument. + func = { + functionName: aNode.name, + functionLocation: aNode.loc || aNode._parent.loc + }; + } + return; + } + + // Found a valid "new" expression or "call" expression node. + func = { + functionName: aNode.name, + functionLocation: ParserHelpers.getFunctionCalleeInfo(expression).loc + }; + + // Abruptly halt walking the syntax tree. + this.break = true; + } + }); + + return func; + }, + + /** + * Gets all the function identifiers in this syntax tree (both the + * function names and their inferred names). + * + * @return array + * An array of strings. + */ + get functionIdentifiersCache() { + if (this._functionIdentifiersCache) { + return this._functionIdentifiersCache; + } + let functionDefinitions = this.getNamedFunctionDefinitions(""); + let functionIdentifiers = new Set(); + + for (let { functionName, inferredName } of functionDefinitions) { + functionIdentifiers.add(functionName); + functionIdentifiers.add(inferredName); + } + return this._functionIdentifiersCache = functionIdentifiers; + }, + + AST: null, + url: "", + length: 0, + offset: 0 +}; + +/** + * Parser utility methods. + */ +let ParserHelpers = { + /** + * Checks if a node's bounds contains a specified line. + * + * @param Node aNode + * The node's bounds used as reference. + * @param number aLine + * The line number to check. + * @return boolean + * True if the line and column is contained in the node's bounds. + */ + isWithinLines: function PH_isWithinLines(aNode, aLine) { + // Not all nodes have location information attached. + if (!aNode.loc) { + return this.isWithinLines(aNode._parent, aLine); + } + return aNode.loc.start.line <= aLine && aNode.loc.end.line >= aLine; + }, + + /** + * Checks if a node's bounds contains a specified line and column. + * + * @param Node aNode + * The node's bounds used as reference. + * @param number aLine + * The line number to check. + * @param number aColumn + * The column number to check. + * @return boolean + * True if the line and column is contained in the node's bounds. + */ + isWithinBounds: function PH_isWithinBounds(aNode, aLine, aColumn) { + // Not all nodes have location information attached. + if (!aNode.loc) { + return this.isWithinBounds(aNode._parent, aLine, aColumn); + } + return aNode.loc.start.line == aLine && aNode.loc.end.line == aLine && + aNode.loc.start.column <= aColumn && aNode.loc.end.column >= aColumn; + }, + + /** + * Try to infer a function expression's name & other details based on the + * enclosing VariableDeclarator, AssignmentExpression or ObjectExpression node. + * + * @param Node aNode + * The function expression node to get the name for. + * @return object + * The inferred function name, or empty string can't infer name, + * along with the chain (a generic "context", like a prototype chain) + * and location if available. + */ + inferFunctionExpressionInfo: function PH_inferFunctionExpressionInfo(aNode) { + let parent = aNode._parent; + + // A function expression may be defined in a variable declarator, + // e.g. var foo = function(){}, in which case it is possible to infer + // the variable name. + if (parent.type == "VariableDeclarator") { + return { + name: parent.id.name, + chain: null, + loc: parent.loc + }; + } + + // Function expressions can also be defined in assignment expressions, + // e.g. foo = function(){} or foo.bar = function(){}, in which case it is + // possible to infer the assignee name ("foo" and "bar" respectively). + if (parent.type == "AssignmentExpression") { + let assigneeChain = this.getAssignmentExpressionAssigneeChain(parent); + let assigneeLeaf = assigneeChain.pop(); + return { + name: assigneeLeaf, + chain: assigneeChain, + loc: parent.left.loc + }; + } + + // If a function expression is defined in an object expression, + // e.g. { foo: function(){} }, then it is possible to infer the name + // from the corresponding property. + if (parent.type == "ObjectExpression") { + let propertyDetails = this.getObjectExpressionPropertyKeyForValue(aNode); + let propertyChain = this.getObjectExpressionPropertyChain(parent); + let propertyLeaf = propertyDetails.name; + return { + name: propertyLeaf, + chain: propertyChain, + loc: propertyDetails.loc + }; + } + + // Can't infer the function expression's name. + return { + name: "", + chain: null, + loc: null + }; + }, + + /** + * Gets details about an object expression's property to which a specified + * value is assigned. For example, the node returned for the value 42 in + * "{ foo: { bar: 42 } }" is "bar". + * + * @param Node aNode + * The value node assigned to a property in an object expression. + * @return object + * The details about the assignee property node. + */ + getObjectExpressionPropertyKeyForValue: + function PH_getObjectExpressionPropertyKeyForValue(aNode) { + let parent = aNode._parent; + if (parent.type != "ObjectExpression") { + return null; + } + for (let property of parent.properties) { + if (property.value == aNode) { + return property.key; + } + } + }, + + /** + * Gets an object expression property chain to its parent variable declarator. + * For example, the chain to "baz" in "foo = { bar: { baz: { } } }" is + * ["foo", "bar", "baz"]. + * + * @param Node aNode + * The object expression node to begin the scan from. + * @param array aStore [optional] + * The chain to store the nodes into. + * @return array + * The chain to the parent variable declarator, as strings. + */ + getObjectExpressionPropertyChain: + function PH_getObjectExpressionPropertyChain(aNode, aStore = []) { + switch (aNode.type) { + case "ObjectExpression": + this.getObjectExpressionPropertyChain(aNode._parent, aStore); + + let propertyDetails = this.getObjectExpressionPropertyKeyForValue(aNode); + if (propertyDetails) { + aStore.push(this.getObjectExpressionPropertyKeyForValue(aNode).name); + } + break; + // Handle "foo.bar = { ... }" since it's commonly used when defining an + // object's prototype methods; for example: "Foo.prototype = { ... }". + case "AssignmentExpression": + this.getAssignmentExpressionAssigneeChain(aNode, aStore); + break; + // Additionally handle stuff like "foo = bar.baz({ ... })", because it's + // commonly used in prototype-based inheritance in many libraries; + // for example: "Foo.Bar = Baz.extend({ ... })". + case "NewExpression": + case "CallExpression": + this.getObjectExpressionPropertyChain(aNode._parent, aStore); + break; + // End of the chain. + case "VariableDeclarator": + aStore.push(aNode.id.name); + break; + } + return aStore; + }, + + /** + * Gets the assignee property chain in an assignment expression. + * For example, the chain in "foo.bar.baz = 42" is ["foo", "bar", "baz"]. + * + * @param Node aNode + * The assignment expression node to begin the scan from. + * @param array aStore + * The chain to store the nodes into. + * @param array aStore [optional] + * The chain to store the nodes into. + * @return array + * The full assignee chain, as strings. + */ + getAssignmentExpressionAssigneeChain: + function PH_getAssignmentExpressionAssigneeChain(aNode, aStore = []) { + switch (aNode.type) { + case "AssignmentExpression": + this.getAssignmentExpressionAssigneeChain(aNode.left, aStore); + break; + case "MemberExpression": + this.getAssignmentExpressionAssigneeChain(aNode.object, aStore); + this.getAssignmentExpressionAssigneeChain(aNode.property, aStore); + break; + case "ThisExpression": + // Such expressions may appear in an assignee chain, for example + // "this.foo.bar = baz", however it seems better to ignore such nodes + // and limit the chain to ["foo", "bar"]. + break; + case "Identifier": + aStore.push(aNode.name); + break; + } + return aStore; + }, + + /** + * Gets the "new" expression or "call" expression containing the specified + * node. If the node is not enclosed in either of these expression types, + * null is returned. + * + * @param Node aNode + * The child node of an enclosing "new" expression or "call" expression. + * @return Node + * The enclosing "new" expression or "call" expression node, or + * null if nothing is found. + */ + getEnclosingFunctionExpression: + function PH_getEnclosingFunctionExpression(aNode) { + switch (aNode.type) { + case "NewExpression": + case "CallExpression": + return aNode; + case "MemberExpression": + case "Identifier": + return this.getEnclosingFunctionExpression(aNode._parent); + default: + return null; + } + }, + + /** + * Gets the name and { line, column } location of a "new" expression or + * "call" expression's callee node. + * + * @param Node aNode + * The "new" expression or "call" expression to get the callee info for. + * @return object + * An object containing the name and location as properties, or + * null if nothing is found. + */ + getFunctionCalleeInfo: function PH_getFunctionCalleeInfo(aNode) { + switch (aNode.type) { + case "NewExpression": + case "CallExpression": + return this.getFunctionCalleeInfo(aNode.callee); + case "MemberExpression": + return this.getFunctionCalleeInfo(aNode.property); + case "Identifier": + return { + name: aNode.name, + loc: aNode.loc || (aNode._parent || {}).loc + }; + default: + return null; + } + }, + + /** + * Determines if an identifier node is part of a "new" expression or + * "call" expression's callee arguments. + * + * @param Node aNode + * The node to determine if part of a function's arguments. + * @return boolean + * True if the identifier is an argument, false otherwise. + */ + isFunctionCalleeArgument: function PH_isFunctionCalleeArgument(aNode) { + if (!aNode._parent) { + return false; + } + switch (aNode._parent.type) { + case "NewExpression": + case "CallExpression": + return aNode._parent.arguments.indexOf(aNode) != -1; + default: + return this.isFunctionCalleeArgument(aNode._parent); + } + } +}; + +/** + * A visitor for a syntax tree generated by the reflection API. + * See https://developer.mozilla.org/en-US/docs/SpiderMonkey/Parser_API. + * + * All node types implement the following interface: + * interface Node { + * type: string; + * loc: SourceLocation | null; + * } + */ +let SyntaxTreeVisitor = { + /** + * Walks a syntax tree. + * + * @param object aTree + * The AST nodes generated by the reflection API + * @param object aCallbacks + * A map of all the callbacks to invoke when passing through certain + * types of noes (e.g: onFunctionDeclaration, onBlockStatement etc.). + */ + walk: function STV_walk(aTree, aCallbacks) { + this[aTree.type](aTree, aCallbacks); + }, + + /** + * A flag checked on each node in the syntax tree. If true, walking is + * abruptly halted. + */ + break: false, + + /** + * A complete program source tree. + * + * interface Program <: Node { + * type: "Program"; + * body: [ Statement ]; + * } + */ + Program: function STV_Program(aNode, aCallbacks) { + if (aCallbacks.onProgram) { + aCallbacks.onProgram(aNode); + } + for (let statement of aNode.body) { + this[statement.type](statement, aNode, aCallbacks); + } + }, + + /** + * Any statement. + * + * interface Statement <: Node { } + */ + Statement: function STV_Statement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onStatement) { + aCallbacks.onStatement(aNode); + } + }, + + /** + * An empty statement, i.e., a solitary semicolon. + * + * interface EmptyStatement <: Statement { + * type: "EmptyStatement"; + * } + */ + EmptyStatement: function STV_EmptyStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onEmptyStatement) { + aCallbacks.onEmptyStatement(aNode); + } + }, + + /** + * A block statement, i.e., a sequence of statements surrounded by braces. + * + * interface BlockStatement <: Statement { + * type: "BlockStatement"; + * body: [ Statement ]; + * } + */ + BlockStatement: function STV_BlockStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onBlockStatement) { + aCallbacks.onBlockStatement(aNode); + } + for (let statement of aNode.body) { + this[statement.type](statement, aNode, aCallbacks); + } + }, + + /** + * An expression statement, i.e., a statement consisting of a single expression. + * + * interface ExpressionStatement <: Statement { + * type: "ExpressionStatement"; + * expression: Expression; + * } + */ + ExpressionStatement: function STV_ExpressionStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onExpressionStatement) { + aCallbacks.onExpressionStatement(aNode); + } + this[aNode.expression.type](aNode.expression, aNode, aCallbacks); + }, + + /** + * An if statement. + * + * interface IfStatement <: Statement { + * type: "IfStatement"; + * test: Expression; + * consequent: Statement; + * alternate: Statement | null; + * } + */ + IfStatement: function STV_IfStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onIfStatement) { + aCallbacks.onIfStatement(aNode); + } + this[aNode.test.type](aNode.test, aNode, aCallbacks); + this[aNode.consequent.type](aNode.consequent, aNode, aCallbacks); + if (aNode.alternate) { + this[aNode.alternate.type](aNode.alternate, aNode, aCallbacks); + } + }, + + /** + * A labeled statement, i.e., a statement prefixed by a break/continue label. + * + * interface LabeledStatement <: Statement { + * type: "LabeledStatement"; + * label: Identifier; + * body: Statement; + * } + */ + LabeledStatement: function STV_LabeledStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onLabeledStatement) { + aCallbacks.onLabeledStatement(aNode); + } + this[aNode.label.type](aNode.label, aNode, aCallbacks); + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A break statement. + * + * interface BreakStatement <: Statement { + * type: "BreakStatement"; + * label: Identifier | null; + * } + */ + BreakStatement: function STV_BreakStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onBreakStatement) { + aCallbacks.onBreakStatement(aNode); + } + if (aNode.label) { + this[aNode.label.type](aNode.label, aNode, aCallbacks); + } + }, + + /** + * A continue statement. + * + * interface ContinueStatement <: Statement { + * type: "ContinueStatement"; + * label: Identifier | null; + * } + */ + ContinueStatement: function STV_ContinueStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onContinueStatement) { + aCallbacks.onContinueStatement(aNode); + } + if (aNode.label) { + this[aNode.label.type](aNode.label, aNode, aCallbacks); + } + }, + + /** + * A with statement. + * + * interface WithStatement <: Statement { + * type: "WithStatement"; + * object: Expression; + * body: Statement; + * } + */ + WithStatement: function STV_WithStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onWithStatement) { + aCallbacks.onWithStatement(aNode); + } + this[aNode.object.type](aNode.object, aNode, aCallbacks); + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A switch statement. The lexical flag is metadata indicating whether the + * switch statement contains any unnested let declarations (and therefore + * introduces a new lexical scope). + * + * interface SwitchStatement <: Statement { + * type: "SwitchStatement"; + * discriminant: Expression; + * cases: [ SwitchCase ]; + * lexical: boolean; + * } + */ + SwitchStatement: function STV_SwitchStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onSwitchStatement) { + aCallbacks.onSwitchStatement(aNode); + } + this[aNode.discriminant.type](aNode.discriminant, aNode, aCallbacks); + for (let _case of aNode.cases) { + this[_case.type](_case, aNode, aCallbacks); + } + }, + + /** + * A return statement. + * + * interface ReturnStatement <: Statement { + * type: "ReturnStatement"; + * argument: Expression | null; + * } + */ + ReturnStatement: function STV_ReturnStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onReturnStatement) { + aCallbacks.onReturnStatement(aNode); + } + if (aNode.argument) { + this[aNode.argument.type](aNode.argument, aNode, aCallbacks); + } + }, + + /** + * A throw statement. + * + * interface ThrowStatement <: Statement { + * type: "ThrowStatement"; + * argument: Expression; + * } + */ + ThrowStatement: function STV_ThrowStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onThrowStatement) { + aCallbacks.onThrowStatement(aNode); + } + this[aNode.argument.type](aNode.argument, aNode, aCallbacks); + }, + + /** + * A try statement. + * + * interface TryStatement <: Statement { + * type: "TryStatement"; + * block: BlockStatement; + * handler: CatchClause | null; + * guardedHandlers: [ CatchClause ]; + * finalizer: BlockStatement | null; + * } + */ + TryStatement: function STV_TryStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onTryStatement) { + aCallbacks.onTryStatement(aNode); + } + this[aNode.block.type](aNode.block, aNode, aCallbacks); + if (aNode.handler) { + this[aNode.handler.type](aNode.handler, aNode, aCallbacks); + } + for (let guardedHandler of aNode.guardedHandlers) { + this[guardedHandler.type](guardedHandler, aNode, aCallbacks); + } + if (aNode.finalizer) { + this[aNode.finalizer.type](aNode.finalizer, aNode, aCallbacks); + } + }, + + /** + * A while statement. + * + * interface WhileStatement <: Statement { + * type: "WhileStatement"; + * test: Expression; + * body: Statement; + * } + */ + WhileStatement: function STV_WhileStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onWhileStatement) { + aCallbacks.onWhileStatement(aNode); + } + this[aNode.test.type](aNode.test, aNode, aCallbacks); + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A do/while statement. + * + * interface DoWhileStatement <: Statement { + * type: "DoWhileStatement"; + * body: Statement; + * test: Expression; + * } + */ + DoWhileStatement: function STV_DoWhileStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onDoWhileStatement) { + aCallbacks.onDoWhileStatement(aNode); + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + this[aNode.test.type](aNode.test, aNode, aCallbacks); + }, + + /** + * A for statement. + * + * interface ForStatement <: Statement { + * type: "ForStatement"; + * init: VariableDeclaration | Expression | null; + * test: Expression | null; + * update: Expression | null; + * body: Statement; + * } + */ + ForStatement: function STV_ForStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onForStatement) { + aCallbacks.onForStatement(aNode); + } + if (aNode.init) { + this[aNode.init.type](aNode.init, aNode, aCallbacks); + } + if (aNode.test) { + this[aNode.test.type](aNode.test, aNode, aCallbacks); + } + if (aNode.update) { + this[aNode.update.type](aNode.update, aNode, aCallbacks); + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A for/in statement, or, if each is true, a for each/in statement. + * + * interface ForInStatement <: Statement { + * type: "ForInStatement"; + * left: VariableDeclaration | Expression; + * right: Expression; + * body: Statement; + * each: boolean; + * } + */ + ForInStatement: function STV_ForInStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onForInStatement) { + aCallbacks.onForInStatement(aNode); + } + this[aNode.left.type](aNode.left, aNode, aCallbacks); + this[aNode.right.type](aNode.right, aNode, aCallbacks); + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A for/of statement. + * + * interface ForOfStatement <: Statement { + * type: "ForOfStatement"; + * left: VariableDeclaration | Expression; + * right: Expression; + * body: Statement; + * } + */ + ForOfStatement: function STV_ForOfStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onForOfStatement) { + aCallbacks.onForOfStatement(aNode); + } + this[aNode.left.type](aNode.left, aNode, aCallbacks); + this[aNode.right.type](aNode.right, aNode, aCallbacks); + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A let statement. + * + * interface LetStatement <: Statement { + * type: "LetStatement"; + * head: [ { id: Pattern, init: Expression | null } ]; + * body: Statement; + * } + */ + LetStatement: function STV_LetStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onLetStatement) { + aCallbacks.onLetStatement(aNode); + } + for (let { id, init } of aNode.head) { + this[id.type](id, aNode, aCallbacks); + if (init) { + this[init.type](init, aNode, aCallbacks); + } + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A debugger statement. + * + * interface DebuggerStatement <: Statement { + * type: "DebuggerStatement"; + * } + */ + DebuggerStatement: function STV_DebuggerStatement(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onDebuggerStatement) { + aCallbacks.onDebuggerStatement(aNode); + } + }, + + /** + * Any declaration node. Note that declarations are considered statements; + * this is because declarations can appear in any statement context in the + * language recognized by the SpiderMonkey parser. + * + * interface Declaration <: Statement { } + */ + Declaration: function STV_Declaration(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onDeclaration) { + aCallbacks.onDeclaration(aNode); + } + }, + + /** + * A function declaration. + * + * interface FunctionDeclaration <: Function, Declaration { + * type: "FunctionDeclaration"; + * id: Identifier; + * params: [ Pattern ]; + * defaults: [ Expression ]; + * rest: Identifier | null; + * body: BlockStatement | Expression; + * generator: boolean; + * expression: boolean; + * } + */ + FunctionDeclaration: function STV_FunctionDeclaration(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onFunctionDeclaration) { + aCallbacks.onFunctionDeclaration(aNode); + } + this[aNode.id.type](aNode.id, aNode, aCallbacks); + for (let param of aNode.params) { + this[param.type](param, aNode, aCallbacks); + } + for (let _default of aNode.defaults) { + this[_default.type](_default, aNode, aCallbacks); + } + if (aNode.rest) { + this[aNode.rest.type](aNode.rest, aNode, aCallbacks); + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A variable declaration, via one of var, let, or const. + * + * interface VariableDeclaration <: Declaration { + * type: "VariableDeclaration"; + * declarations: [ VariableDeclarator ]; + * kind: "var" | "let" | "const"; + * } + */ + VariableDeclaration: function STV_VariableDeclaration(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onVariableDeclaration) { + aCallbacks.onVariableDeclaration(aNode); + } + for (let declaration of aNode.declarations) { + this[declaration.type](declaration, aNode, aCallbacks); + } + }, + + /** + * A variable declarator. + * + * interface VariableDeclarator <: Node { + * type: "VariableDeclarator"; + * id: Pattern; + * init: Expression | null; + * } + */ + VariableDeclarator: function STV_VariableDeclarator(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onVariableDeclarator) { + aCallbacks.onVariableDeclarator(aNode); + } + this[aNode.id.type](aNode.id, aNode, aCallbacks); + if (aNode.init) { + this[aNode.init.type](aNode.init, aNode, aCallbacks); + } + }, + + /** + * Any expression node. Since the left-hand side of an assignment may be any + * expression in general, an expression can also be a pattern. + * + * interface Expression <: Node, Pattern { } + */ + Expression: function STV_Expression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onExpression) { + aCallbacks.onExpression(aNode); + } + }, + + /** + * A this expression. + * + * interface ThisExpression <: Expression { + * type: "ThisExpression"; + * } + */ + ThisExpression: function STV_ThisExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onThisExpression) { + aCallbacks.onThisExpression(aNode); + } + }, + + /** + * An array expression. + * + * interface ArrayExpression <: Expression { + * type: "ArrayExpression"; + * elements: [ Expression | null ]; + * } + */ + ArrayExpression: function STV_ArrayExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onArrayExpression) { + aCallbacks.onArrayExpression(aNode); + } + for (let element of aNode.elements) { + if (element) { + this[element.type](element, aNode, aCallbacks); + } + } + }, + + /** + * An object expression. A literal property in an object expression can have + * either a string or number as its value. Ordinary property initializers + * have a kind value "init"; getters and setters have the kind values "get" + * and "set", respectively. + * + * interface ObjectExpression <: Expression { + * type: "ObjectExpression"; + * properties: [ { key: Literal | Identifier, + * value: Expression, + * kind: "init" | "get" | "set" } ]; + * } + */ + ObjectExpression: function STV_ObjectExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onObjectExpression) { + aCallbacks.onObjectExpression(aNode); + } + for (let { key, value } of aNode.properties) { + this[key.type](key, aNode, aCallbacks); + this[value.type](value, aNode, aCallbacks); + } + }, + + /** + * A function expression. + * + * interface FunctionExpression <: Function, Expression { + * type: "FunctionExpression"; + * id: Identifier | null; + * params: [ Pattern ]; + * defaults: [ Expression ]; + * rest: Identifier | null; + * body: BlockStatement | Expression; + * generator: boolean; + * expression: boolean; + * } + */ + FunctionExpression: function STV_FunctionExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onFunctionExpression) { + aCallbacks.onFunctionExpression(aNode); + } + if (aNode.id) { + this[aNode.id.type](aNode.id, aNode, aCallbacks); + } + for (let param of aNode.params) { + this[param.type](param, aNode, aCallbacks); + } + for (let _default of aNode.defaults) { + this[_default.type](_default, aNode, aCallbacks); + } + if (aNode.rest) { + this[aNode.rest.type](aNode.rest, aNode, aCallbacks); + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * An arrow expression. + * + * interface ArrowExpression <: Function, Expression { + * type: "ArrowExpression"; + * params: [ Pattern ]; + * defaults: [ Expression ]; + * rest: Identifier | null; + * body: BlockStatement | Expression; + * generator: boolean; + * expression: boolean; + * } + */ + ArrowExpression: function STV_ArrowExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onArrowExpression) { + aCallbacks.onArrowExpression(aNode); + } + for (let param of aNode.params) { + this[param.type](param, aNode, aCallbacks); + } + for (let _default of aNode.defaults) { + this[_default.type](_default, aNode, aCallbacks); + } + if (aNode.rest) { + this[aNode.rest.type](aNode.rest, aNode, aCallbacks); + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A sequence expression, i.e., a comma-separated sequence of expressions. + * + * interface SequenceExpression <: Expression { + * type: "SequenceExpression"; + * expressions: [ Expression ]; + * } + */ + SequenceExpression: function STV_SequenceExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onSequenceExpression) { + aCallbacks.onSequenceExpression(aNode); + } + for (let expression of aNode.expressions) { + this[expression.type](expression, aNode, aCallbacks); + } + }, + + /** + * A unary operator expression. + * + * interface UnaryExpression <: Expression { + * type: "UnaryExpression"; + * operator: UnaryOperator; + * prefix: boolean; + * argument: Expression; + * } + */ + UnaryExpression: function STV_UnaryExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onUnaryExpression) { + aCallbacks.onUnaryExpression(aNode); + } + this[aNode.argument.type](aNode.argument, aNode, aCallbacks); + }, + + /** + * A binary operator expression. + * + * interface BinaryExpression <: Expression { + * type: "BinaryExpression"; + * operator: BinaryOperator; + * left: Expression; + * right: Expression; + * } + */ + BinaryExpression: function STV_BinaryExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onBinaryExpression) { + aCallbacks.onBinaryExpression(aNode); + } + this[aNode.left.type](aNode.left, aNode, aCallbacks); + this[aNode.right.type](aNode.right, aNode, aCallbacks); + }, + + /** + * An assignment operator expression. + * + * interface AssignmentExpression <: Expression { + * type: "AssignmentExpression"; + * operator: AssignmentOperator; + * left: Expression; + * right: Expression; + * } + */ + AssignmentExpression: function STV_AssignmentExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onAssignmentExpression) { + aCallbacks.onAssignmentExpression(aNode); + } + this[aNode.left.type](aNode.left, aNode, aCallbacks); + this[aNode.right.type](aNode.right, aNode, aCallbacks); + }, + + /** + * An update (increment or decrement) operator expression. + * + * interface UpdateExpression <: Expression { + * type: "UpdateExpression"; + * operator: UpdateOperator; + * argument: Expression; + * prefix: boolean; + * } + */ + UpdateExpression: function STV_UpdateExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onUpdateExpression) { + aCallbacks.onUpdateExpression(aNode); + } + this[aNode.argument.type](aNode.argument, aNode, aCallbacks); + }, + + /** + * A logical operator expression. + * + * interface LogicalExpression <: Expression { + * type: "LogicalExpression"; + * operator: LogicalOperator; + * left: Expression; + * right: Expression; + * } + */ + LogicalExpression: function STV_LogicalExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onLogicalExpression) { + aCallbacks.onLogicalExpression(aNode); + } + this[aNode.left.type](aNode.left, aNode, aCallbacks); + this[aNode.right.type](aNode.right, aNode, aCallbacks); + }, + + /** + * A conditional expression, i.e., a ternary ?/: expression. + * + * interface ConditionalExpression <: Expression { + * type: "ConditionalExpression"; + * test: Expression; + * alternate: Expression; + * consequent: Expression; + * } + */ + ConditionalExpression: function STV_ConditionalExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onConditionalExpression) { + aCallbacks.onConditionalExpression(aNode); + } + this[aNode.test.type](aNode.test, aNode, aCallbacks); + this[aNode.alternate.type](aNode.alternate, aNode, aCallbacks); + this[aNode.consequent.type](aNode.consequent, aNode, aCallbacks); + }, + + /** + * A new expression. + * + * interface NewExpression <: Expression { + * type: "NewExpression"; + * callee: Expression; + * arguments: [ Expression | null ]; + * } + */ + NewExpression: function STV_NewExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onNewExpression) { + aCallbacks.onNewExpression(aNode); + } + this[aNode.callee.type](aNode.callee, aNode, aCallbacks); + for (let argument of aNode.arguments) { + if (argument) { + this[argument.type](argument, aNode, aCallbacks); + } + } + }, + + /** + * A function or method call expression. + * + * interface CallExpression <: Expression { + * type: "CallExpression"; + * callee: Expression; + * arguments: [ Expression | null ]; + * } + */ + CallExpression: function STV_CallExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onCallExpression) { + aCallbacks.onCallExpression(aNode); + } + this[aNode.callee.type](aNode.callee, aNode, aCallbacks); + for (let argument of aNode.arguments) { + if (argument) { + this[argument.type](argument, aNode, aCallbacks); + } + } + }, + + /** + * A member expression. If computed is true, the node corresponds to a + * computed e1[e2] expression and property is an Expression. If computed is + * false, the node corresponds to a static e1.x expression and property is an + * Identifier. + * + * interface MemberExpression <: Expression { + * type: "MemberExpression"; + * object: Expression; + * property: Identifier | Expression; + * computed: boolean; + * } + */ + MemberExpression: function STV_MemberExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onMemberExpression) { + aCallbacks.onMemberExpression(aNode); + } + this[aNode.object.type](aNode.object, aNode, aCallbacks); + this[aNode.property.type](aNode.property, aNode, aCallbacks); + }, + + /** + * A yield expression. + * + * interface YieldExpression <: Expression { + * argument: Expression | null; + * } + */ + YieldExpression: function STV_YieldExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onYieldExpression) { + aCallbacks.onYieldExpression(aNode); + } + if (aNode.argument) { + this[aNode.argument.type](aNode.argument, aNode, aCallbacks); + } + }, + + /** + * An array comprehension. The blocks array corresponds to the sequence of + * for and for each blocks. The optional filter expression corresponds to the + * final if clause, if present. + * + * interface ComprehensionExpression <: Expression { + * body: Expression; + * blocks: [ ComprehensionBlock ]; + * filter: Expression | null; + * } + */ + ComprehensionExpression: function STV_ComprehensionExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onComprehensionExpression) { + aCallbacks.onComprehensionExpression(aNode); + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + for (let block of aNode.blocks) { + this[block.type](block, aNode, aCallbacks); + } + if (aNode.filter) { + this[aNode.filter.type](aNode.filter, aNode, aCallbacks); + } + }, + + /** + * A generator expression. As with array comprehensions, the blocks array + * corresponds to the sequence of for and for each blocks, and the optional + * filter expression corresponds to the final if clause, if present. + * + * interface GeneratorExpression <: Expression { + * body: Expression; + * blocks: [ ComprehensionBlock ]; + * filter: Expression | null; + * } + */ + GeneratorExpression: function STV_GeneratorExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onGeneratorExpression) { + aCallbacks.onGeneratorExpression(aNode); + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + for (let block of aNode.blocks) { + this[block.type](block, aNode, aCallbacks); + } + if (aNode.filter) { + this[aNode.filter.type](aNode.filter, aNode, aCallbacks); + } + }, + + /** + * A graph expression, aka "sharp literal," such as #1={ self: #1# }. + * + * interface GraphExpression <: Expression { + * index: uint32; + * expression: Literal; + * } + */ + GraphExpression: function STV_GraphExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onGraphExpression) { + aCallbacks.onGraphExpression(aNode); + } + this[aNode.expression.type](aNode.expression, aNode, aCallbacks); + }, + + /** + * A graph index expression, aka "sharp variable," such as #1#. + * + * interface GraphIndexExpression <: Expression { + * index: uint32; + * } + */ + GraphIndexExpression: function STV_GraphIndexExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onGraphIndexExpression) { + aCallbacks.onGraphIndexExpression(aNode); + } + }, + + /** + * A let expression. + * + * interface LetExpression <: Expression { + * type: "LetExpression"; + * head: [ { id: Pattern, init: Expression | null } ]; + * body: Expression; + * } + */ + LetExpression: function STV_LetExpression(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onLetExpression) { + aCallbacks.onLetExpression(aNode); + } + for (let { id, init } of aNode.head) { + this[id.type](id, aNode, aCallbacks); + if (init) { + this[init.type](init, aNode, aCallbacks); + } + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * Any pattern. + * + * interface Pattern <: Node { } + */ + Pattern: function STV_Pattern(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onPattern) { + aCallbacks.onPattern(aNode); + } + }, + + /** + * An object-destructuring pattern. A literal property in an object pattern + * can have either a string or number as its value. + * + * interface ObjectPattern <: Pattern { + * type: "ObjectPattern"; + * properties: [ { key: Literal | Identifier, value: Pattern } ]; + * } + */ + ObjectPattern: function STV_ObjectPattern(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onObjectPattern) { + aCallbacks.onObjectPattern(aNode); + } + for (let { key, value } of aNode.properties) { + this[key.type](key, aNode, aCallbacks); + this[value.type](value, aNode, aCallbacks); + } + }, + + /** + * An array-destructuring pattern. + * + * interface ArrayPattern <: Pattern { + * type: "ArrayPattern"; + * elements: [ Pattern | null ]; + * } + */ + ArrayPattern: function STV_ArrayPattern(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onArrayPattern) { + aCallbacks.onArrayPattern(aNode); + } + for (let element of aNode.elements) { + if (element) { + this[element.type](element, aNode, aCallbacks); + } + } + }, + + /** + * A case (if test is an Expression) or default (if test is null) clause in + * the body of a switch statement. + * + * interface SwitchCase <: Node { + * type: "SwitchCase"; + * test: Expression | null; + * consequent: [ Statement ]; + * } + */ + SwitchCase: function STV_SwitchCase(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onSwitchCase) { + aCallbacks.onSwitchCase(aNode); + } + if (aNode.test) { + this[aNode.test.type](aNode.test, aNode, aCallbacks); + } + for (let consequent of aNode.consequent) { + this[consequent.type](consequent, aNode, aCallbacks); + } + }, + + /** + * A catch clause following a try block. The optional guard property + * corresponds to the optional expression guard on the bound variable. + * + * interface CatchClause <: Node { + * type: "CatchClause"; + * param: Pattern; + * guard: Expression | null; + * body: BlockStatement; + * } + */ + CatchClause: function STV_CatchClause(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onCatchClause) { + aCallbacks.onCatchClause(aNode); + } + this[aNode.param.type](aNode.param, aNode, aCallbacks); + if (aNode.guard) { + this[aNode.guard.type](aNode.guard, aNode, aCallbacks); + } + this[aNode.body.type](aNode.body, aNode, aCallbacks); + }, + + /** + * A for or for each block in an array comprehension or generator expression. + * + * interface ComprehensionBlock <: Node { + * left: Pattern; + * right: Expression; + * each: boolean; + * } + */ + ComprehensionBlock: function STV_ComprehensionBlock(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onComprehensionBlock) { + aCallbacks.onComprehensionBlock(aNode); + } + this[aNode.left.type](aNode.left, aNode, aCallbacks); + this[aNode.right.type](aNode.right, aNode, aCallbacks); + }, + + /** + * An identifier. Note that an identifier may be an expression or a + * destructuring pattern. + * + * interface Identifier <: Node, Expression, Pattern { + * type: "Identifier"; + * name: string; + * } + */ + Identifier: function STV_Identifier(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onIdentifier) { + aCallbacks.onIdentifier(aNode); + } + }, + + /** + * A literal token. Note that a literal can be an expression. + * + * interface Literal <: Node, Expression { + * type: "Literal"; + * value: string | boolean | null | number | RegExp; + * } + */ + Literal: function STV_Literal(aNode, aParent, aCallbacks) { + aNode._parent = aParent; + + if (this.break) { + return; + } + if (aCallbacks.onNode) { + if (aCallbacks.onNode(aNode, aParent) === false) { + return; + } + } + if (aCallbacks.onLiteral) { + aCallbacks.onLiteral(aNode); + } + } +}; + +/** + * Logs a warning. + * + * @param string aStr + * The message to be displayed. + * @param Exception aEx + * The thrown exception. + */ +function log(aStr, aEx) { + let msg = "Warning: " + aStr + ", " + aEx + "\n" + aEx.stack; + Cu.reportError(msg); + dump(msg + "\n"); +}; + +XPCOMUtils.defineLazyGetter(Parser, "reflectionAPI", function() Reflect); diff --git a/browser/devtools/shared/SplitView.jsm b/browser/devtools/shared/SplitView.jsm new file mode 100644 index 000000000..ec8376f45 --- /dev/null +++ b/browser/devtools/shared/SplitView.jsm @@ -0,0 +1,302 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SplitView"]; + +/* this must be kept in sync with CSS (ie. splitview.css) */ +const LANDSCAPE_MEDIA_QUERY = "(min-width: 551px)"; + +let bindings = new WeakMap(); + +/** + * SplitView constructor + * + * Initialize the split view UI on an existing DOM element. + * + * A split view contains items, each of those having one summary and one details + * elements. + * It is adaptive as it behaves similarly to a richlistbox when there the aspect + * ratio is narrow or as a pair listbox-box otherwise. + * + * @param DOMElement aRoot + * @see appendItem + */ +this.SplitView = function SplitView(aRoot) +{ + this._root = aRoot; + this._controller = aRoot.querySelector(".splitview-controller"); + this._nav = aRoot.querySelector(".splitview-nav"); + this._side = aRoot.querySelector(".splitview-side-details"); + this._activeSummary = null + + this._mql = aRoot.ownerDocument.defaultView.matchMedia(LANDSCAPE_MEDIA_QUERY); + + // items list focus and search-on-type handling + this._nav.addEventListener("keydown", function onKeyCatchAll(aEvent) { + function getFocusedItemWithin(nav) { + let node = nav.ownerDocument.activeElement; + while (node && node.parentNode != nav) { + node = node.parentNode; + } + return node; + } + + // do not steal focus from inside iframes or textboxes + if (aEvent.target.ownerDocument != this._nav.ownerDocument || + aEvent.target.tagName == "input" || + aEvent.target.tagName == "textbox" || + aEvent.target.tagName == "textarea" || + aEvent.target.classList.contains("textbox")) { + return false; + } + + // handle keyboard navigation within the items list + let newFocusOrdinal; + if (aEvent.keyCode == aEvent.DOM_VK_PAGE_UP || + aEvent.keyCode == aEvent.DOM_VK_HOME) { + newFocusOrdinal = 0; + } else if (aEvent.keyCode == aEvent.DOM_VK_PAGE_DOWN || + aEvent.keyCode == aEvent.DOM_VK_END) { + newFocusOrdinal = this._nav.childNodes.length - 1; + } else if (aEvent.keyCode == aEvent.DOM_VK_UP) { + newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute("data-ordinal"); + newFocusOrdinal--; + } else if (aEvent.keyCode == aEvent.DOM_VK_DOWN) { + newFocusOrdinal = getFocusedItemWithin(this._nav).getAttribute("data-ordinal"); + newFocusOrdinal++; + } + if (newFocusOrdinal !== undefined) { + aEvent.stopPropagation(); + let el = this.getSummaryElementByOrdinal(newFocusOrdinal); + if (el) { + el.focus(); + } + return false; + } + }.bind(this), false); +} + +SplitView.prototype = { + /** + * Retrieve whether the UI currently has a landscape orientation. + * + * @return boolean + */ + get isLandscape() this._mql.matches, + + /** + * Retrieve the root element. + * + * @return DOMElement + */ + get rootElement() this._root, + + /** + * Retrieve the active item's summary element or null if there is none. + * + * @return DOMElement + */ + get activeSummary() this._activeSummary, + + /** + * Set the active item's summary element. + * + * @param DOMElement aSummary + */ + set activeSummary(aSummary) + { + if (aSummary == this._activeSummary) { + return; + } + + if (this._activeSummary) { + let binding = bindings.get(this._activeSummary); + + if (binding.onHide) { + binding.onHide(this._activeSummary, binding._details, binding.data); + } + + this._activeSummary.classList.remove("splitview-active"); + binding._details.classList.remove("splitview-active"); + } + + if (!aSummary) { + return; + } + + let binding = bindings.get(aSummary); + aSummary.classList.add("splitview-active"); + binding._details.classList.add("splitview-active"); + + this._activeSummary = aSummary; + + if (binding.onShow) { + binding.onShow(aSummary, binding._details, binding.data); + } + }, + + /** + * Retrieve the active item's details element or null if there is none. + * @return DOMElement + */ + get activeDetails() + { + let summary = this.activeSummary; + return summary ? bindings.get(summary)._details : null; + }, + + /** + * Retrieve the summary element for a given ordinal. + * + * @param number aOrdinal + * @return DOMElement + * Summary element with given ordinal or null if not found. + * @see appendItem + */ + getSummaryElementByOrdinal: function SEC_getSummaryElementByOrdinal(aOrdinal) + { + return this._nav.querySelector("* > li[data-ordinal='" + aOrdinal + "']"); + }, + + /** + * Append an item to the split view. + * + * @param DOMElement aSummary + * The summary element for the item. + * @param DOMElement aDetails + * The details element for the item. + * @param object aOptions + * Optional object that defines custom behavior and data for the item. + * All properties are optional : + * - function(DOMElement summary, DOMElement details, object data) onCreate + * Called when the item has been added. + * - function(summary, details, data) onShow + * Called when the item is shown/active. + * - function(summary, details, data) onHide + * Called when the item is hidden/inactive. + * - function(summary, details, data) onDestroy + * Called when the item has been removed. + * - object data + * Object to pass to the callbacks above. + * - number ordinal + * Items with a lower ordinal are displayed before those with a + * higher ordinal. + */ + appendItem: function ASV_appendItem(aSummary, aDetails, aOptions) + { + let binding = aOptions || {}; + + binding._summary = aSummary; + binding._details = aDetails; + bindings.set(aSummary, binding); + + this._nav.appendChild(aSummary); + + aSummary.addEventListener("click", function onSummaryClick(aEvent) { + aEvent.stopPropagation(); + this.activeSummary = aSummary; + }.bind(this), false); + + this._side.appendChild(aDetails); + + if (binding.onCreate) { + // queue onCreate handler + this._root.ownerDocument.defaultView.setTimeout(function () { + binding.onCreate(aSummary, aDetails, binding.data); + }, 0); + } + }, + + /** + * Append an item to the split view according to two template elements + * (one for the item's summary and the other for the item's details). + * + * @param string aName + * Name of the template elements to instantiate. + * Requires two (hidden) DOM elements with id "splitview-tpl-summary-" + * and "splitview-tpl-details-" suffixed with aName. + * @param object aOptions + * Optional object that defines custom behavior and data for the item. + * See appendItem for full description. + * @return object{summary:,details:} + * Object with the new DOM elements created for summary and details. + * @see appendItem + */ + appendTemplatedItem: function ASV_appendTemplatedItem(aName, aOptions) + { + aOptions = aOptions || {}; + let summary = this._root.querySelector("#splitview-tpl-summary-" + aName); + let details = this._root.querySelector("#splitview-tpl-details-" + aName); + + summary = summary.cloneNode(true); + summary.id = ""; + if (aOptions.ordinal !== undefined) { // can be zero + summary.style.MozBoxOrdinalGroup = aOptions.ordinal; + summary.setAttribute("data-ordinal", aOptions.ordinal); + } + details = details.cloneNode(true); + details.id = ""; + + this.appendItem(summary, details, aOptions); + return {summary: summary, details: details}; + }, + + /** + * Remove an item from the split view. + * + * @param DOMElement aSummary + * Summary element of the item to remove. + */ + removeItem: function ASV_removeItem(aSummary) + { + if (aSummary == this._activeSummary) { + this.activeSummary = null; + } + + let binding = bindings.get(aSummary); + aSummary.parentNode.removeChild(aSummary); + binding._details.parentNode.removeChild(binding._details); + + if (binding.onDestroy) { + binding.onDestroy(aSummary, binding._details, binding.data); + } + }, + + /** + * Remove all items from the split view. + */ + removeAll: function ASV_removeAll() + { + while (this._nav.hasChildNodes()) { + this.removeItem(this._nav.firstChild); + } + }, + + /** + * Set the item's CSS class name. + * This sets the class on both the summary and details elements, retaining + * any SplitView-specific classes. + * + * @param DOMElement aSummary + * Summary element of the item to set. + * @param string aClassName + * One or more space-separated CSS classes. + */ + setItemClassName: function ASV_setItemClassName(aSummary, aClassName) + { + let binding = bindings.get(aSummary); + let viewSpecific; + + viewSpecific = aSummary.className.match(/(splitview\-[\w-]+)/g); + viewSpecific = viewSpecific ? viewSpecific.join(" ") : ""; + aSummary.className = viewSpecific + " " + aClassName; + + viewSpecific = binding._details.className.match(/(splitview\-[\w-]+)/g); + viewSpecific = viewSpecific ? viewSpecific.join(" ") : ""; + binding._details.className = viewSpecific + " " + aClassName; + }, +}; diff --git a/browser/devtools/shared/event-emitter.js b/browser/devtools/shared/event-emitter.js new file mode 100644 index 000000000..44f5fd5e3 --- /dev/null +++ b/browser/devtools/shared/event-emitter.js @@ -0,0 +1,118 @@ +/* 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/. */ + +/** + * EventEmitter. + */ + +this.EventEmitter = function EventEmitter() {}; + +if (typeof(require) === "function") { + module.exports = EventEmitter; + var {Cu} = require("chrome"); +} else { + var EXPORTED_SYMBOLS = ["EventEmitter"]; + var Cu = this["Components"].utils; +} + +/** + * Decorate an object with event emitter functionality. + * + * @param Object aObjectToDecorate + * Bind all public methods of EventEmitter to + * the aObjectToDecorate object. + */ +EventEmitter.decorate = function EventEmitter_decorate (aObjectToDecorate) { + let emitter = new EventEmitter(); + aObjectToDecorate.on = emitter.on.bind(emitter); + aObjectToDecorate.off = emitter.off.bind(emitter); + aObjectToDecorate.once = emitter.once.bind(emitter); + aObjectToDecorate.emit = emitter.emit.bind(emitter); +}; + +EventEmitter.prototype = { + /** + * Connect a listener. + * + * @param string aEvent + * The event name to which we're connecting. + * @param function aListener + * Called when the event is fired. + */ + on: function EventEmitter_on(aEvent, aListener) { + if (!this._eventEmitterListeners) + this._eventEmitterListeners = new Map(); + if (!this._eventEmitterListeners.has(aEvent)) { + this._eventEmitterListeners.set(aEvent, []); + } + this._eventEmitterListeners.get(aEvent).push(aListener); + }, + + /** + * Listen for the next time an event is fired. + * + * @param string aEvent + * The event name to which we're connecting. + * @param function aListener + * Called when the event is fired. Will be called at most one time. + */ + once: function EventEmitter_once(aEvent, aListener) { + let handler = function() { + this.off(aEvent, handler); + aListener.apply(null, arguments); + }.bind(this); + this.on(aEvent, handler); + }, + + /** + * Remove a previously-registered event listener. Works for events + * registered with either on or once. + * + * @param string aEvent + * The event name whose listener we're disconnecting. + * @param function aListener + * The listener to remove. + */ + off: function EventEmitter_off(aEvent, aListener) { + if (!this._eventEmitterListeners) + return; + let listeners = this._eventEmitterListeners.get(aEvent); + if (listeners) { + this._eventEmitterListeners.set(aEvent, listeners.filter(function(l) aListener != l)); + } + }, + + /** + * Emit an event. All arguments to this method will + * be sent to listner functions. + */ + emit: function EventEmitter_emit(aEvent) { + if (!this._eventEmitterListeners || !this._eventEmitterListeners.has(aEvent)) + return; + + let originalListeners = this._eventEmitterListeners.get(aEvent); + for (let listener of this._eventEmitterListeners.get(aEvent)) { + // If the object was destroyed during event emission, stop + // emitting. + if (!this._eventEmitterListeners) { + break; + } + + // If listeners were removed during emission, make sure the + // event handler we're going to fire wasn't removed. + if (originalListeners === this._eventEmitterListeners.get(aEvent) || + this._eventEmitterListeners.get(aEvent).some(function(l) l === listener)) { + try { + listener.apply(null, arguments); + } + catch (ex) { + // Prevent a bad listener from interfering with the others. + let msg = ex + ": " + ex.stack; + Cu.reportError(msg); + dump(msg + "\n"); + } + } + } + } +}; diff --git a/browser/devtools/shared/inplace-editor.js b/browser/devtools/shared/inplace-editor.js new file mode 100644 index 000000000..cc5d28038 --- /dev/null +++ b/browser/devtools/shared/inplace-editor.js @@ -0,0 +1,851 @@ +/* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set 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/. + * + * Basic use: + * let spanToEdit = document.getElementById("somespan"); + * + * editableField({ + * element: spanToEdit, + * done: function(value, commit) { + * if (commit) { + * spanToEdit.textContent = value; + * } + * }, + * trigger: "dblclick" + * }); + * + * See editableField() for more options. + */ + +"use strict"; + +const {Ci, Cu} = require("chrome"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +const FOCUS_FORWARD = Ci.nsIFocusManager.MOVEFOCUS_FORWARD; +const FOCUS_BACKWARD = Ci.nsIFocusManager.MOVEFOCUS_BACKWARD; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * Mark a span editable. |editableField| will listen for the span to + * be focused and create an InlineEditor to handle text input. + * Changes will be committed when the InlineEditor's input is blurred + * or dropped when the user presses escape. + * + * @param {object} aOptions + * Options for the editable field, including: + * {Element} element: + * (required) The span to be edited on focus. + * {function} canEdit: + * Will be called before creating the inplace editor. Editor + * won't be created if canEdit returns false. + * {function} start: + * Will be called when the inplace editor is initialized. + * {function} change: + * Will be called when the text input changes. Will be called + * with the current value of the text input. + * {function} done: + * Called when input is committed or blurred. Called with + * current value and a boolean telling the caller whether to + * commit the change. This function is called before the editor + * has been torn down. + * {function} destroy: + * Called when the editor is destroyed and has been torn down. + * {string} advanceChars: + * If any characters in advanceChars are typed, focus will advance + * to the next element. + * {boolean} stopOnReturn: + * If true, the return key will not advance the editor to the next + * focusable element. + * {string} trigger: The DOM event that should trigger editing, + * defaults to "click" + */ +function editableField(aOptions) +{ + return editableItem(aOptions, function(aElement, aEvent) { + new InplaceEditor(aOptions, aEvent); + }); +} + +exports.editableField = editableField; + +/** + * Handle events for an element that should respond to + * clicks and sit in the editing tab order, and call + * a callback when it is activated. + * + * @param {object} aOptions + * The options for this editor, including: + * {Element} element: The DOM element. + * {string} trigger: The DOM event that should trigger editing, + * defaults to "click" + * @param {function} aCallback + * Called when the editor is activated. + */ +function editableItem(aOptions, aCallback) +{ + let trigger = aOptions.trigger || "click" + let element = aOptions.element; + element.addEventListener(trigger, function(evt) { + let win = this.ownerDocument.defaultView; + let selection = win.getSelection(); + if (trigger != "click" || selection.isCollapsed) { + aCallback(element, evt); + } + evt.stopPropagation(); + }, false); + + // If focused by means other than a click, start editing by + // pressing enter or space. + element.addEventListener("keypress", function(evt) { + if (evt.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN || + evt.charCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) { + aCallback(element); + } + }, true); + + // Ugly workaround - the element is focused on mousedown but + // the editor is activated on click/mouseup. This leads + // to an ugly flash of the focus ring before showing the editor. + // So hide the focus ring while the mouse is down. + element.addEventListener("mousedown", function(evt) { + let cleanup = function() { + element.style.removeProperty("outline-style"); + element.removeEventListener("mouseup", cleanup, false); + element.removeEventListener("mouseout", cleanup, false); + }; + element.style.setProperty("outline-style", "none"); + element.addEventListener("mouseup", cleanup, false); + element.addEventListener("mouseout", cleanup, false); + }, false); + + // Mark the element editable field for tab + // navigation while editing. + element._editable = true; +} + +exports.editableItem = this.editableItem; + +/* + * Various API consumers (especially tests) sometimes want to grab the + * inplaceEditor expando off span elements. However, when each global has its + * own compartment, those expandos live on Xray wrappers that are only visible + * within this JSM. So we provide a little workaround here. + */ + +function getInplaceEditorForSpan(aSpan) +{ + return aSpan.inplaceEditor; +}; +exports.getInplaceEditorForSpan = getInplaceEditorForSpan; + +function InplaceEditor(aOptions, aEvent) +{ + this.elt = aOptions.element; + let doc = this.elt.ownerDocument; + this.doc = doc; + this.elt.inplaceEditor = this; + + this.change = aOptions.change; + this.done = aOptions.done; + this.destroy = aOptions.destroy; + this.initial = aOptions.initial ? aOptions.initial : this.elt.textContent; + this.multiline = aOptions.multiline || false; + this.stopOnReturn = !!aOptions.stopOnReturn; + + this._onBlur = this._onBlur.bind(this); + this._onKeyPress = this._onKeyPress.bind(this); + this._onInput = this._onInput.bind(this); + this._onKeyup = this._onKeyup.bind(this); + + this._createInput(); + this._autosize(); + + // Pull out character codes for advanceChars, listing the + // characters that should trigger a blur. + this._advanceCharCodes = {}; + let advanceChars = aOptions.advanceChars || ''; + for (let i = 0; i < advanceChars.length; i++) { + this._advanceCharCodes[advanceChars.charCodeAt(i)] = true; + } + + // Hide the provided element and add our editor. + this.originalDisplay = this.elt.style.display; + this.elt.style.display = "none"; + this.elt.parentNode.insertBefore(this.input, this.elt); + + if (typeof(aOptions.selectAll) == "undefined" || aOptions.selectAll) { + this.input.select(); + } + this.input.focus(); + + this.input.addEventListener("blur", this._onBlur, false); + this.input.addEventListener("keypress", this._onKeyPress, false); + this.input.addEventListener("input", this._onInput, false); + this.input.addEventListener("mousedown", function(aEvt) { + aEvt.stopPropagation(); + }, false); + + this.warning = aOptions.warning; + this.validate = aOptions.validate; + + if (this.warning && this.validate) { + this.input.addEventListener("keyup", this._onKeyup, false); + } + + if (aOptions.start) { + aOptions.start(this, aEvent); + } +} + +exports.InplaceEditor = InplaceEditor; + +InplaceEditor.prototype = { + _createInput: function InplaceEditor_createEditor() + { + this.input = + this.doc.createElementNS(HTML_NS, this.multiline ? "textarea" : "input"); + this.input.inplaceEditor = this; + this.input.classList.add("styleinspector-propertyeditor"); + this.input.value = this.initial; + + copyTextStyles(this.elt, this.input); + }, + + /** + * Get rid of the editor. + */ + _clear: function InplaceEditor_clear() + { + if (!this.input) { + // Already cleared. + return; + } + + this.input.removeEventListener("blur", this._onBlur, false); + this.input.removeEventListener("keypress", this._onKeyPress, false); + this.input.removeEventListener("keyup", this._onKeyup, false); + this.input.removeEventListener("oninput", this._onInput, false); + this._stopAutosize(); + + this.elt.style.display = this.originalDisplay; + this.elt.focus(); + + if (this.destroy) { + this.destroy(); + } + + this.elt.parentNode.removeChild(this.input); + this.input = null; + + delete this.elt.inplaceEditor; + delete this.elt; + }, + + /** + * Keeps the editor close to the size of its input string. This is pretty + * crappy, suggestions for improvement welcome. + */ + _autosize: function InplaceEditor_autosize() + { + // Create a hidden, absolutely-positioned span to measure the text + // in the input. Boo. + + // We can't just measure the original element because a) we don't + // change the underlying element's text ourselves (we leave that + // up to the client), and b) without tweaking the style of the + // original element, it might wrap differently or something. + this._measurement = + this.doc.createElementNS(HTML_NS, this.multiline ? "pre" : "span"); + this._measurement.className = "autosizer"; + this.elt.parentNode.appendChild(this._measurement); + let style = this._measurement.style; + style.visibility = "hidden"; + style.position = "absolute"; + style.top = "0"; + style.left = "0"; + copyTextStyles(this.input, this._measurement); + this._updateSize(); + }, + + /** + * Clean up the mess created by _autosize(). + */ + _stopAutosize: function InplaceEditor_stopAutosize() + { + if (!this._measurement) { + return; + } + this._measurement.parentNode.removeChild(this._measurement); + delete this._measurement; + }, + + /** + * Size the editor to fit its current contents. + */ + _updateSize: function InplaceEditor_updateSize() + { + // Replace spaces with non-breaking spaces. Otherwise setting + // the span's textContent will collapse spaces and the measurement + // will be wrong. + this._measurement.textContent = this.input.value.replace(/ /g, '\u00a0'); + + // We add a bit of padding to the end. Should be enough to fit + // any letter that could be typed, otherwise we'll scroll before + // we get a chance to resize. Yuck. + let width = this._measurement.offsetWidth + 10; + + if (this.multiline) { + // Make sure there's some content in the current line. This is a hack to + // account for the fact that after adding a newline the <pre> doesn't grow + // unless there's text content on the line. + width += 15; + this._measurement.textContent += "M"; + this.input.style.height = this._measurement.offsetHeight + "px"; + } + + this.input.style.width = width + "px"; + }, + + /** + * Increment property values in rule view. + * + * @param {number} increment + * The amount to increase/decrease the property value. + * @return {bool} true if value has been incremented. + */ + _incrementValue: function InplaceEditor_incrementValue(increment) + { + let value = this.input.value; + let selectionStart = this.input.selectionStart; + let selectionEnd = this.input.selectionEnd; + + let newValue = this._incrementCSSValue(value, increment, selectionStart, + selectionEnd); + + if (!newValue) { + return false; + } + + this.input.value = newValue.value; + this.input.setSelectionRange(newValue.start, newValue.end); + + return true; + }, + + /** + * Increment the property value based on the property type. + * + * @param {string} value + * Property value. + * @param {number} increment + * Amount to increase/decrease the property value. + * @param {number} selStart + * Starting index of the value. + * @param {number} selEnd + * Ending index of the value. + * @return {object} object with properties 'value', 'start', and 'end'. + */ + _incrementCSSValue: function InplaceEditor_incrementCSSValue(value, increment, + selStart, selEnd) + { + let range = this._parseCSSValue(value, selStart); + let type = (range && range.type) || ""; + let rawValue = (range ? value.substring(range.start, range.end) : ""); + let incrementedValue = null, selection; + + if (type === "num") { + let newValue = this._incrementRawValue(rawValue, increment); + if (newValue !== null) { + incrementedValue = newValue; + selection = [0, incrementedValue.length]; + } + } else if (type === "hex") { + let exprOffset = selStart - range.start; + let exprOffsetEnd = selEnd - range.start; + let newValue = this._incHexColor(rawValue, increment, exprOffset, + exprOffsetEnd); + if (newValue) { + incrementedValue = newValue.value; + selection = newValue.selection; + } + } else { + let info; + if (type === "rgb" || type === "hsl") { + info = {}; + let part = value.substring(range.start, selStart).split(",").length - 1; + if (part === 3) { // alpha + info.minValue = 0; + info.maxValue = 1; + } else if (type === "rgb") { + info.minValue = 0; + info.maxValue = 255; + } else if (part !== 0) { // hsl percentage + info.minValue = 0; + info.maxValue = 100; + + // select the previous number if the selection is at the end of a + // percentage sign. + if (value.charAt(selStart - 1) === "%") { + --selStart; + } + } + } + return this._incrementGenericValue(value, increment, selStart, selEnd, info); + } + + if (incrementedValue === null) { + return; + } + + let preRawValue = value.substr(0, range.start); + let postRawValue = value.substr(range.end); + + return { + value: preRawValue + incrementedValue + postRawValue, + start: range.start + selection[0], + end: range.start + selection[1] + }; + }, + + /** + * Parses the property value and type. + * + * @param {string} value + * Property value. + * @param {number} offset + * Starting index of value. + * @return {object} object with properties 'value', 'start', 'end', and 'type'. + */ + _parseCSSValue: function InplaceEditor_parseCSSValue(value, offset) + { + const reSplitCSS = /(url\("?[^"\)]+"?\)?)|(rgba?\([^)]*\)?)|(hsla?\([^)]*\)?)|(#[\dA-Fa-f]+)|(-?\d+(\.\d+)?(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/; + let start = 0; + let m; + + // retreive values from left to right until we find the one at our offset + while ((m = reSplitCSS.exec(value)) && + (m.index + m[0].length < offset)) { + value = value.substr(m.index + m[0].length); + start += m.index + m[0].length; + offset -= m.index + m[0].length; + } + + if (!m) { + return; + } + + let type; + if (m[1]) { + type = "url"; + } else if (m[2]) { + type = "rgb"; + } else if (m[3]) { + type = "hsl"; + } else if (m[4]) { + type = "hex"; + } else if (m[5]) { + type = "num"; + } + + return { + value: m[0], + start: start + m.index, + end: start + m.index + m[0].length, + type: type + }; + }, + + /** + * Increment the property value for types other than + * number or hex, such as rgb, hsl, and file names. + * + * @param {string} value + * Property value. + * @param {number} increment + * Amount to increment/decrement. + * @param {number} offset + * Starting index of the property value. + * @param {number} offsetEnd + * Ending index of the property value. + * @param {object} info + * Object with details about the property value. + * @return {object} object with properties 'value', 'start', and 'end'. + */ + _incrementGenericValue: + function InplaceEditor_incrementGenericValue(value, increment, offset, + offsetEnd, info) + { + // Try to find a number around the cursor to increment. + let start, end; + // Check if we are incrementing in a non-number context (such as a URL) + if (/^-?[0-9.]/.test(value.substring(offset, offsetEnd)) && + !(/\d/.test(value.charAt(offset - 1) + value.charAt(offsetEnd)))) { + // We have a number selected, possibly with a suffix, and we are not in + // the disallowed case of just part of a known number being selected. + // Use that number. + start = offset; + end = offsetEnd; + } else { + // Parse periods as belonging to the number only if we are in a known number + // context. (This makes incrementing the 1 in 'image1.gif' work.) + let pattern = "[" + (info ? "0-9." : "0-9") + "]*"; + let before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0].length; + let after = new RegExp("^" + pattern).exec(value.substr(offset))[0].length; + + start = offset - before; + end = offset + after; + + // Expand the number to contain an initial minus sign if it seems + // free-standing. + if (value.charAt(start - 1) === "-" && + (start - 1 === 0 || /[ (:,='"]/.test(value.charAt(start - 2)))) { + --start; + } + } + + if (start !== end) + { + // Include percentages as part of the incremented number (they are + // common enough). + if (value.charAt(end) === "%") { + ++end; + } + + let first = value.substr(0, start); + let mid = value.substring(start, end); + let last = value.substr(end); + + mid = this._incrementRawValue(mid, increment, info); + + if (mid !== null) { + return { + value: first + mid + last, + start: start, + end: start + mid.length + }; + } + } + }, + + /** + * Increment the property value for numbers. + * + * @param {string} rawValue + * Raw value to increment. + * @param {number} increment + * Amount to increase/decrease the raw value. + * @param {object} info + * Object with info about the property value. + * @return {string} the incremented value. + */ + _incrementRawValue: + function InplaceEditor_incrementRawValue(rawValue, increment, info) + { + let num = parseFloat(rawValue); + + if (isNaN(num)) { + return null; + } + + let number = /\d+(\.\d+)?/.exec(rawValue); + let units = rawValue.substr(number.index + number[0].length); + + // avoid rounding errors + let newValue = Math.round((num + increment) * 1000) / 1000; + + if (info && "minValue" in info) { + newValue = Math.max(newValue, info.minValue); + } + if (info && "maxValue" in info) { + newValue = Math.min(newValue, info.maxValue); + } + + newValue = newValue.toString(); + + return newValue + units; + }, + + /** + * Increment the property value for hex. + * + * @param {string} value + * Property value. + * @param {number} increment + * Amount to increase/decrease the property value. + * @param {number} offset + * Starting index of the property value. + * @param {number} offsetEnd + * Ending index of the property value. + * @return {object} object with properties 'value' and 'selection'. + */ + _incHexColor: + function InplaceEditor_incHexColor(rawValue, increment, offset, offsetEnd) + { + // Return early if no part of the rawValue is selected. + if (offsetEnd > rawValue.length && offset >= rawValue.length) { + return; + } + if (offset < 1 && offsetEnd <= 1) { + return; + } + // Ignore the leading #. + rawValue = rawValue.substr(1); + --offset; + --offsetEnd; + + // Clamp the selection to within the actual value. + offset = Math.max(offset, 0); + offsetEnd = Math.min(offsetEnd, rawValue.length); + offsetEnd = Math.max(offsetEnd, offset); + + // Normalize #ABC -> #AABBCC. + if (rawValue.length === 3) { + rawValue = rawValue.charAt(0) + rawValue.charAt(0) + + rawValue.charAt(1) + rawValue.charAt(1) + + rawValue.charAt(2) + rawValue.charAt(2); + offset *= 2; + offsetEnd *= 2; + } + + if (rawValue.length !== 6) { + return; + } + + // If no selection, increment an adjacent color, preferably one to the left. + if (offset === offsetEnd) { + if (offset === 0) { + offsetEnd = 1; + } else { + offset = offsetEnd - 1; + } + } + + // Make the selection cover entire parts. + offset -= offset % 2; + offsetEnd += offsetEnd % 2; + + // Remap the increments from [0.1, 1, 10] to [1, 1, 16]. + if (-1 < increment && increment < 1) { + increment = (increment < 0 ? -1 : 1); + } + if (Math.abs(increment) === 10) { + increment = (increment < 0 ? -16 : 16); + } + + let isUpper = (rawValue.toUpperCase() === rawValue); + + for (let pos = offset; pos < offsetEnd; pos += 2) { + // Increment the part in [pos, pos+2). + let mid = rawValue.substr(pos, 2); + let value = parseInt(mid, 16); + + if (isNaN(value)) { + return; + } + + mid = Math.min(Math.max(value + increment, 0), 255).toString(16); + + while (mid.length < 2) { + mid = "0" + mid; + } + if (isUpper) { + mid = mid.toUpperCase(); + } + + rawValue = rawValue.substr(0, pos) + mid + rawValue.substr(pos + 2); + } + + return { + value: "#" + rawValue, + selection: [offset + 1, offsetEnd + 1] + }; + }, + + /** + * Call the client's done handler and clear out. + */ + _apply: function InplaceEditor_apply(aEvent) + { + if (this._applied) { + return; + } + + this._applied = true; + + if (this.done) { + let val = this.input.value.trim(); + return this.done(this.cancelled ? this.initial : val, !this.cancelled); + } + + return null; + }, + + /** + * Handle loss of focus by calling done if it hasn't been called yet. + */ + _onBlur: function InplaceEditor_onBlur(aEvent, aDoNotClear) + { + this._apply(); + if (!aDoNotClear) { + this._clear(); + } + }, + + /** + * Handle the input field's keypress event. + */ + _onKeyPress: function InplaceEditor_onKeyPress(aEvent) + { + let prevent = false; + + const largeIncrement = 100; + const mediumIncrement = 10; + const smallIncrement = 0.1; + + let increment = 0; + + if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_UP + || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP) { + increment = 1; + } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_DOWN + || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) { + increment = -1; + } + + if (aEvent.shiftKey && !aEvent.altKey) { + if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP + || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) { + increment *= largeIncrement; + } else { + increment *= mediumIncrement; + } + } else if (aEvent.altKey && !aEvent.shiftKey) { + increment *= smallIncrement; + } + + if (increment && this._incrementValue(increment) ) { + this._updateSize(); + prevent = true; + } + + if (this.multiline && + aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN && + aEvent.shiftKey) { + prevent = false; + } else if (aEvent.charCode in this._advanceCharCodes + || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN + || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB) { + prevent = true; + + let direction = FOCUS_FORWARD; + if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB && + aEvent.shiftKey) { + this.cancelled = true; + direction = FOCUS_BACKWARD; + } + if (this.stopOnReturn && aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN) { + direction = null; + } + + let input = this.input; + + this._apply(); + + if (direction !== null && focusManager.focusedElement === input) { + // If the focused element wasn't changed by the done callback, + // move the focus as requested. + let next = moveFocus(this.doc.defaultView, direction); + + // If the next node to be focused has been tagged as an editable + // node, send it a click event to trigger + if (next && next.ownerDocument === this.doc && next._editable) { + next.click(); + } + } + + this._clear(); + } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE) { + // Cancel and blur ourselves. + prevent = true; + this.cancelled = true; + this._apply(); + this._clear(); + aEvent.stopPropagation(); + } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) { + // No need for leading spaces here. This is particularly + // noticable when adding a property: it's very natural to type + // <name>: (which advances to the next property) then spacebar. + prevent = !this.input.value; + } + + if (prevent) { + aEvent.preventDefault(); + } + }, + + /** + * Handle the input field's keyup event. + */ + _onKeyup: function(aEvent) { + // Validate the entered value. + this.warning.hidden = this.validate(this.input.value); + this._applied = false; + this._onBlur(null, true); + }, + + /** + * Handle changes to the input text. + */ + _onInput: function InplaceEditor_onInput(aEvent) + { + // Validate the entered value. + if (this.warning && this.validate) { + this.warning.hidden = this.validate(this.input.value); + } + + // Update size if we're autosizing. + if (this._measurement) { + this._updateSize(); + } + + // Call the user's change handler if available. + if (this.change) { + this.change(this.input.value.trim()); + } + } +}; + +/** + * Copy text-related styles from one element to another. + */ +function copyTextStyles(aFrom, aTo) +{ + let win = aFrom.ownerDocument.defaultView; + let style = win.getComputedStyle(aFrom); + aTo.style.fontFamily = style.getPropertyCSSValue("font-family").cssText; + aTo.style.fontSize = style.getPropertyCSSValue("font-size").cssText; + aTo.style.fontWeight = style.getPropertyCSSValue("font-weight").cssText; + aTo.style.fontStyle = style.getPropertyCSSValue("font-style").cssText; +} + +/** + * Trigger a focus change similar to pressing tab/shift-tab. + */ +function moveFocus(aWin, aDirection) +{ + return focusManager.moveFocus(aWin, null, aDirection, 0); +} + + +XPCOMUtils.defineLazyGetter(this, "focusManager", function() { + return Services.focus; +}); diff --git a/browser/devtools/shared/moz.build b/browser/devtools/shared/moz.build new file mode 100644 index 000000000..86ec46748 --- /dev/null +++ b/browser/devtools/shared/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# 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/. + +TEST_DIRS += ['test'] diff --git a/browser/devtools/shared/splitview.css b/browser/devtools/shared/splitview.css new file mode 100644 index 000000000..72d4d61f9 --- /dev/null +++ b/browser/devtools/shared/splitview.css @@ -0,0 +1,98 @@ +/* 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/. */ + +box, +.splitview-nav { + -moz-box-flex: 1; + -moz-box-orient: vertical; +} + +.splitview-nav-container { + -moz-box-pack: center; +} + +.loading .splitview-nav-container > .placeholder { + display: none !important; +} + +.splitview-controller, +.splitview-main { + -moz-box-flex: 0; +} + +.splitview-controller { + min-height: 3em; + max-height: 14em; + max-width: 400px; +} + +.splitview-nav { + display: -moz-box; + overflow-x: hidden; + overflow-y: auto; +} + +/* only the active details pane is shown */ +.splitview-side-details > * { + display: none; +} +.splitview-side-details > .splitview-active { + display: -moz-box; +} + +.splitview-landscape-resizer { + cursor: ew-resize; +} + +/* this is to keep in sync with SplitView.jsm's LANDSCAPE_MEDIA_QUERY */ +@media (min-width: 551px) { + .splitview-root { + -moz-box-orient: horizontal; + } + .splitview-controller { + max-height: none; + } + .splitview-details { + display: none; + } + .splitview-details.splitview-active { + display: -moz-box; + } +} + +/* filtered items are hidden */ +ol.splitview-nav > li.splitview-filtered { + display: none; +} + +/* "empty list" and "all filtered" placeholders are hidden */ +.splitview-nav:empty, +.splitview-nav.splitview-all-filtered, +.splitview-nav + .splitview-nav.placeholder { + display: none; +} +.splitview-nav.splitview-all-filtered ~ .splitview-nav.placeholder.all-filtered, +.splitview-nav:empty ~ .splitview-nav.placeholder.empty { + display: -moz-box; +} + +.splitview-portrait-resizer { + display: none; +} + +/* portrait mode */ +@media (max-width: 550px) { + .splitview-landscape-splitter { + display: none; + } + + .splitview-portrait-resizer { + display: -moz-box; + } + + .splitview-controller { + max-width: none; + } +} diff --git a/browser/devtools/shared/telemetry.js b/browser/devtools/shared/telemetry.js new file mode 100644 index 000000000..ef0f957b0 --- /dev/null +++ b/browser/devtools/shared/telemetry.js @@ -0,0 +1,259 @@ +/* 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/. */ + +/** + * Telemetry. + * + * To add metrics for a tool: + * + * 1. Create boolean, flag and exponential entries in + * toolkit/components/telemetry/Histograms.json. Each type is optional but it + * is best if all three can be included. + * + * 2. Add your chart entries to browser/devtools/shared/telemetry.js + * (Telemetry.prototype._histograms): + * mytoolname: { + * histogram: "DEVTOOLS_MYTOOLNAME_OPENED_BOOLEAN", + * userHistogram: "DEVTOOLS_MYTOOLNAME_OPENED_PER_USER_FLAG", + * timerHistogram: "DEVTOOLS_MYTOOLNAME_TIME_ACTIVE_SECONDS" + * }, + * + * 3. Include this module at the top of your tool. Use: + * let Telemetry = require("devtools/shared/telemetry") + * + * 4. Create a telemetry instance in your tool's constructor: + * this._telemetry = new Telemetry(); + * + * 5. When your tool is opened call: + * this._telemetry.toolOpened("mytoolname"); + * + * 6. When your tool is closed call: + * this._telemetry.toolClosed("mytoolname"); + * + * Note: + * You can view telemetry stats for your local Firefox instance via + * about:telemetry. + * + * You can view telemetry stats for large groups of Firefox users at + * metrics.mozilla.com. + */ + +const TOOLS_OPENED_PREF = "devtools.telemetry.tools.opened.version"; + +this.Telemetry = function() { + // Bind pretty much all functions so that callers do not need to. + this.toolOpened = this.toolOpened.bind(this); + this.toolClosed = this.toolClosed.bind(this); + this.log = this.log.bind(this); + this.logOncePerBrowserVersion = this.logOncePerBrowserVersion.bind(this); + this.destroy = this.destroy.bind(this); + + this._timers = new Map(); +}; + +module.exports = Telemetry; + +let {Cc, Ci, Cu} = require("chrome"); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); +let {XPCOMUtils} = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); + +Telemetry.prototype = { + _histograms: { + toolbox: { + timerHistogram: "DEVTOOLS_TOOLBOX_TIME_ACTIVE_SECONDS" + }, + options: { + histogram: "DEVTOOLS_OPTIONS_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_OPTIONS_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_OPTIONS_TIME_ACTIVE_SECONDS" + }, + webconsole: { + histogram: "DEVTOOLS_WEBCONSOLE_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_WEBCONSOLE_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_WEBCONSOLE_TIME_ACTIVE_SECONDS" + }, + browserconsole: { + histogram: "DEVTOOLS_BROWSERCONSOLE_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_BROWSERCONSOLE_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_BROWSERCONSOLE_TIME_ACTIVE_SECONDS" + }, + inspector: { + histogram: "DEVTOOLS_INSPECTOR_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_INSPECTOR_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_INSPECTOR_TIME_ACTIVE_SECONDS" + }, + ruleview: { + histogram: "DEVTOOLS_RULEVIEW_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_RULEVIEW_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_RULEVIEW_TIME_ACTIVE_SECONDS" + }, + computedview: { + histogram: "DEVTOOLS_COMPUTEDVIEW_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_COMPUTEDVIEW_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_COMPUTEDVIEW_TIME_ACTIVE_SECONDS" + }, + layoutview: { + histogram: "DEVTOOLS_LAYOUTVIEW_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_LAYOUTVIEW_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_LAYOUTVIEW_TIME_ACTIVE_SECONDS" + }, + fontinspector: { + histogram: "DEVTOOLS_FONTINSPECTOR_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_FONTINSPECTOR_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_FONTINSPECTOR_TIME_ACTIVE_SECONDS" + }, + jsdebugger: { + histogram: "DEVTOOLS_JSDEBUGGER_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_JSDEBUGGER_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_JSDEBUGGER_TIME_ACTIVE_SECONDS" + }, + jsbrowserdebugger: { + histogram: "DEVTOOLS_JSBROWSERDEBUGGER_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_JSBROWSERDEBUGGER_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_JSBROWSERDEBUGGER_TIME_ACTIVE_SECONDS" + }, + styleeditor: { + histogram: "DEVTOOLS_STYLEEDITOR_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_STYLEEDITOR_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_STYLEEDITOR_TIME_ACTIVE_SECONDS" + }, + jsprofiler: { + histogram: "DEVTOOLS_JSPROFILER_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_JSPROFILER_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_JSPROFILER_TIME_ACTIVE_SECONDS" + }, + netmonitor: { + histogram: "DEVTOOLS_NETMONITOR_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_NETMONITOR_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_NETMONITOR_TIME_ACTIVE_SECONDS" + }, + tilt: { + histogram: "DEVTOOLS_TILT_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_TILT_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_TILT_TIME_ACTIVE_SECONDS" + }, + paintflashing: { + histogram: "DEVTOOLS_PAINTFLASHING_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_PAINTFLASHING_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_PAINTFLASHING_TIME_ACTIVE_SECONDS" + }, + scratchpad: { + histogram: "DEVTOOLS_SCRATCHPAD_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_SCRATCHPAD_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_SCRATCHPAD_TIME_ACTIVE_SECONDS" + }, + responsive: { + histogram: "DEVTOOLS_RESPONSIVE_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_RESPONSIVE_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_RESPONSIVE_TIME_ACTIVE_SECONDS" + }, + developertoolbar: { + histogram: "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_BOOLEAN", + userHistogram: "DEVTOOLS_DEVELOPERTOOLBAR_OPENED_PER_USER_FLAG", + timerHistogram: "DEVTOOLS_DEVELOPERTOOLBAR_TIME_ACTIVE_SECONDS" + } + }, + + /** + * Add an entry to a histogram. + * + * @param {String} id + * Used to look up the relevant histogram ID and log true to that + * histogram. + */ + toolOpened: function(id) { + let charts = this._histograms[id]; + + if (!charts) { + dump('Warning: An attempt was made to open a tool with an id of "' + id + + '", which is not listed in Telemetry._histograms. ' + + "Location: telemetry.js/toolOpened()\n"); + return; + } + + if (charts.histogram) { + this.log(charts.histogram, true); + } + if (charts.userHistogram) { + this.logOncePerBrowserVersion(charts.userHistogram, true); + } + if (charts.timerHistogram) { + this._timers.set(charts.timerHistogram, new Date()); + } + }, + + toolClosed: function(id) { + let charts = this._histograms[id]; + + if (!charts || !charts.timerHistogram) { + return; + } + + let startTime = this._timers.get(charts.timerHistogram); + + if (startTime) { + let time = (new Date() - startTime) / 1000; + this.log(charts.timerHistogram, time); + this._timers.delete(charts.timerHistogram); + } + }, + + /** + * Log a value to a histogram. + * + * @param {String} histogramId + * Histogram in which the data is to be stored. + * @param value + * Value to store. + */ + log: function(histogramId, value) { + if (histogramId) { + let histogram; + + try { + let histogram = Services.telemetry.getHistogramById(histogramId); + histogram.add(value); + } catch(e) { + dump("Warning: An attempt was made to write to the " + histogramId + + " histogram, which is not defined in Histograms.json\n"); + } + } + }, + + /** + * Log info about usage once per browser version. This allows us to discover + * how many individual users are using our tools for each browser version. + * + * @param {String} perUserHistogram + * Histogram in which the data is to be stored. + */ + logOncePerBrowserVersion: function(perUserHistogram, value) { + let currentVersion = appInfo.version; + let latest = Services.prefs.getCharPref(TOOLS_OPENED_PREF); + let latestObj = JSON.parse(latest); + + let lastVersionHistogramUpdated = latestObj[perUserHistogram]; + + if (typeof lastVersionHistogramUpdated == "undefined" || + lastVersionHistogramUpdated !== currentVersion) { + latestObj[perUserHistogram] = currentVersion; + latest = JSON.stringify(latestObj); + Services.prefs.setCharPref(TOOLS_OPENED_PREF, latest); + this.log(perUserHistogram, value); + } + }, + + destroy: function() { + for (let [histogram, time] of this._timers) { + time = (new Date() - time) / 1000; + + this.log(histogram, time); + this._timers.delete(histogram); + } + } +}; + +XPCOMUtils.defineLazyGetter(this, "appInfo", function() { + return Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); +}); diff --git a/browser/devtools/shared/test/Makefile.in b/browser/devtools/shared/test/Makefile.in new file mode 100644 index 000000000..088cfa596 --- /dev/null +++ b/browser/devtools/shared/test/Makefile.in @@ -0,0 +1,42 @@ +# +# 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/. + +DEPTH = @DEPTH@ +topsrcdir = @top_srcdir@ +srcdir = @srcdir@ +VPATH = @srcdir@ +relativesrcdir = @relativesrcdir@ + +include $(DEPTH)/config/autoconf.mk + +MOCHITEST_BROWSER_FILES = \ + browser_eventemitter_basic.js \ + browser_layoutHelpers.js \ + browser_require_basic.js \ + browser_telemetry_buttonsandsidebar.js \ + browser_telemetry_toolboxtabs_inspector.js \ + browser_telemetry_toolboxtabs_jsdebugger.js \ + browser_telemetry_toolboxtabs_jsprofiler.js \ + browser_telemetry_toolboxtabs_netmonitor.js \ + browser_telemetry_toolboxtabs_options.js \ + browser_telemetry_toolboxtabs_styleeditor.js \ + browser_telemetry_toolboxtabs_webconsole.js \ + browser_templater_basic.js \ + browser_toolbar_basic.js \ + browser_toolbar_tooltip.js \ + browser_toolbar_webconsole_errors_count.js \ + head.js \ + leakhunt.js \ + $(NULL) + +MOCHITEST_BROWSER_FILES += \ + browser_templater_basic.html \ + browser_toolbar_basic.html \ + browser_toolbar_webconsole_errors_count.html \ + browser_layoutHelpers.html \ + browser_layoutHelpers_iframe.html \ + $(NULL) + +include $(topsrcdir)/config/rules.mk diff --git a/browser/devtools/shared/test/browser_eventemitter_basic.js b/browser/devtools/shared/test/browser_eventemitter_basic.js new file mode 100644 index 000000000..7e9cccae3 --- /dev/null +++ b/browser/devtools/shared/test/browser_eventemitter_basic.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + testEmitter(); + testEmitter({}); +} + + +function testEmitter(aObject) { + Cu.import("resource:///modules/devtools/shared/event-emitter.js", this); + + let emitter; + + if (aObject) { + emitter = aObject; + EventEmitter.decorate(emitter); + } else { + emitter = new EventEmitter(); + } + + ok(emitter, "We have an event emitter"); + + emitter.on("next", next); + emitter.emit("next", "abc", "def"); + + let beenHere1 = false; + function next(eventName, str1, str2) { + is(eventName, "next", "Got event"); + is(str1, "abc", "Argument 1 is correct"); + is(str2, "def", "Argument 2 is correct"); + + ok(!beenHere1, "first time in next callback"); + beenHere1 = true; + + emitter.off("next", next); + + emitter.emit("next"); + + emitter.once("onlyonce", onlyOnce); + + emitter.emit("onlyonce"); + emitter.emit("onlyonce"); + } + + let beenHere2 = false; + function onlyOnce() { + ok(!beenHere2, "\"once\" listner has been called once"); + beenHere2 = true; + emitter.emit("onlyonce"); + + killItWhileEmitting(); + } + + function killItWhileEmitting() { + function c1() { + ok(true, "c1 called"); + } + function c2() { + ok(true, "c2 called"); + emitter.off("tick", c3); + } + function c3() { + ok(false, "c3 should not be called"); + } + function c4() { + ok(true, "c4 called"); + } + + emitter.on("tick", c1); + emitter.on("tick", c2); + emitter.on("tick", c3); + emitter.on("tick", c4); + + emitter.emit("tick"); + + delete emitter; + finish(); + } +} diff --git a/browser/devtools/shared/test/browser_layoutHelpers.html b/browser/devtools/shared/test/browser_layoutHelpers.html new file mode 100644 index 000000000..3b9a285b4 --- /dev/null +++ b/browser/devtools/shared/test/browser_layoutHelpers.html @@ -0,0 +1,25 @@ +<!doctype html> +<meta charset=utf-8> +<title> Layout Helpers </title> + +<style> + html { + height: 300%; + width: 300%; + } + div#some { + position: absolute; + background: black; + width: 2px; + height: 2px; + } + iframe { + position: absolute; + width: 40px; + height: 40px; + border: 0; + } +</style> + +<div id=some></div> +<iframe id=frame src='./browser_layoutHelpers_iframe.html'></iframe> diff --git a/browser/devtools/shared/test/browser_layoutHelpers.js b/browser/devtools/shared/test/browser_layoutHelpers.js new file mode 100644 index 000000000..9747399a4 --- /dev/null +++ b/browser/devtools/shared/test/browser_layoutHelpers.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that scrollIntoViewIfNeeded works properly. + +let imported = {}; +Components.utils.import("resource:///modules/devtools/LayoutHelpers.jsm", + imported); +registerCleanupFunction(function () { + imported = {}; +}); + +let LayoutHelpers = imported.LayoutHelpers; + +const TEST_URI = "http://example.com/browser/browser/devtools/shared/test/browser_layoutHelpers.html"; + +function test() { + addTab(TEST_URI, function(browser, tab) { + info("Starting browser_layoutHelpers.js"); + let doc = browser.contentDocument; + runTest(doc.defaultView, doc.getElementById('some')); + gBrowser.removeCurrentTab(); + finish(); + }); +} + +function runTest(win, some) { + some.style.top = win.innerHeight + 'px'; + some.style.left = win.innerWidth + 'px'; + // The tests start with a black 2x2 pixels square below bottom right. + // Do not resize the window during the tests. + + win.scroll(win.innerWidth / 2, win.innerHeight + 2); // Above the viewport. + LayoutHelpers.scrollIntoViewIfNeeded(some); + is(win.scrollY, Math.floor(win.innerHeight / 2) + 1, + 'Element completely hidden above should appear centered.'); + + win.scroll(win.innerWidth / 2, win.innerHeight + 1); // On the top edge. + LayoutHelpers.scrollIntoViewIfNeeded(some); + is(win.scrollY, win.innerHeight, + 'Element partially visible above should appear above.'); + + win.scroll(win.innerWidth / 2, 0); // Just below the viewport. + LayoutHelpers.scrollIntoViewIfNeeded(some); + is(win.scrollY, Math.floor(win.innerHeight / 2) + 1, + 'Element completely hidden below should appear centered.'); + + win.scroll(win.innerWidth / 2, 1); // On the bottom edge. + LayoutHelpers.scrollIntoViewIfNeeded(some); + is(win.scrollY, 2, + 'Element partially visible below should appear below.'); + + + win.scroll(win.innerWidth / 2, win.innerHeight + 2); // Above the viewport. + LayoutHelpers.scrollIntoViewIfNeeded(some, false); + is(win.scrollY, win.innerHeight, + 'Element completely hidden above should appear above ' + + 'if parameter is false.'); + + win.scroll(win.innerWidth / 2, win.innerHeight + 1); // On the top edge. + LayoutHelpers.scrollIntoViewIfNeeded(some, false); + is(win.scrollY, win.innerHeight, + 'Element partially visible above should appear above ' + + 'if parameter is false.'); + + win.scroll(win.innerWidth / 2, 0); // Below the viewport. + LayoutHelpers.scrollIntoViewIfNeeded(some, false); + is(win.scrollY, 2, + 'Element completely hidden below should appear below ' + + 'if parameter is false.'); + + win.scroll(win.innerWidth / 2, 1); // On the bottom edge. + LayoutHelpers.scrollIntoViewIfNeeded(some, false); + is(win.scrollY, 2, + 'Element partially visible below should appear below ' + + 'if parameter is false.'); + + // The case of iframes. + win.scroll(0, 0); + + let frame = win.document.getElementById('frame'); + let fwin = frame.contentWindow; + + frame.style.top = win.innerHeight + 'px'; + frame.style.left = win.innerWidth + 'px'; + + fwin.addEventListener('load', function frameLoad() { + let some = fwin.document.getElementById('some'); + LayoutHelpers.scrollIntoViewIfNeeded(some); + is(win.scrollX, Math.floor(win.innerWidth / 2) + 20, + 'Scrolling from an iframe should center the iframe vertically.'); + is(win.scrollY, Math.floor(win.innerHeight / 2) + 20, + 'Scrolling from an iframe should center the iframe horizontally.'); + is(fwin.scrollX, Math.floor(fwin.innerWidth / 2) + 1, + 'Scrolling from an iframe should center the element vertically.'); + is(fwin.scrollY, Math.floor(fwin.innerHeight / 2) + 1, + 'Scrolling from an iframe should center the element horizontally.'); + }, false); +} diff --git a/browser/devtools/shared/test/browser_layoutHelpers_iframe.html b/browser/devtools/shared/test/browser_layoutHelpers_iframe.html new file mode 100644 index 000000000..66ef5b293 --- /dev/null +++ b/browser/devtools/shared/test/browser_layoutHelpers_iframe.html @@ -0,0 +1,19 @@ +<!doctype html> +<meta charset=utf-8> +<title> Layout Helpers </title> + +<style> + html { + height: 300%; + width: 300%; + } + div#some { + position: absolute; + background: black; + width: 2px; + height: 2px; + } +</style> + +<div id=some></div> + diff --git a/browser/devtools/shared/test/browser_require_basic.js b/browser/devtools/shared/test/browser_require_basic.js new file mode 100644 index 000000000..f86974df4 --- /dev/null +++ b/browser/devtools/shared/test/browser_require_basic.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that source URLs in the Web Console can be clicked to display the +// standard View Source window. + +let [ define, require ] = (function() { + let tempScope = {}; + Components.utils.import("resource://gre/modules/devtools/Require.jsm", tempScope); + return [ tempScope.define, tempScope.require ]; +})(); + +function test() { + addTab("about:blank", function() { + info("Starting Require Tests"); + setup(); + + testWorking(); + testDomains(); + testLeakage(); + testMultiImport(); + testRecursive(); + testUncompilable(); + testFirebug(); + + shutdown(); + }); +} + +function setup() { + define('gclitest/requirable', [ 'require', 'exports', 'module' ], function(require, exports, module) { + exports.thing1 = 'thing1'; + exports.thing2 = 2; + + let status = 'initial'; + exports.setStatus = function(aStatus) { status = aStatus; }; + exports.getStatus = function() { return status; }; + }); + + define('gclitest/unrequirable', [ 'require', 'exports', 'module' ], function(require, exports, module) { + null.throwNPE(); + }); + + define('gclitest/recurse', [ 'require', 'exports', 'module', 'gclitest/recurse' ], function(require, exports, module) { + require('gclitest/recurse'); + }); + + define('gclitest/firebug', [ 'gclitest/requirable' ], function(requirable) { + return { requirable: requirable, fb: true }; + }); +} + +function shutdown() { + delete define.modules['gclitest/requirable']; + delete define.globalDomain.modules['gclitest/requirable']; + delete define.modules['gclitest/unrequirable']; + delete define.globalDomain.modules['gclitest/unrequirable']; + delete define.modules['gclitest/recurse']; + delete define.globalDomain.modules['gclitest/recurse']; + delete define.modules['gclitest/firebug']; + delete define.globalDomain.modules['gclitest/firebug']; + + define = undefined; + require = undefined; + + finish(); +} + +function testWorking() { + // There are lots of requirement tests that we could be doing here + // The fact that we can get anything at all working is a testament to + // require doing what it should - we don't need to test the + let requireable = require('gclitest/requirable'); + is('thing1', requireable.thing1, 'thing1 was required'); + is(2, requireable.thing2, 'thing2 was required'); + is(requireable.thing3, undefined, 'thing3 was not required'); +} + +function testDomains() { + let requireable = require('gclitest/requirable'); + is(requireable.status, undefined, 'requirable has no status'); + requireable.setStatus(null); + is(null, requireable.getStatus(), 'requirable.getStatus changed to null'); + is(requireable.status, undefined, 'requirable still has no status'); + requireable.setStatus('42'); + is('42', requireable.getStatus(), 'requirable.getStatus changed to 42'); + is(requireable.status, undefined, 'requirable *still* has no status'); + + let domain = new define.Domain(); + let requireable2 = domain.require('gclitest/requirable'); + is(requireable2.status, undefined, 'requirable2 has no status'); + is('initial', requireable2.getStatus(), 'requirable2.getStatus is initial'); + requireable2.setStatus(999); + is(999, requireable2.getStatus(), 'requirable2.getStatus changed to 999'); + is(requireable2.status, undefined, 'requirable2 still has no status'); + + is('42', requireable.getStatus(), 'status 42'); + ok(requireable.status === undefined, 'requirable has no status (as expected)'); + + delete domain.modules['gclitest/requirable']; +} + +function testLeakage() { + let requireable = require('gclitest/requirable'); + is(requireable.setup, null, 'leakage of setup'); + is(requireable.shutdown, null, 'leakage of shutdown'); + is(requireable.testWorking, null, 'leakage of testWorking'); +} + +function testMultiImport() { + let r1 = require('gclitest/requirable'); + let r2 = require('gclitest/requirable'); + is(r1, r2, 'double require was strict equal'); +} + +function testUncompilable() { + // It's not totally clear how a module loader should perform with unusable + // modules, however at least it should go into a flat spin ... + // GCLI mini_require reports an error as it should + try { + let unrequireable = require('gclitest/unrequirable'); + fail(); + } + catch (ex) { + // an exception is expected + } +} + +function testRecursive() { + // See Bug 658583 + // require('gclitest/recurse'); + // Also see the comments in the testRecursive() function +} + +function testFirebug() { + let requirable = require('gclitest/requirable'); + let firebug = require('gclitest/firebug'); + ok(firebug.fb, 'firebug.fb is true'); + is(requirable, firebug.requirable, 'requirable pass-through'); +} diff --git a/browser/devtools/shared/test/browser_telemetry_buttonsandsidebar.js b/browser/devtools/shared/test/browser_telemetry_buttonsandsidebar.js new file mode 100644 index 000000000..8553ed799 --- /dev/null +++ b/browser/devtools/shared/test/browser_telemetry_buttonsandsidebar.js @@ -0,0 +1,179 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_buttonsandsidebar.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +let {Promise} = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); + +let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +let Telemetry = require("devtools/shared/telemetry"); + +function init() { + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function(histogramId, value) { + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + + this.telemetryInfo[histogramId].push(value); + } + } + + testButtons(); +} + +function testButtons() { + info("Testing buttons"); + + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, "inspector").then(function(toolbox) { + let container = toolbox.doc.getElementById("toolbox-buttons"); + let buttons = container.getElementsByTagName("toolbarbutton"); + + // Copy HTMLCollection to array. + buttons = Array.prototype.slice.call(buttons); + + (function testButton() { + let button = buttons.pop(); + + if (button) { + info("Clicking button " + button.id); + button.click(); + delayedClicks(button, 3).then(function(button) { + if (buttons.length == 0) { + // Remove scratchpads + let wins = Services.wm.getEnumerator("devtools:scratchpad"); + while (wins.hasMoreElements()) { + let win = wins.getNext(); + info("Closing scratchpad window"); + win.close(); + } + + testSidebar(); + } else { + setTimeout(testButton, TOOL_DELAY); + } + }); + } + })(); + }).then(null, reportError); +} + +function delayedClicks(node, clicks) { + let deferred = Promise.defer(); + let clicked = 0; + + setTimeout(function delayedClick() { + info("Clicking button " + node.id); + node.click(); + clicked++; + + if (clicked >= clicks) { + deferred.resolve(node); + } else { + setTimeout(delayedClick, TOOL_DELAY); + } + }, TOOL_DELAY); + + return deferred.promise; +} + +function testSidebar() { + info("Testing sidebar"); + + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, "inspector").then(function(toolbox) { + let inspector = toolbox.getCurrentPanel(); + let sidebarTools = ["ruleview", "computedview", "fontinspector", "layoutview"]; + + // Concatenate the array with itself so that we can open each tool twice. + sidebarTools.push.apply(sidebarTools, sidebarTools); + + setTimeout(function selectSidebarTab() { + let tool = sidebarTools.pop(); + if (tool) { + inspector.sidebar.select(tool); + setTimeout(function() { + setTimeout(selectSidebarTab, TOOL_DELAY); + }, TOOL_DELAY); + } else { + checkResults(); + } + }, TOOL_DELAY); + }); +} + +function checkResults() { + let result = Telemetry.prototype.telemetryInfo; + + for (let [histId, value] of Iterator(result)) { + if (histId.startsWith("DEVTOOLS_INSPECTOR_")) { + // Inspector stats are tested in browser_telemetry_toolboxtabs.js so we + // skip them here because we only open the inspector once for this test. + continue; + } + + if (histId.endsWith("OPENED_PER_USER_FLAG")) { + ok(value.length === 1 && value[0] === true, + "Per user value " + histId + " has a single value of true"); + } else if (histId.endsWith("OPENED_BOOLEAN")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element === true; + }); + + ok(okay, "All " + histId + " entries are === true"); + } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element > 0; + }); + + ok(okay, "All " + histId + " entries have time > 0"); + } + } + + finishUp(); +} + +function reportError(error) { + let stack = " " + error.stack.replace(/\n?.*?@/g, "\n JS frame :: "); + + ok(false, "ERROR: " + error + " at " + error.fileName + ":" + + error.lineNumber + "\n\nStack trace:" + stack); + finishUp(); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + + Telemetry.prototype.log = Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype.telemetryInfo; + + TargetFactory = Services = Promise = require = null; + + finish(); +} + +function test() { + waitForExplicitFinish(); + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function() { + gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true); + waitForFocus(init, content); + }, true); + + content.location = TEST_URI; +} diff --git a/browser/devtools/shared/test/browser_telemetry_toolboxtabs_inspector.js b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_inspector.js new file mode 100644 index 000000000..e53c1b829 --- /dev/null +++ b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_inspector.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_inspector.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +let {Promise} = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); + +let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +let Telemetry = require("devtools/shared/telemetry"); + +function init() { + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function(histogramId, value) { + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + + this.telemetryInfo[histogramId].push(value); + } + } + + openToolboxTabTwice("inspector", false); +} + +function openToolboxTabTwice(id, secondPass) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, id).then(function(toolbox) { + info("Toolbox tab " + id + " opened"); + + toolbox.once("destroyed", function() { + if (secondPass) { + checkResults(); + } else { + openToolboxTabTwice(id, true); + } + }); + // We use a timeout to check the tools active time + setTimeout(function() { + gDevTools.closeToolbox(target); + }, TOOL_DELAY); + }).then(null, reportError); +} + +function checkResults() { + let result = Telemetry.prototype.telemetryInfo; + + for (let [histId, value] of Iterator(result)) { + if (histId.endsWith("OPENED_PER_USER_FLAG")) { + ok(value.length === 1 && value[0] === true, + "Per user value " + histId + " has a single value of true"); + } else if (histId.endsWith("OPENED_BOOLEAN")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element === true; + }); + + ok(okay, "All " + histId + " entries are === true"); + } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element > 0; + }); + + ok(okay, "All " + histId + " entries have time > 0"); + } + } + + finishUp(); +} + +function reportError(error) { + let stack = " " + error.stack.replace(/\n?.*?@/g, "\n JS frame :: "); + + ok(false, "ERROR: " + error + " at " + error.fileName + ":" + + error.lineNumber + "\n\nStack trace:" + stack); + finishUp(); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + + Telemetry.prototype.log = Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype.telemetryInfo; + + TargetFactory = Services = Promise = require = null; + + finish(); +} + +function test() { + waitForExplicitFinish(); + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function() { + gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true); + waitForFocus(init, content); + }, true); + + content.location = TEST_URI; +} diff --git a/browser/devtools/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js new file mode 100644 index 000000000..1b1a6234b --- /dev/null +++ b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_jsdebugger.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_jsdebugger.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +let {Promise} = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); + +let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +let Telemetry = require("devtools/shared/telemetry"); + +function init() { + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function(histogramId, value) { + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + + this.telemetryInfo[histogramId].push(value); + } + } + + openToolboxTabTwice("jsdebugger", false); +} + +function openToolboxTabTwice(id, secondPass) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, id).then(function(toolbox) { + info("Toolbox tab " + id + " opened"); + + toolbox.once("destroyed", function() { + if (secondPass) { + checkResults(); + } else { + openToolboxTabTwice(id, true); + } + }); + // We use a timeout to check the tools active time + setTimeout(function() { + gDevTools.closeToolbox(target); + }, TOOL_DELAY); + }).then(null, reportError); +} + +function checkResults() { + let result = Telemetry.prototype.telemetryInfo; + + for (let [histId, value] of Iterator(result)) { + if (histId.endsWith("OPENED_PER_USER_FLAG")) { + ok(value.length === 1 && value[0] === true, + "Per user value " + histId + " has a single value of true"); + } else if (histId.endsWith("OPENED_BOOLEAN")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element === true; + }); + + ok(okay, "All " + histId + " entries are === true"); + } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element > 0; + }); + + ok(okay, "All " + histId + " entries have time > 0"); + } + } + + finishUp(); +} + +function reportError(error) { + let stack = " " + error.stack.replace(/\n?.*?@/g, "\n JS frame :: "); + + ok(false, "ERROR: " + error + " at " + error.fileName + ":" + + error.lineNumber + "\n\nStack trace:" + stack); + finishUp(); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + + Telemetry.prototype.log = Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype.telemetryInfo; + + TargetFactory = Services = Promise = require = null; + + finish(); +} + +function test() { + waitForExplicitFinish(); + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function() { + gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true); + waitForFocus(init, content); + }, true); + + content.location = TEST_URI; +} diff --git a/browser/devtools/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js new file mode 100644 index 000000000..567219222 --- /dev/null +++ b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_jsprofiler.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_jsprofiler.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +let {Promise} = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); + +let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +let Telemetry = require("devtools/shared/telemetry"); + +function init() { + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function(histogramId, value) { + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + + this.telemetryInfo[histogramId].push(value); + } + } + + openToolboxTabTwice("jsprofiler", false); +} + +function openToolboxTabTwice(id, secondPass) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, id).then(function(toolbox) { + info("Toolbox tab " + id + " opened"); + + toolbox.once("destroyed", function() { + if (secondPass) { + checkResults(); + } else { + openToolboxTabTwice(id, true); + } + }); + // We use a timeout to check the tools active time + setTimeout(function() { + gDevTools.closeToolbox(target); + }, TOOL_DELAY); + }).then(null, reportError); +} + +function checkResults() { + let result = Telemetry.prototype.telemetryInfo; + + for (let [histId, value] of Iterator(result)) { + if (histId.endsWith("OPENED_PER_USER_FLAG")) { + ok(value.length === 1 && value[0] === true, + "Per user value " + histId + " has a single value of true"); + } else if (histId.endsWith("OPENED_BOOLEAN")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element === true; + }); + + ok(okay, "All " + histId + " entries are === true"); + } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element > 0; + }); + + ok(okay, "All " + histId + " entries have time > 0"); + } + } + + finishUp(); +} + +function reportError(error) { + let stack = " " + error.stack.replace(/\n?.*?@/g, "\n JS frame :: "); + + ok(false, "ERROR: " + error + " at " + error.fileName + ":" + + error.lineNumber + "\n\nStack trace:" + stack); + finishUp(); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + + Telemetry.prototype.log = Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype.telemetryInfo; + + TargetFactory = Services = Promise = require = null; + + finish(); +} + +function test() { + waitForExplicitFinish(); + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function() { + gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true); + waitForFocus(init, content); + }, true); + + content.location = TEST_URI; +} diff --git a/browser/devtools/shared/test/browser_telemetry_toolboxtabs_netmonitor.js b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_netmonitor.js new file mode 100644 index 000000000..bc139acfb --- /dev/null +++ b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_netmonitor.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_netmonitor.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +let {Promise} = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); + +let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +let Telemetry = require("devtools/shared/telemetry"); + +function init() { + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function(histogramId, value) { + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + + this.telemetryInfo[histogramId].push(value); + } + } + + openToolboxTabTwice("netmonitor", false); +} + +function openToolboxTabTwice(id, secondPass) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, id).then(function(toolbox) { + info("Toolbox tab " + id + " opened"); + + toolbox.once("destroyed", function() { + if (secondPass) { + checkResults(); + } else { + openToolboxTabTwice(id, true); + } + }); + // We use a timeout to check the tools active time + setTimeout(function() { + gDevTools.closeToolbox(target); + }, TOOL_DELAY); + }).then(null, reportError); +} + +function checkResults() { + let result = Telemetry.prototype.telemetryInfo; + + for (let [histId, value] of Iterator(result)) { + if (histId.endsWith("OPENED_PER_USER_FLAG")) { + ok(value.length === 1 && value[0] === true, + "Per user value " + histId + " has a single value of true"); + } else if (histId.endsWith("OPENED_BOOLEAN")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element === true; + }); + + ok(okay, "All " + histId + " entries are === true"); + } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element > 0; + }); + + ok(okay, "All " + histId + " entries have time > 0"); + } + } + + finishUp(); +} + +function reportError(error) { + let stack = " " + error.stack.replace(/\n?.*?@/g, "\n JS frame :: "); + + ok(false, "ERROR: " + error + " at " + error.fileName + ":" + + error.lineNumber + "\n\nStack trace:" + stack); + finishUp(); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + + Telemetry.prototype.log = Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype.telemetryInfo; + + TargetFactory = Services = Promise = require = null; + + finish(); +} + +function test() { + waitForExplicitFinish(); + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function() { + gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true); + waitForFocus(init, content); + }, true); + + content.location = TEST_URI; +} diff --git a/browser/devtools/shared/test/browser_telemetry_toolboxtabs_options.js b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_options.js new file mode 100644 index 000000000..687a07c38 --- /dev/null +++ b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_options.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_options.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +let {Promise} = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); + +let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +let Telemetry = require("devtools/shared/telemetry"); + +function init() { + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function(histogramId, value) { + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + + this.telemetryInfo[histogramId].push(value); + } + } + + openToolboxTabTwice("options", false); +} + +function openToolboxTabTwice(id, secondPass) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, id).then(function(toolbox) { + info("Toolbox tab " + id + " opened"); + + toolbox.once("destroyed", function() { + if (secondPass) { + checkResults(); + } else { + openToolboxTabTwice(id, true); + } + }); + // We use a timeout to check the tools active time + setTimeout(function() { + gDevTools.closeToolbox(target); + }, TOOL_DELAY); + }).then(null, reportError); +} + +function checkResults() { + let result = Telemetry.prototype.telemetryInfo; + + for (let [histId, value] of Iterator(result)) { + if (histId.endsWith("OPENED_PER_USER_FLAG")) { + ok(value.length === 1 && value[0] === true, + "Per user value " + histId + " has a single value of true"); + } else if (histId.endsWith("OPENED_BOOLEAN")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element === true; + }); + + ok(okay, "All " + histId + " entries are === true"); + } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element > 0; + }); + + ok(okay, "All " + histId + " entries have time > 0"); + } + } + + finishUp(); +} + +function reportError(error) { + let stack = " " + error.stack.replace(/\n?.*?@/g, "\n JS frame :: "); + + ok(false, "ERROR: " + error + " at " + error.fileName + ":" + + error.lineNumber + "\n\nStack trace:" + stack); + finishUp(); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + + Telemetry.prototype.log = Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype.telemetryInfo; + + TargetFactory = Services = Promise = require = null; + + finish(); +} + +function test() { + waitForExplicitFinish(); + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function() { + gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true); + waitForFocus(init, content); + }, true); + + content.location = TEST_URI; +} diff --git a/browser/devtools/shared/test/browser_telemetry_toolboxtabs_styleeditor.js b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_styleeditor.js new file mode 100644 index 000000000..9e30c74fa --- /dev/null +++ b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_styleeditor.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_styleeditor.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +let {Promise} = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); + +let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +let Telemetry = require("devtools/shared/telemetry"); + +function init() { + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function(histogramId, value) { + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + + this.telemetryInfo[histogramId].push(value); + } + } + + openToolboxTabTwice("styleeditor", false); +} + +function openToolboxTabTwice(id, secondPass) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, id).then(function(toolbox) { + info("Toolbox tab " + id + " opened"); + + toolbox.once("destroyed", function() { + if (secondPass) { + checkResults(); + } else { + openToolboxTabTwice(id, true); + } + }); + // We use a timeout to check the tools active time + setTimeout(function() { + gDevTools.closeToolbox(target); + }, TOOL_DELAY); + }).then(null, reportError); +} + +function checkResults() { + let result = Telemetry.prototype.telemetryInfo; + + for (let [histId, value] of Iterator(result)) { + if (histId.endsWith("OPENED_PER_USER_FLAG")) { + ok(value.length === 1 && value[0] === true, + "Per user value " + histId + " has a single value of true"); + } else if (histId.endsWith("OPENED_BOOLEAN")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element === true; + }); + + ok(okay, "All " + histId + " entries are === true"); + } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element > 0; + }); + + ok(okay, "All " + histId + " entries have time > 0"); + } + } + + finishUp(); +} + +function reportError(error) { + let stack = " " + error.stack.replace(/\n?.*?@/g, "\n JS frame :: "); + + ok(false, "ERROR: " + error + " at " + error.fileName + ":" + + error.lineNumber + "\n\nStack trace:" + stack); + finishUp(); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + + Telemetry.prototype.log = Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype.telemetryInfo; + + TargetFactory = Services = Promise = require = null; + + finish(); +} + +function test() { + waitForExplicitFinish(); + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function() { + gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true); + waitForFocus(init, content); + }, true); + + content.location = TEST_URI; +} diff --git a/browser/devtools/shared/test/browser_telemetry_toolboxtabs_webconsole.js b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_webconsole.js new file mode 100644 index 000000000..ba027e7fd --- /dev/null +++ b/browser/devtools/shared/test/browser_telemetry_toolboxtabs_webconsole.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URI = "data:text/html;charset=utf-8,<p>browser_telemetry_toolboxtabs_styleeditor_webconsole.js</p>"; + +// Because we need to gather stats for the period of time that a tool has been +// opened we make use of setTimeout() to create tool active times. +const TOOL_DELAY = 200; + +let {Promise} = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}); +let {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); + +let require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +let Telemetry = require("devtools/shared/telemetry"); + +function init() { + Telemetry.prototype.telemetryInfo = {}; + Telemetry.prototype._oldlog = Telemetry.prototype.log; + Telemetry.prototype.log = function(histogramId, value) { + if (histogramId) { + if (!this.telemetryInfo[histogramId]) { + this.telemetryInfo[histogramId] = []; + } + + this.telemetryInfo[histogramId].push(value); + } + } + + openToolboxTabTwice("webconsole", false); +} + +function openToolboxTabTwice(id, secondPass) { + let target = TargetFactory.forTab(gBrowser.selectedTab); + + gDevTools.showToolbox(target, id).then(function(toolbox) { + info("Toolbox tab " + id + " opened"); + + toolbox.once("destroyed", function() { + if (secondPass) { + checkResults(); + } else { + openToolboxTabTwice(id, true); + } + }); + // We use a timeout to check the tools active time + setTimeout(function() { + gDevTools.closeToolbox(target); + }, TOOL_DELAY); + }).then(null, reportError); +} + +function checkResults() { + let result = Telemetry.prototype.telemetryInfo; + + for (let [histId, value] of Iterator(result)) { + if (histId.endsWith("OPENED_PER_USER_FLAG")) { + ok(value.length === 1 && value[0] === true, + "Per user value " + histId + " has a single value of true"); + } else if (histId.endsWith("OPENED_BOOLEAN")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element === true; + }); + + ok(okay, "All " + histId + " entries are === true"); + } else if (histId.endsWith("TIME_ACTIVE_SECONDS")) { + ok(value.length > 1, histId + " has more than one entry"); + + let okay = value.every(function(element) { + return element > 0; + }); + + ok(okay, "All " + histId + " entries have time > 0"); + } + } + + finishUp(); +} + +function reportError(error) { + let stack = " " + error.stack.replace(/\n?.*?@/g, "\n JS frame :: "); + + ok(false, "ERROR: " + error + " at " + error.fileName + ":" + + error.lineNumber + "\n\nStack trace:" + stack); + finishUp(); +} + +function finishUp() { + gBrowser.removeCurrentTab(); + + Telemetry.prototype.log = Telemetry.prototype._oldlog; + delete Telemetry.prototype._oldlog; + delete Telemetry.prototype.telemetryInfo; + + TargetFactory = Services = Promise = require = null; + + finish(); +} + +function test() { + waitForExplicitFinish(); + gBrowser.selectedTab = gBrowser.addTab(); + gBrowser.selectedBrowser.addEventListener("load", function() { + gBrowser.selectedBrowser.removeEventListener("load", arguments.callee, true); + waitForFocus(init, content); + }, true); + + content.location = TEST_URI; +} diff --git a/browser/devtools/shared/test/browser_templater_basic.html b/browser/devtools/shared/test/browser_templater_basic.html new file mode 100644 index 000000000..473c731f3 --- /dev/null +++ b/browser/devtools/shared/test/browser_templater_basic.html @@ -0,0 +1,13 @@ +<!doctype html> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + +<html> +<head> + <title>DOM Template Tests</title> +</head> +<body> + +</body> +</html> + diff --git a/browser/devtools/shared/test/browser_templater_basic.js b/browser/devtools/shared/test/browser_templater_basic.js new file mode 100644 index 000000000..ce8cb130e --- /dev/null +++ b/browser/devtools/shared/test/browser_templater_basic.js @@ -0,0 +1,288 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the DOM Template engine works properly + +/* + * These tests run both in Mozilla/Mochitest and plain browsers (as does + * domtemplate) + * We should endevour to keep the source in sync. + */ + +var Promise = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {}).Promise; +var template = Cu.import("resource://gre/modules/devtools/Templater.jsm", {}).template; + +const TEST_URI = "http://example.com/browser/browser/devtools/shared/test/browser_templater_basic.html"; + +function test() { + addTab(TEST_URI, function() { + info("Starting DOM Templater Tests"); + runTest(0); + }); +} + +function runTest(index) { + var options = tests[index] = tests[index](); + var holder = content.document.createElement('div'); + holder.id = options.name; + var body = content.document.body; + body.appendChild(holder); + holder.innerHTML = options.template; + + info('Running ' + options.name); + template(holder, options.data, options.options); + + if (typeof options.result == 'string') { + is(holder.innerHTML, options.result, options.name); + } + else { + ok(holder.innerHTML.match(options.result) != null, + options.name + ' result=\'' + holder.innerHTML + '\''); + } + + if (options.also) { + options.also(options); + } + + function runNextTest() { + index++; + if (index < tests.length) { + runTest(index); + } + else { + finished(); + } + } + + if (options.later) { + var ais = is.bind(this); + + function createTester(holder, options) { + return function() { + ais(holder.innerHTML, options.later, options.name + ' later'); + runNextTest(); + }.bind(this); + } + + executeSoon(createTester(holder, options)); + } + else { + runNextTest(); + } +} + +function finished() { + gBrowser.removeCurrentTab(); + info("Finishing DOM Templater Tests"); + tests = null; + finish(); +} + +/** + * Why have an array of functions that return data rather than just an array + * of the data itself? Some of these tests contain calls to delayReply() which + * sets up async processing using executeSoon(). Since the execution of these + * tests is asynchronous, the delayed reply will probably arrive before the + * test is executed, making the test be synchronous. So we wrap the data in a + * function so we only set it up just before we use it. + */ +var tests = [ + function() { return { + name: 'simpleNesting', + template: '<div id="ex1">${nested.value}</div>', + data: { nested:{ value:'pass 1' } }, + result: '<div id="ex1">pass 1</div>' + };}, + + function() { return { + name: 'returnDom', + template: '<div id="ex2">${__element.ownerDocument.createTextNode(\'pass 2\')}</div>', + options: { allowEval: true }, + data: {}, + result: '<div id="ex2">pass 2</div>' + };}, + + function() { return { + name: 'srcChange', + template: '<img _src="${fred}" id="ex3">', + data: { fred:'green.png' }, + result: /<img( id="ex3")? src="green.png"( id="ex3")?>/ + };}, + + function() { return { + name: 'ifTrue', + template: '<p if="${name !== \'jim\'}">hello ${name}</p>', + options: { allowEval: true }, + data: { name: 'fred' }, + result: '<p>hello fred</p>' + };}, + + function() { return { + name: 'ifFalse', + template: '<p if="${name !== \'jim\'}">hello ${name}</p>', + options: { allowEval: true }, + data: { name: 'jim' }, + result: '' + };}, + + function() { return { + name: 'simpleLoop', + template: '<p foreach="index in ${[ 1, 2, 3 ]}">${index}</p>', + options: { allowEval: true }, + data: {}, + result: '<p>1</p><p>2</p><p>3</p>' + };}, + + function() { return { + name: 'loopElement', + template: '<loop foreach="i in ${array}">${i}</loop>', + data: { array: [ 1, 2, 3 ] }, + result: '123' + };}, + + // Bug 692028: DOMTemplate memory leak with asynchronous arrays + // Bug 692031: DOMTemplate async loops do not drop the loop element + function() { return { + name: 'asyncLoopElement', + template: '<loop foreach="i in ${array}">${i}</loop>', + data: { array: delayReply([1, 2, 3]) }, + result: '<span></span>', + later: '123' + };}, + + function() { return { + name: 'saveElement', + template: '<p save="${element}">${name}</p>', + data: { name: 'pass 8' }, + result: '<p>pass 8</p>', + also: function(options) { + ok(options.data.element.innerHTML, 'pass 9', 'saveElement saved'); + delete options.data.element; + } + };}, + + function() { return { + name: 'useElement', + template: '<p id="pass9">${adjust(__element)}</p>', + options: { allowEval: true }, + data: { + adjust: function(element) { + is('pass9', element.id, 'useElement adjust'); + return 'pass 9b' + } + }, + result: '<p id="pass9">pass 9b</p>' + };}, + + function() { return { + name: 'asyncInline', + template: '${delayed}', + data: { delayed: delayReply('inline') }, + result: '<span></span>', + later: 'inline' + };}, + + // Bug 692028: DOMTemplate memory leak with asynchronous arrays + function() { return { + name: 'asyncArray', + template: '<p foreach="i in ${delayed}">${i}</p>', + data: { delayed: delayReply([1, 2, 3]) }, + result: '<span></span>', + later: '<p>1</p><p>2</p><p>3</p>' + };}, + + function() { return { + name: 'asyncMember', + template: '<p foreach="i in ${delayed}">${i}</p>', + data: { delayed: [delayReply(4), delayReply(5), delayReply(6)] }, + result: '<span></span><span></span><span></span>', + later: '<p>4</p><p>5</p><p>6</p>' + };}, + + // Bug 692028: DOMTemplate memory leak with asynchronous arrays + function() { return { + name: 'asyncBoth', + template: '<p foreach="i in ${delayed}">${i}</p>', + data: { + delayed: delayReply([ + delayReply(4), + delayReply(5), + delayReply(6) + ]) + }, + result: '<span></span>', + later: '<p>4</p><p>5</p><p>6</p>' + };}, + + // Bug 701762: DOMTemplate fails when ${foo()} returns undefined + function() { return { + name: 'functionReturningUndefiend', + template: '<p>${foo()}</p>', + options: { allowEval: true }, + data: { + foo: function() {} + }, + result: '<p>undefined</p>' + };}, + + // Bug 702642: DOMTemplate is relatively slow when evaluating JS ${} + function() { return { + name: 'propertySimple', + template: '<p>${a.b.c}</p>', + data: { a: { b: { c: 'hello' } } }, + result: '<p>hello</p>' + };}, + + function() { return { + name: 'propertyPass', + template: '<p>${Math.max(1, 2)}</p>', + options: { allowEval: true }, + result: '<p>2</p>' + };}, + + function() { return { + name: 'propertyFail', + template: '<p>${Math.max(1, 2)}</p>', + result: '<p>${Math.max(1, 2)}</p>' + };}, + + // Bug 723431: DOMTemplate should allow customisation of display of + // null/undefined values + function() { return { + name: 'propertyUndefAttrFull', + template: '<p>${nullvar}|${undefinedvar1}|${undefinedvar2}</p>', + data: { nullvar: null, undefinedvar1: undefined }, + result: '<p>null|undefined|undefined</p>' + };}, + + function() { return { + name: 'propertyUndefAttrBlank', + template: '<p>${nullvar}|${undefinedvar1}|${undefinedvar2}</p>', + data: { nullvar: null, undefinedvar1: undefined }, + options: { blankNullUndefined: true }, + result: '<p>||</p>' + };}, + + function() { return { + name: 'propertyUndefAttrFull', + template: '<div><p value="${nullvar}"></p><p value="${undefinedvar1}"></p><p value="${undefinedvar2}"></p></div>', + data: { nullvar: null, undefinedvar1: undefined }, + result: '<div><p value="null"></p><p value="undefined"></p><p value="undefined"></p></div>' + };}, + + function() { return { + name: 'propertyUndefAttrBlank', + template: '<div><p value="${nullvar}"></p><p value="${undefinedvar1}"></p><p value="${undefinedvar2}"></p></div>', + data: { nullvar: null, undefinedvar1: undefined }, + options: { blankNullUndefined: true }, + result: '<div><p value=""></p><p value=""></p><p value=""></p></div>' + };} +]; + +function delayReply(data) { + var d = Promise.defer(); + executeSoon(function() { + d.resolve(data); + }); + return d.promise; +} diff --git a/browser/devtools/shared/test/browser_toolbar_basic.html b/browser/devtools/shared/test/browser_toolbar_basic.html new file mode 100644 index 000000000..7ec012b0e --- /dev/null +++ b/browser/devtools/shared/test/browser_toolbar_basic.html @@ -0,0 +1,35 @@ +<!doctype html> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + +<html> +<head> + <meta charset="UTF-8"> + <title>Developer Toolbar Tests</title> + <style type="text/css"> + #single { color: red; } + </style> + <script type="text/javascript">var a=1;</script> +</head> +<body> + +<p id=single> +1 +</p> + +<p class=twin> +2a +</p> + +<p class=twin> +2b +</p> + +<style> +.twin { color: blue; } +</style> +<script>var b=2;</script> + +</body> +</html> + diff --git a/browser/devtools/shared/test/browser_toolbar_basic.js b/browser/devtools/shared/test/browser_toolbar_basic.js new file mode 100644 index 000000000..8819effc1 --- /dev/null +++ b/browser/devtools/shared/test/browser_toolbar_basic.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the developer toolbar works properly + +const TEST_URI = "http://example.com/browser/browser/devtools/shared/test/browser_toolbar_basic.html"; + +function test() { + addTab(TEST_URI, function(browser, tab) { + info("Starting browser_toolbar_basic.js"); + runTest(); + }); +} + +function runTest() { + ok(!DeveloperToolbar.visible, "DeveloperToolbar is not visible in runTest"); + + oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.SHOW, catchFail(checkOpen)); + document.getElementById("Tools:DevToolbar").doCommand(); +} + +function isChecked(b) { + return b.getAttribute("checked") == "true"; +} + +function checkOpen() { + ok(DeveloperToolbar.visible, "DeveloperToolbar is visible in checkOpen"); + let close = document.getElementById("developer-toolbar-closebutton"); + ok(close, "Close button exists"); + + let toggleToolbox = + document.getElementById("devtoolsMenuBroadcaster_DevToolbox"); + ok(!isChecked(toggleToolbox), "toggle toolbox button is not checked"); + + let target = TargetFactory.forTab(gBrowser.selectedTab); + gDevTools.showToolbox(target, "inspector").then(function(toolbox) { + ok(isChecked(toggleToolbox), "toggle toolbox button is checked"); + + addTab("about:blank", function(browser, tab) { + info("Opened a new tab"); + + ok(!isChecked(toggleToolbox), "toggle toolbox button is not checked"); + + gBrowser.removeCurrentTab(); + + oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.HIDE, catchFail(checkClosed)); + document.getElementById("Tools:DevToolbar").doCommand(); + }); + }); +} + +function checkClosed() { + ok(!DeveloperToolbar.visible, "DeveloperToolbar is not visible in checkClosed"); + + oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.SHOW, catchFail(checkReOpen)); + document.getElementById("Tools:DevToolbar").doCommand(); +} + +function checkReOpen() { + ok(DeveloperToolbar.visible, "DeveloperToolbar is visible in checkReOpen"); + + let toggleToolbox = + document.getElementById("devtoolsMenuBroadcaster_DevToolbox"); + ok(isChecked(toggleToolbox), "toggle toolbox button is checked"); + + oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.HIDE, catchFail(checkReClosed)); + document.getElementById("developer-toolbar-closebutton").doCommand(); +} + +function checkReClosed() { + ok(!DeveloperToolbar.visible, "DeveloperToolbar is not visible in checkReClosed"); + + finish(); +} diff --git a/browser/devtools/shared/test/browser_toolbar_tooltip.js b/browser/devtools/shared/test/browser_toolbar_tooltip.js new file mode 100644 index 000000000..fa86b2fcb --- /dev/null +++ b/browser/devtools/shared/test/browser_toolbar_tooltip.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the developer toolbar works properly + +const TEST_URI = "data:text/html;charset=utf-8,<p>Tooltip Tests</p>"; + +function test() { + addTab(TEST_URI, function(browser, tab) { + info("Starting browser_toolbar_tooltip.js"); + openTest(); + }); +} + +function openTest() { + ok(!DeveloperToolbar.visible, "DeveloperToolbar is not visible in runTest"); + + oneTimeObserve(DeveloperToolbar.NOTIFICATIONS.SHOW, catchFail(runTest)); + document.getElementById("Tools:DevToolbar").doCommand(); +} + +function runTest() { + let tooltipPanel = DeveloperToolbar.tooltipPanel; + + DeveloperToolbar.display.focusManager.helpRequest(); + DeveloperToolbar.display.inputter.setInput('help help'); + + DeveloperToolbar.display.inputter.setCursor({ start: 'help help'.length }); + is(tooltipPanel._dimensions.start, 'help '.length, + 'search param start, when cursor at end'); + ok(getLeftMargin() > 30, 'tooltip offset, when cursor at end') + + DeveloperToolbar.display.inputter.setCursor({ start: 'help'.length }); + is(tooltipPanel._dimensions.start, 0, + 'search param start, when cursor at end of command'); + ok(getLeftMargin() > 9, 'tooltip offset, when cursor at end of command') + + DeveloperToolbar.display.inputter.setCursor({ start: 'help help'.length - 1 }); + is(tooltipPanel._dimensions.start, 'help '.length, + 'search param start, when cursor at penultimate position'); + ok(getLeftMargin() > 30, 'tooltip offset, when cursor at penultimate position') + + DeveloperToolbar.display.inputter.setCursor({ start: 0 }); + is(tooltipPanel._dimensions.start, 0, + 'search param start, when cursor at start'); + ok(getLeftMargin() > 9, 'tooltip offset, when cursor at start') + + finish(); +} + +function getLeftMargin() { + let style = DeveloperToolbar.tooltipPanel._panel.style.marginLeft; + return parseInt(style.slice(0, -2), 10); +} diff --git a/browser/devtools/shared/test/browser_toolbar_webconsole_errors_count.html b/browser/devtools/shared/test/browser_toolbar_webconsole_errors_count.html new file mode 100644 index 000000000..216cc0d49 --- /dev/null +++ b/browser/devtools/shared/test/browser_toolbar_webconsole_errors_count.html @@ -0,0 +1,32 @@ +<!DOCTYPE HTML> +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<html> +<head> + <meta charset="UTF-8"> + <title>Developer Toolbar Tests - errors count in the Web Console button</title> + <script type="text/javascript"> + console.log("foobarBug762996consoleLog"); + window.onload = function() { + window.foobarBug762996load(); + }; + window.foobarBug762996a(); + </script> + <script type="text/javascript"> + window.foobarBug762996b(); + </script> +</head> +<body> + <p>Hello world! Test for errors count in the Web Console button (developer + toolbar).</p> + <p style="color: foobarBug762996css"><button>click me</button></p> + <script type="text/javascript;version=1.8"> + "use strict"; + let testObj = {}; + document.querySelector("button").onclick = function() { + let test = testObj.fooBug788445 + "warning"; + window.foobarBug762996click(); + }; + </script> +</body> +</html> diff --git a/browser/devtools/shared/test/browser_toolbar_webconsole_errors_count.js b/browser/devtools/shared/test/browser_toolbar_webconsole_errors_count.js new file mode 100644 index 000000000..aaf027451 --- /dev/null +++ b/browser/devtools/shared/test/browser_toolbar_webconsole_errors_count.js @@ -0,0 +1,245 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the developer toolbar errors count works properly. + +function test() { + const TEST_URI = "http://example.com/browser/browser/devtools/shared/test/" + + "browser_toolbar_webconsole_errors_count.html"; + + let HUDService = Cu.import("resource:///modules/HUDService.jsm", + {}).HUDService; + let gDevTools = Cu.import("resource:///modules/devtools/gDevTools.jsm", + {}).gDevTools; + + let webconsole = document.getElementById("developer-toolbar-toolbox-button"); + let tab1, tab2; + + Services.prefs.setBoolPref("javascript.options.strict", true); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("javascript.options.strict"); + }); + + ignoreAllUncaughtExceptions(); + addTab(TEST_URI, openToolbar); + + function openToolbar(browser, tab) { + tab1 = tab; + ignoreAllUncaughtExceptions(false); + + expectUncaughtException(); + + if (!DeveloperToolbar.visible) { + DeveloperToolbar.show(true, onOpenToolbar); + } + else { + onOpenToolbar(); + } + } + + function onOpenToolbar() { + ok(DeveloperToolbar.visible, "DeveloperToolbar is visible"); + + waitForButtonUpdate({ + name: "web console button shows page errors", + errors: 3, + warnings: 0, + callback: addErrors, + }); + } + + function addErrors() { + expectUncaughtException(); + + waitForFocus(function() { + let button = content.document.querySelector("button"); + executeSoon(function() { + EventUtils.synthesizeMouse(button, 3, 2, {}, content); + }); + }, content); + + waitForButtonUpdate({ + name: "button shows one more error after click in page", + errors: 4, + warnings: 1, + callback: () => { + ignoreAllUncaughtExceptions(); + addTab(TEST_URI, onOpenSecondTab); + }, + }); + } + + function onOpenSecondTab(browser, tab) { + tab2 = tab; + + ignoreAllUncaughtExceptions(false); + expectUncaughtException(); + + waitForButtonUpdate({ + name: "button shows correct number of errors after new tab is open", + errors: 3, + warnings: 0, + callback: switchToTab1, + }); + } + + function switchToTab1() { + gBrowser.selectedTab = tab1; + waitForButtonUpdate({ + name: "button shows the page errors from tab 1", + errors: 4, + warnings: 1, + callback: openWebConsole.bind(null, tab1, onWebConsoleOpen), + }); + } + + function onWebConsoleOpen(hud) { + waitForValue({ + name: "web console shows the page errors", + validator: function() { + return hud.outputNode.querySelectorAll(".hud-exception").length; + }, + value: 4, + success: checkConsoleOutput.bind(null, hud), + failure: finish, + }); + } + + function checkConsoleOutput(hud) { + let msgs = ["foobarBug762996a", "foobarBug762996b", "foobarBug762996load", + "foobarBug762996click", "foobarBug762996consoleLog", + "foobarBug762996css", "fooBug788445"]; + msgs.forEach(function(msg) { + isnot(hud.outputNode.textContent.indexOf(msg), -1, + msg + " found in the Web Console output"); + }); + + hud.jsterm.clearOutput(); + + is(hud.outputNode.textContent.indexOf("foobarBug762996color"), -1, + "clearOutput() worked"); + + expectUncaughtException(); + let button = content.document.querySelector("button"); + EventUtils.synthesizeMouse(button, 2, 2, {}, content); + + waitForButtonUpdate({ + name: "button shows one more error after another click in page", + errors: 5, + warnings: 1, // warnings are not repeated by the js engine + callback: () => waitForValue(waitForNewError), + }); + + let waitForNewError = { + name: "the Web Console displays the new error", + validator: function() { + return hud.outputNode.textContent.indexOf("foobarBug762996click") > -1; + }, + success: doClearConsoleButton.bind(null, hud), + failure: finish, + }; + } + + function doClearConsoleButton(hud) { + let clearButton = hud.ui.rootElement + .querySelector(".webconsole-clear-console-button"); + EventUtils.synthesizeMouse(clearButton, 2, 2, {}, hud.iframeWindow); + + is(hud.outputNode.textContent.indexOf("foobarBug762996click"), -1, + "clear console button worked"); + is(getErrorsCount(), 0, "page errors counter has been reset"); + let tooltip = getTooltipValues(); + is(tooltip[1], 0, "page warnings counter has been reset"); + + doPageReload(hud); + } + + function doPageReload(hud) { + tab1.linkedBrowser.addEventListener("load", onReload, true); + + ignoreAllUncaughtExceptions(); + content.location.reload(); + + function onReload() { + tab1.linkedBrowser.removeEventListener("load", onReload, true); + ignoreAllUncaughtExceptions(false); + expectUncaughtException(); + + waitForButtonUpdate({ + name: "the Web Console button count has been reset after page reload", + errors: 3, + warnings: 0, + callback: waitForValue.bind(null, waitForConsoleOutputAfterReload), + }); + } + + let waitForConsoleOutputAfterReload = { + name: "the Web Console displays the correct number of errors after reload", + validator: function() { + return hud.outputNode.querySelectorAll(".hud-exception").length; + }, + value: 3, + success: function() { + isnot(hud.outputNode.textContent.indexOf("foobarBug762996load"), -1, + "foobarBug762996load found in console output after page reload"); + testEnd(); + }, + failure: testEnd, + }; + } + + function testEnd() { + document.getElementById("developer-toolbar-closebutton").doCommand(); + let target1 = TargetFactory.forTab(tab1); + gDevTools.closeToolbox(target1); + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + finish(); + } + + // Utility functions + + function getErrorsCount() { + let count = webconsole.getAttribute("error-count"); + return count ? count : "0"; + } + + function getTooltipValues() { + let matches = webconsole.getAttribute("tooltiptext") + .match(/(\d+) errors?, (\d+) warnings?/); + return matches ? [matches[1], matches[2]] : [0, 0]; + } + + function waitForButtonUpdate(options) { + function check() { + let errors = getErrorsCount(); + let tooltip = getTooltipValues(); + let result = errors == options.errors && tooltip[1] == options.warnings; + if (result) { + ok(true, options.name); + is(errors, tooltip[0], "button error-count is the same as in the tooltip"); + + // Get out of the toolbar event execution loop. + executeSoon(options.callback); + } + return result; + } + + if (!check()) { + info("wait for: " + options.name); + DeveloperToolbar.on("errors-counter-updated", function onUpdate(event) { + if (check()) { + DeveloperToolbar.off(event, onUpdate); + } + }); + } + } + + function openWebConsole(tab, callback) + { + let target = TargetFactory.forTab(tab); + gDevTools.showToolbox(target, "webconsole").then((toolbox) => + callback(toolbox.getCurrentPanel().hud)); + } +} diff --git a/browser/devtools/shared/test/head.js b/browser/devtools/shared/test/head.js new file mode 100644 index 000000000..d79c5c4c8 --- /dev/null +++ b/browser/devtools/shared/test/head.js @@ -0,0 +1,121 @@ +/* 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/. */ + +let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); +let TargetFactory = devtools.TargetFactory; + +/** + * Open a new tab at a URL and call a callback on load + */ +function addTab(aURL, aCallback) +{ + waitForExplicitFinish(); + + gBrowser.selectedTab = gBrowser.addTab(); + content.location = aURL; + + let tab = gBrowser.selectedTab; + let browser = gBrowser.getBrowserForTab(tab); + + function onTabLoad() { + browser.removeEventListener("load", onTabLoad, true); + aCallback(browser, tab, browser.contentDocument); + } + + browser.addEventListener("load", onTabLoad, true); +} + +registerCleanupFunction(function tearDown() { + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } + + console = undefined; +}); + +function catchFail(func) { + return function() { + try { + return func.apply(null, arguments); + } + catch (ex) { + ok(false, ex); + console.error(ex); + finish(); + throw ex; + } + }; +} + +/** + * Polls a given function waiting for the given value. + * + * @param object aOptions + * Options object with the following properties: + * - validator + * A validator function that should return the expected value. This is + * called every few milliseconds to check if the result is the expected + * one. When the returned result is the expected one, then the |success| + * function is called and polling stops. If |validator| never returns + * the expected value, then polling timeouts after several tries and + * a failure is recorded - the given |failure| function is invoked. + * - success + * A function called when the validator function returns the expected + * value. + * - failure + * A function called if the validator function timeouts - fails to return + * the expected value in the given time. + * - name + * Name of test. This is used to generate the success and failure + * messages. + * - timeout + * Timeout for validator function, in milliseconds. Default is 5000 ms. + * - value + * The expected value. If this option is omitted then the |validator| + * function must return a trueish value. + * Each of the provided callback functions will receive two arguments: + * the |aOptions| object and the last value returned by |validator|. + */ +function waitForValue(aOptions) +{ + let start = Date.now(); + let timeout = aOptions.timeout || 5000; + let lastValue; + + function wait(validatorFn, successFn, failureFn) + { + if ((Date.now() - start) > timeout) { + // Log the failure. + ok(false, "Timed out while waiting for: " + aOptions.name); + let expected = "value" in aOptions ? + "'" + aOptions.value + "'" : + "a trueish value"; + info("timeout info :: got '" + lastValue + "', expected " + expected); + failureFn(aOptions, lastValue); + return; + } + + lastValue = validatorFn(aOptions, lastValue); + let successful = "value" in aOptions ? + lastValue == aOptions.value : + lastValue; + if (successful) { + ok(true, aOptions.name); + successFn(aOptions, lastValue); + } + else { + setTimeout(function() wait(validatorFn, successFn, failureFn), 100); + } + } + + wait(aOptions.validator, aOptions.success, aOptions.failure); +} + +function oneTimeObserve(name, callback) { + var func = function() { + Services.obs.removeObserver(func, name); + callback(); + }; + Services.obs.addObserver(func, name, false); +} diff --git a/browser/devtools/shared/test/leakhunt.js b/browser/devtools/shared/test/leakhunt.js new file mode 100644 index 000000000..66d067a3c --- /dev/null +++ b/browser/devtools/shared/test/leakhunt.js @@ -0,0 +1,170 @@ +/* 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/. */ + +/** + * Memory leak hunter. Walks a tree of objects looking for DOM nodes. + * Usage: + * leakHunt({ + * thing: thing, + * otherthing: otherthing + * }); + */ +function leakHunt(root) { + var path = []; + var seen = []; + + try { + var output = leakHunt.inner(root, path, seen); + output.forEach(function(line) { + dump(line + '\n'); + }); + } + catch (ex) { + dump(ex + '\n'); + } +} + +leakHunt.inner = function LH_inner(root, path, seen) { + var prefix = new Array(path.length).join(' '); + + var reply = []; + function log(msg) { + reply.push(msg); + } + + var direct + try { + direct = Object.keys(root); + } + catch (ex) { + log(prefix + ' Error enumerating: ' + ex); + return reply; + } + + try { + var index = 0; + for (var data of root) { + var prop = '' + index; + leakHunt.digProperty(prop, data, path, seen, direct, log); + index++; + } + } + catch (ex) { /* Ignore things that are not enumerable */ } + + for (var prop in root) { + var data; + try { + data = root[prop]; + } + catch (ex) { + log(prefix + ' ' + prop + ' = Error: ' + ex.toString().substring(0, 30)); + continue; + } + + leakHunt.digProperty(prop, data, path, seen, direct, log); + } + + return reply; +} + +leakHunt.hide = [ /^string$/, /^number$/, /^boolean$/, /^null/, /^undefined/ ]; + +leakHunt.noRecurse = [ + /^string$/, /^number$/, /^boolean$/, /^null/, /^undefined/, + /^Window$/, /^Document$/, + /^XULDocument$/, /^XULElement$/, + /^DOMWindow$/, /^HTMLDocument$/, /^HTML.*Element$/, /^ChromeWindow$/ +]; + +leakHunt.digProperty = function LH_digProperty(prop, data, path, seen, direct, log) { + var newPath = path.slice(); + newPath.push(prop); + var prefix = new Array(newPath.length).join(' '); + + var recurse = true; + var message = leakHunt.getType(data); + + if (leakHunt.matchesAnyPattern(message, leakHunt.hide)) { + return; + } + + if (message === 'function' && direct.indexOf(prop) == -1) { + return; + } + + if (message === 'string') { + var extra = data.length > 10 ? data.substring(0, 9) + '_' : data; + message += ' "' + extra.replace(/\n/g, "|") + '"'; + recurse = false; + } + else if (leakHunt.matchesAnyPattern(message, leakHunt.noRecurse)) { + message += ' (no recurse)' + recurse = false; + } + else if (seen.indexOf(data) !== -1) { + message += ' (already seen)'; + recurse = false; + } + + if (recurse) { + seen.push(data); + var lines = leakHunt.inner(data, newPath, seen); + if (lines.length == 0) { + if (message !== 'function') { + log(prefix + prop + ' = ' + message + ' { }'); + } + } + else { + log(prefix + prop + ' = ' + message + ' {'); + lines.forEach(function(line) { + log(line); + }); + log(prefix + '}'); + } + } + else { + log(prefix + prop + ' = ' + message); + } +}; + +leakHunt.matchesAnyPattern = function LH_matchesAnyPattern(str, patterns) { + var match = false; + patterns.forEach(function(pattern) { + if (str.match(pattern)) { + match = true; + } + }); + return match; +}; + +leakHunt.getType = function LH_getType(data) { + if (data === null) { + return 'null'; + } + if (data === undefined) { + return 'undefined'; + } + + var type = typeof data; + if (type === 'object' || type === 'Object') { + type = leakHunt.getCtorName(data); + } + + return type; +}; + +leakHunt.getCtorName = function LH_getCtorName(aObj) { + try { + if (aObj.constructor && aObj.constructor.name) { + return aObj.constructor.name; + } + } + catch (ex) { + return 'UnknownObject'; + } + + // If that fails, use Objects toString which sometimes gives something + // better than 'Object', and at least defaults to Object if nothing better + return Object.prototype.toString.call(aObj).slice(8, -1); +}; diff --git a/browser/devtools/shared/test/moz.build b/browser/devtools/shared/test/moz.build new file mode 100644 index 000000000..191c90f0b --- /dev/null +++ b/browser/devtools/shared/test/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# 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/. + +XPCSHELL_TESTS_MANIFESTS += ['unit/xpcshell.ini'] diff --git a/browser/devtools/shared/test/unit/test_undoStack.js b/browser/devtools/shared/test/unit/test_undoStack.js new file mode 100644 index 000000000..f1a230693 --- /dev/null +++ b/browser/devtools/shared/test/unit/test_undoStack.js @@ -0,0 +1,98 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const Cu = Components.utils; +let {Loader} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {}); + +let loader = new Loader.Loader({ + paths: { + "": "resource://gre/modules/commonjs/", + "devtools": "resource:///modules/devtools", + }, + globals: {}, +}); +let require = Loader.Require(loader, { id: "undo-test" }) + +let {UndoStack} = require("devtools/shared/undo"); + +const MAX_SIZE = 5; + +function run_test() +{ + let str = ""; + let stack = new UndoStack(MAX_SIZE); + + function add(ch) { + stack.do(function() { + str += ch; + }, function() { + str = str.slice(0, -1); + }); + } + + do_check_false(stack.canUndo()); + do_check_false(stack.canRedo()); + + // Check adding up to the limit of the size + add("a"); + do_check_true(stack.canUndo()); + do_check_false(stack.canRedo()); + + add("b"); + add("c"); + add("d"); + add("e"); + + do_check_eq(str, "abcde"); + + // Check a simple undo+redo + stack.undo(); + + do_check_eq(str, "abcd"); + do_check_true(stack.canRedo()); + + stack.redo(); + do_check_eq(str, "abcde") + do_check_false(stack.canRedo()); + + // Check an undo followed by a new action + stack.undo(); + do_check_eq(str, "abcd"); + + add("q"); + do_check_eq(str, "abcdq"); + do_check_false(stack.canRedo()); + + stack.undo(); + do_check_eq(str, "abcd"); + stack.redo(); + do_check_eq(str, "abcdq"); + + // Revert back to the beginning of the queue... + while (stack.canUndo()) { + stack.undo(); + } + do_check_eq(str, ""); + + // Now put it all back.... + while (stack.canRedo()) { + stack.redo(); + } + do_check_eq(str, "abcdq"); + + // Now go over the undo limit... + add("1"); + add("2"); + add("3"); + + do_check_eq(str, "abcdq123"); + + // And now undoing the whole stack should only undo 5 actions. + while (stack.canUndo()) { + stack.undo(); + } + + do_check_eq(str, "abc"); +} diff --git a/browser/devtools/shared/test/unit/xpcshell.ini b/browser/devtools/shared/test/unit/xpcshell.ini new file mode 100644 index 000000000..342274bed --- /dev/null +++ b/browser/devtools/shared/test/unit/xpcshell.ini @@ -0,0 +1,6 @@ +[DEFAULT] +head = +tail = +firefox-appdir = browser + +[test_undoStack.js] diff --git a/browser/devtools/shared/theme-switching.js b/browser/devtools/shared/theme-switching.js new file mode 100644 index 000000000..d36e51c87 --- /dev/null +++ b/browser/devtools/shared/theme-switching.js @@ -0,0 +1,74 @@ +/* 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/. */ + +(function() { + const DEVTOOLS_SKIN_URL = "chrome://browser/skin/devtools/"; + + function forceStyle() { + let computedStyle = window.getComputedStyle(document.documentElement); + if (!computedStyle) { + // Null when documentElement is not ready. This method is anyways not + // required then as scrollbars would be in their state without flushing. + return; + } + let display = computedStyle.display; // Save display value + document.documentElement.style.display = "none"; + window.getComputedStyle(document.documentElement).display; // Flush + document.documentElement.style.display = display; // Restore + } + + function switchTheme(newTheme, oldTheme) { + let winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + + if (oldTheme && newTheme != oldTheme) { + let oldThemeUrl = Services.io.newURI( + DEVTOOLS_SKIN_URL + oldTheme + "-theme.css", null, null); + try { + winUtils.removeSheet(oldThemeUrl, window.AUTHOR_SHEET); + } catch(ex) {} + } + + let newThemeUrl = Services.io.newURI( + DEVTOOLS_SKIN_URL + newTheme + "-theme.css", null, null); + winUtils.loadSheet(newThemeUrl, window.AUTHOR_SHEET); + + // Floating scrollbars à la osx + if (Services.appinfo.OS != "Darwin") { + let scrollbarsUrl = Services.io.newURI( + DEVTOOLS_SKIN_URL + "floating-scrollbars-light.css", null, null); + + if (newTheme == "dark") { + winUtils.loadSheet(scrollbarsUrl, window.AGENT_SHEET); + } else if (oldTheme == "dark") { + try { + winUtils.removeSheet(scrollbarsUrl, window.AGENT_SHEET); + } catch(ex) {} + } + forceStyle(); + } + + document.documentElement.classList.remove("theme-" + oldTheme); + document.documentElement.classList.add("theme-" + newTheme); + } + + function handlePrefChange(event, data) { + if (data.pref == "devtools.theme") { + switchTheme(data.newValue, data.oldValue); + } + } + + const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + + Cu.import("resource://gre/modules/Services.jsm"); + Cu.import("resource:///modules/devtools/gDevTools.jsm"); + + let theme = Services.prefs.getCharPref("devtools.theme"); + switchTheme(theme); + + gDevTools.on("pref-changed", handlePrefChange); + window.addEventListener("unload", function() { + gDevTools.off("pref-changed", handlePrefChange); + }); +})(); diff --git a/browser/devtools/shared/undo.js b/browser/devtools/shared/undo.js new file mode 100644 index 000000000..ca825329a --- /dev/null +++ b/browser/devtools/shared/undo.js @@ -0,0 +1,206 @@ +/* -*- Mode: C++; tab-width: 8; 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/. */ + +/** + * A simple undo stack manager. + * + * Actions are added along with the necessary code to + * reverse the action. + * + * @param function aChange Called whenever the size or position + * of the undo stack changes, to use for updating undo-related + * UI. + * @param integer aMaxUndo Maximum number of undo steps. + * defaults to 50. + */ +function UndoStack(aMaxUndo) +{ + this.maxUndo = aMaxUndo || 50; + this._stack = []; +} + +exports.UndoStack = UndoStack; + +UndoStack.prototype = { + // Current index into the undo stack. Is positioned after the last + // currently-applied change. + _index: 0, + + // The current batch depth (see startBatch() for details) + _batchDepth: 0, + + destroy: function Undo_destroy() + { + this.uninstallController(); + delete this._stack; + }, + + /** + * Start a collection of related changes. Changes will be batched + * together into one undo/redo item until endBatch() is called. + * + * Batches can be nested, in which case the outer batch will contain + * all items from the inner batches. This allows larger user + * actions made up of a collection of smaller actions to be + * undone as a single action. + */ + startBatch: function Undo_startBatch() + { + if (this._batchDepth++ === 0) { + this._batch = []; + } + }, + + /** + * End a batch of related changes, performing its action and adding + * it to the undo stack. + */ + endBatch: function Undo_endBatch() + { + if (--this._batchDepth > 0) { + return; + } + + // Cut off the end of the undo stack at the current index, + // and the beginning to prevent a stack larger than maxUndo. + let start = Math.max((this._index + 1) - this.maxUndo, 0); + this._stack = this._stack.slice(start, this._index); + + let batch = this._batch; + delete this._batch; + let entry = { + do: function() { + for (let item of batch) { + item.do(); + } + }, + undo: function() { + for (let i = batch.length - 1; i >= 0; i--) { + batch[i].undo(); + } + } + }; + this._stack.push(entry); + this._index = this._stack.length; + entry.do(); + this._change(); + }, + + /** + * Perform an action, adding it to the undo stack. + * + * @param function aDo Called to perform the action. + * @param function aUndo Called to reverse the action. + */ + do: function Undo_do(aDo, aUndo) { + this.startBatch(); + this._batch.push({ do: aDo, undo: aUndo }); + this.endBatch(); + }, + + /* + * Returns true if undo() will do anything. + */ + canUndo: function Undo_canUndo() + { + return this._index > 0; + }, + + /** + * Undo the top of the undo stack. + * + * @return true if an action was undone. + */ + undo: function Undo_canUndo() + { + if (!this.canUndo()) { + return false; + } + this._stack[--this._index].undo(); + this._change(); + return true; + }, + + /** + * Returns true if redo() will do anything. + */ + canRedo: function Undo_canRedo() + { + return this._stack.length > this._index; + }, + + /** + * Redo the most recently undone action. + * + * @return true if an action was redone. + */ + redo: function Undo_canRedo() + { + if (!this.canRedo()) { + return false; + } + this._stack[this._index++].do(); + this._change(); + return true; + }, + + _change: function Undo__change() + { + if (this._controllerWindow) { + this._controllerWindow.goUpdateCommand("cmd_undo"); + this._controllerWindow.goUpdateCommand("cmd_redo"); + } + }, + + /** + * ViewController implementation for undo/redo. + */ + + /** + * Install this object as a command controller. + */ + installController: function Undo_installController(aControllerWindow) + { + this._controllerWindow = aControllerWindow; + aControllerWindow.controllers.appendController(this); + }, + + /** + * Uninstall this object from the command controller. + */ + uninstallController: function Undo_uninstallController() + { + if (!this._controllerWindow) { + return; + } + this._controllerWindow.controllers.removeController(this); + }, + + supportsCommand: function Undo_supportsCommand(aCommand) + { + return (aCommand == "cmd_undo" || + aCommand == "cmd_redo"); + }, + + isCommandEnabled: function Undo_isCommandEnabled(aCommand) + { + switch(aCommand) { + case "cmd_undo": return this.canUndo(); + case "cmd_redo": return this.canRedo(); + }; + return false; + }, + + doCommand: function Undo_doCommand(aCommand) + { + switch(aCommand) { + case "cmd_undo": return this.undo(); + case "cmd_redo": return this.redo(); + } + }, + + onEvent: function Undo_onEvent(aEvent) {}, +} diff --git a/browser/devtools/shared/widgets/BreadcrumbsWidget.jsm b/browser/devtools/shared/widgets/BreadcrumbsWidget.jsm new file mode 100644 index 000000000..a6c50a01c --- /dev/null +++ b/browser/devtools/shared/widgets/BreadcrumbsWidget.jsm @@ -0,0 +1,227 @@ +/* -*- 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 Ci = Components.interfaces; +const Cu = Components.utils; + +const ENSURE_SELECTION_VISIBLE_DELAY = 50; // ms + +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); +Cu.import("resource:///modules/devtools/shared/event-emitter.js"); + +this.EXPORTED_SYMBOLS = ["BreadcrumbsWidget"]; + +/** + * A breadcrumb-like list of items. + * This widget should be used in tandem with the WidgetMethods in ViewHelpers.jsm + * + * @param nsIDOMNode aNode + * The element associated with the widget. + */ +this.BreadcrumbsWidget = function BreadcrumbsWidget(aNode) { + this.document = aNode.ownerDocument; + this.window = this.document.defaultView; + this._parent = aNode; + + // Create an internal arrowscrollbox container. + this._list = this.document.createElement("arrowscrollbox"); + this._list.className = "breadcrumbs-widget-container"; + this._list.setAttribute("flex", "1"); + this._list.setAttribute("orient", "horizontal"); + this._list.setAttribute("clicktoscroll", "true") + this._list.addEventListener("keypress", e => this.emit("keyPress", e), false); + this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false); + this._parent.appendChild(this._list); + + // By default, hide the arrows. We let the arrowscrollbox show them + // in case of overflow. + this._list._scrollButtonUp.collapsed = true; + this._list._scrollButtonDown.collapsed = true; + this._list.addEventListener("underflow", this._onUnderflow.bind(this), false); + this._list.addEventListener("overflow", this._onOverflow.bind(this), false); + + // This widget emits events that can be handled in a MenuContainer. + EventEmitter.decorate(this); + + // Delegate some of the associated node's methods to satisfy the interface + // required by MenuContainer instances. + ViewHelpers.delegateWidgetAttributeMethods(this, aNode); + ViewHelpers.delegateWidgetEventMethods(this, aNode); +}; + +BreadcrumbsWidget.prototype = { + /** + * Inserts an item in this container at the specified index. + * + * @param number aIndex + * The position in the container intended for this item. + * @param string | nsIDOMNode aContents + * The string or node displayed in the container. + * @return nsIDOMNode + * The element associated with the displayed item. + */ + insertItemAt: function(aIndex, aContents) { + let list = this._list; + let breadcrumb = new Breadcrumb(this, aContents); + return list.insertBefore(breadcrumb._target, list.childNodes[aIndex]); + }, + + /** + * Returns the child node in this container situated at the specified index. + * + * @param number aIndex + * The position in the container intended for this item. + * @return nsIDOMNode + * The element associated with the displayed item. + */ + getItemAtIndex: function(aIndex) { + return this._list.childNodes[aIndex]; + }, + + /** + * Removes the specified child node from this container. + * + * @param nsIDOMNode aChild + * The element associated with the displayed item. + */ + removeChild: function(aChild) { + this._list.removeChild(aChild); + + if (this._selectedItem == aChild) { + this._selectedItem = null; + } + }, + + /** + * Removes all of the child nodes from this container. + */ + removeAllItems: function() { + let list = this._list; + + while (list.hasChildNodes()) { + list.firstChild.remove(); + } + + this._selectedItem = null; + }, + + /** + * Gets the currently selected child node in this container. + * @return nsIDOMNode + */ + get selectedItem() this._selectedItem, + + /** + * Sets the currently selected child node in this container. + * @param nsIDOMNode aChild + */ + set selectedItem(aChild) { + let childNodes = this._list.childNodes; + + if (!aChild) { + this._selectedItem = null; + } + for (let node of childNodes) { + if (node == aChild) { + node.setAttribute("checked", ""); + this._selectedItem = node; + } else { + node.removeAttribute("checked"); + } + } + + // Repeated calls to ensureElementIsVisible would interfere with each other + // and may sometimes result in incorrect scroll positions. + this.window.clearTimeout(this._ensureVisibleTimeout); + this._ensureVisibleTimeout = this.window.setTimeout(() => { + if (this._selectedItem) { + this._list.ensureElementIsVisible(this._selectedItem); + } + }, ENSURE_SELECTION_VISIBLE_DELAY); + }, + + /** + * The underflow and overflow listener for the arrowscrollbox container. + */ + _onUnderflow: function({ target }) { + if (target != this._list) { + return; + } + target._scrollButtonUp.collapsed = true; + target._scrollButtonDown.collapsed = true; + target.removeAttribute("overflows"); + }, + + /** + * The underflow and overflow listener for the arrowscrollbox container. + */ + _onOverflow: function({ target }) { + if (target != this._list) { + return; + } + target._scrollButtonUp.collapsed = false; + target._scrollButtonDown.collapsed = false; + target.setAttribute("overflows", ""); + }, + + window: null, + document: null, + _parent: null, + _list: null, + _selectedItem: null, + _ensureVisibleTimeout: null +}; + +/** + * A Breadcrumb constructor for the BreadcrumbsWidget. + * + * @param BreadcrumbsWidget aWidget + * The widget to contain this breadcrumb. + * @param string | nsIDOMNode aContents + * The string or node displayed in the container. + */ +function Breadcrumb(aWidget, aContents) { + this.document = aWidget.document; + this.window = aWidget.window; + this.ownerView = aWidget; + + this._target = this.document.createElement("hbox"); + this._target.className = "breadcrumbs-widget-item"; + this._target.setAttribute("align", "center"); + this.contents = aContents; +} + +Breadcrumb.prototype = { + /** + * Sets the contents displayed in this item's view. + * + * @param string | nsIDOMNode aContents + * The string or node displayed in the container. + */ + set contents(aContents) { + // If this item's view contents are a string, then create a label to hold + // the text displayed in this breadcrumb. + if (typeof aContents == "string") { + let label = this.document.createElement("label"); + label.setAttribute("value", aContents); + this.contents = label; + return; + } + // If there are already some contents displayed, replace them. + if (this._target.hasChildNodes()) { + this._target.replaceChild(aContents, this._target.firstChild); + return; + } + // These are the first contents ever displayed. + this._target.appendChild(aContents); + }, + + window: null, + document: null, + ownerView: null, + _target: null +}; diff --git a/browser/devtools/shared/widgets/SideMenuWidget.jsm b/browser/devtools/shared/widgets/SideMenuWidget.jsm new file mode 100644 index 000000000..d4ca8d9f0 --- /dev/null +++ b/browser/devtools/shared/widgets/SideMenuWidget.jsm @@ -0,0 +1,621 @@ +/* -*- 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 Ci = Components.interfaces; +const Cu = Components.utils; + +const ENSURE_SELECTION_VISIBLE_DELAY = 50; // ms + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); +Cu.import("resource:///modules/devtools/shared/event-emitter.js"); + +XPCOMUtils.defineLazyModuleGetter(this, "NetworkHelper", + "resource://gre/modules/devtools/NetworkHelper.jsm"); + +this.EXPORTED_SYMBOLS = ["SideMenuWidget"]; + +/** + * A simple side menu, with the ability of grouping menu items. + * This widget should be used in tandem with the WidgetMethods in ViewHelpers.jsm + * + * @param nsIDOMNode aNode + * The element associated with the widget. + * @param boolean aShowArrows + * Specifies if items in this container should display horizontal arrows. + */ +this.SideMenuWidget = function SideMenuWidget(aNode, aShowArrows = true) { + this.document = aNode.ownerDocument; + this.window = this.document.defaultView; + this._parent = aNode; + this._showArrows = aShowArrows; + + // Create an internal scrollbox container. + this._list = this.document.createElement("scrollbox"); + this._list.className = "side-menu-widget-container"; + this._list.setAttribute("flex", "1"); + this._list.setAttribute("orient", "vertical"); + this._list.setAttribute("with-arrow", aShowArrows); + this._list.setAttribute("tabindex", "0"); + this._list.addEventListener("keypress", e => this.emit("keyPress", e), false); + this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false); + this._parent.appendChild(this._list); + this._boxObject = this._list.boxObject.QueryInterface(Ci.nsIScrollBoxObject); + + // Menu items can optionally be grouped. + this._groupsByName = new Map(); // Can't use a WeakMap because keys are strings. + this._orderedGroupElementsArray = []; + this._orderedMenuElementsArray = []; + + // This widget emits events that can be handled in a MenuContainer. + EventEmitter.decorate(this); + + // Delegate some of the associated node's methods to satisfy the interface + // required by MenuContainer instances. + ViewHelpers.delegateWidgetEventMethods(this, aNode); +}; + +SideMenuWidget.prototype = { + /** + * Specifies if groups in this container should be sorted alphabetically. + */ + sortedGroups: true, + + /** + * 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 that the container viewport should be "stuck" to the + * bottom. That is, the container is automatically scrolled down to + * keep appended items visible, but only when the scroll position is + * already at the bottom. + */ + autoscrollWithAppendedItems: false, + + /** + * Inserts an item in this container at the specified index, optionally + * grouping by name. + * + * @param number aIndex + * The position in the container intended for this item. + * @param string | nsIDOMNode aContents + * The string or node displayed in the container. + * @param string aTooltip [optional] + * A tooltip attribute for the displayed item. + * @param string aGroup [optional] + * The group to place the displayed item into. + * @return nsIDOMNode + * The element associated with the displayed item. + */ + insertItemAt: function(aIndex, aContents, aTooltip = "", aGroup = "") { + aTooltip = NetworkHelper.convertToUnicode(unescape(aTooltip)); + aGroup = NetworkHelper.convertToUnicode(unescape(aGroup)); + + // Invalidate any notices set on this widget. + this.removeAttribute("notice"); + + // Maintaining scroll position at the bottom when a new item is inserted + // depends on several factors (the order of testing is important to avoid + // needlessly expensive operations that may cause reflows): + let maintainScrollAtBottom = + // 1. The behavior should be enabled, + this.autoscrollWithAppendedItems && + // 2. There shouldn't currently be any selected item in the list. + !this._selectedItem && + // 3. The new item should be appended at the end of the list. + (aIndex < 0 || aIndex >= this._orderedMenuElementsArray.length) && + // 4. The list should already be scrolled at the bottom. + (this._list.scrollTop + this._list.clientHeight >= this._list.scrollHeight); + + let group = this._getMenuGroupForName(aGroup); + let item = this._getMenuItemForGroup(group, aContents, aTooltip); + let element = item.insertSelfAt(aIndex); + + if (this.maintainSelectionVisible) { + this.ensureSelectionIsVisible({ withGroup: true, delayed: true }); + } + if (maintainScrollAtBottom) { + this._list.scrollTop = this._list.scrollHeight; + } + + return element; + }, + + /** + * Returns the child node in this container situated at the specified index. + * + * @param number aIndex + * The position in the container intended for this item. + * @return nsIDOMNode + * The element associated with the displayed item. + */ + getItemAtIndex: function(aIndex) { + return this._orderedMenuElementsArray[aIndex]; + }, + + /** + * Removes the specified child node from this container. + * + * @param nsIDOMNode aChild + * The element associated with the displayed item. + */ + removeChild: function(aChild) { + if (aChild.className == "side-menu-widget-item-contents") { + // Remove the item itself, not the contents. + aChild.parentNode.remove(); + } else { + // Groups with no title don't have any special internal structure. + aChild.remove(); + } + + this._orderedMenuElementsArray.splice( + this._orderedMenuElementsArray.indexOf(aChild), 1); + + if (this._selectedItem == aChild) { + this._selectedItem = null; + } + }, + + /** + * Removes all of the child nodes from this container. + */ + removeAllItems: function() { + let parent = this._parent; + let list = this._list; + + while (list.hasChildNodes()) { + list.firstChild.remove(); + } + + this._selectedItem = null; + + this._groupsByName.clear(); + this._orderedGroupElementsArray.length = 0; + this._orderedMenuElementsArray.length = 0; + }, + + /** + * Gets the currently selected child node in this container. + * @return nsIDOMNode + */ + get selectedItem() this._selectedItem, + + /** + * Sets the currently selected child node in this container. + * @param nsIDOMNode aChild + */ + set selectedItem(aChild) { + let menuArray = this._orderedMenuElementsArray; + + if (!aChild) { + this._selectedItem = null; + } + for (let node of menuArray) { + if (node == aChild) { + node.classList.add("selected"); + node.parentNode.classList.add("selected"); + this._selectedItem = node; + } else { + node.classList.remove("selected"); + node.parentNode.classList.remove("selected"); + } + } + + // Repeated calls to ensureElementIsVisible would interfere with each other + // and may sometimes result in incorrect scroll positions. + this.ensureSelectionIsVisible({ delayed: true }); + }, + + /** + * Ensures the selected element is visible. + * @see SideMenuWidget.prototype.ensureElementIsVisible. + */ + ensureSelectionIsVisible: function(aFlags) { + this.ensureElementIsVisible(this.selectedItem, aFlags); + }, + + /** + * Ensures the specified element is visible. + * + * @param nsIDOMNode aElement + * The element to make visible. + * @param object aFlags [optional] + * An object containing some of the following flags: + * - withGroup: true if the group header should also be made visible, if possible + * - delayed: wait a few cycles before ensuring the selection is visible + */ + ensureElementIsVisible: function(aElement, aFlags = {}) { + if (!aElement) { + return; + } + + if (aFlags.delayed) { + delete aFlags.delayed; + this.window.clearTimeout(this._ensureVisibleTimeout); + this._ensureVisibleTimeout = this.window.setTimeout(() => { + this.ensureElementIsVisible(aElement, aFlags); + }, ENSURE_SELECTION_VISIBLE_DELAY); + return; + } + + if (aFlags.withGroup) { + let groupList = aElement.parentNode; + let groupContainer = groupList.parentNode; + groupContainer.scrollIntoView(true); // Align with the top. + } + + this._boxObject.ensureElementIsVisible(aElement); + }, + + /** + * Shows all the groups, even the ones with no visible children. + */ + showEmptyGroups: function() { + for (let group of this._orderedGroupElementsArray) { + group.hidden = false; + } + }, + + /** + * Hides all the groups which have no visible children. + */ + hideEmptyGroups: function() { + let visibleChildNodes = ".side-menu-widget-item-contents:not([hidden=true])"; + + for (let group of this._orderedGroupElementsArray) { + group.hidden = group.querySelectorAll(visibleChildNodes).length == 0; + } + for (let menuItem of this._orderedMenuElementsArray) { + menuItem.parentNode.hidden = menuItem.hidden; + } + }, + + /** + * Returns the value of the named attribute on this container. + * + * @param string aName + * The name of the attribute. + * @return string + * The current attribute value. + */ + getAttribute: function(aName) { + return this._parent.getAttribute(aName); + }, + + /** + * Adds a new attribute or changes an existing attribute on this container. + * + * @param string aName + * The name of the attribute. + * @param string aValue + * The desired attribute value. + */ + setAttribute: function(aName, aValue) { + this._parent.setAttribute(aName, aValue); + + if (aName == "notice") { + this.notice = aValue; + } + }, + + /** + * Removes an attribute on this container. + * + * @param string aName + * The name of the attribute. + */ + removeAttribute: function(aName) { + this._parent.removeAttribute(aName); + + if (aName == "notice") { + this._removeNotice(); + } + }, + + /** + * Sets the text displayed in this container as a notice. + * @param string aValue + */ + set notice(aValue) { + if (this._noticeTextNode) { + this._noticeTextNode.setAttribute("value", aValue); + } + this._noticeTextValue = aValue; + this._appendNotice(); + }, + + /** + * Creates and appends a label representing a notice in this container. + */ + _appendNotice: function() { + if (this._noticeTextNode || !this._noticeTextValue) { + return; + } + + let container = this.document.createElement("vbox"); + container.className = "side-menu-widget-empty-notice-container"; + container.setAttribute("align", "center"); + + let label = this.document.createElement("label"); + label.className = "plain side-menu-widget-empty-notice"; + label.setAttribute("value", this._noticeTextValue); + container.appendChild(label); + + this._parent.insertBefore(container, this._list); + this._noticeTextContainer = container; + this._noticeTextNode = label; + }, + + /** + * Removes the label representing a notice in this container. + */ + _removeNotice: function() { + if (!this._noticeTextNode) { + return; + } + + this._parent.removeChild(this._noticeTextContainer); + this._noticeTextContainer = null; + this._noticeTextNode = null; + }, + + /** + * Gets a container representing a group for menu items. If the container + * is not available yet, it is immediately created. + * + * @param string aName + * The required group name. + * @return SideMenuGroup + * The newly created group. + */ + _getMenuGroupForName: function(aName) { + let cachedGroup = this._groupsByName.get(aName); + if (cachedGroup) { + return cachedGroup; + } + + let group = new SideMenuGroup(this, aName); + this._groupsByName.set(aName, group); + group.insertSelfAt(this.sortedGroups ? group.findExpectedIndexForSelf() : -1); + return group; + }, + + /** + * Gets a menu item to be displayed inside a group. + * @see SideMenuWidget.prototype._getMenuGroupForName + * + * @param SideMenuGroup aGroup + * The group to contain the menu item. + * @param string | nsIDOMNode aContents + * The string or node displayed in the container. + * @param string aTooltip [optional] + * A tooltip attribute for the displayed item. + */ + _getMenuItemForGroup: function(aGroup, aContents, aTooltip) { + return new SideMenuItem(aGroup, aContents, aTooltip, this._showArrows); + }, + + window: null, + document: null, + _showArrows: false, + _parent: null, + _list: null, + _boxObject: null, + _selectedItem: null, + _groupsByName: null, + _orderedGroupElementsArray: null, + _orderedMenuElementsArray: null, + _ensureVisibleTimeout: null, + _noticeTextContainer: null, + _noticeTextNode: null, + _noticeTextValue: "" +}; + +/** + * A SideMenuGroup constructor for the BreadcrumbsWidget. + * Represents a group which should contain SideMenuItems. + * + * @param SideMenuWidget aWidget + * The widget to contain this menu item. + * @param string aName + * The string displayed in the container. + */ +function SideMenuGroup(aWidget, aName) { + this.document = aWidget.document; + this.window = aWidget.window; + this.ownerView = aWidget; + this.identifier = aName; + + // Create an internal title and list container. + if (aName) { + let target = this._target = this.document.createElement("vbox"); + target.className = "side-menu-widget-group"; + target.setAttribute("name", aName); + target.setAttribute("tooltiptext", aName); + + let list = this._list = this.document.createElement("vbox"); + list.className = "side-menu-widget-group-list"; + + let title = this._title = this.document.createElement("hbox"); + title.className = "side-menu-widget-group-title"; + + let name = this._name = this.document.createElement("label"); + name.className = "plain name"; + name.setAttribute("value", aName); + name.setAttribute("crop", "end"); + name.setAttribute("flex", "1"); + + title.appendChild(name); + target.appendChild(title); + target.appendChild(list); + } + // Skip a few redundant nodes when no title is shown. + else { + let target = this._target = this._list = this.document.createElement("vbox"); + target.className = "side-menu-widget-group side-menu-widget-group-list"; + } +} + +SideMenuGroup.prototype = { + get _orderedGroupElementsArray() this.ownerView._orderedGroupElementsArray, + get _orderedMenuElementsArray() this.ownerView._orderedMenuElementsArray, + + /** + * Inserts this group in the parent container at the specified index. + * + * @param number aIndex + * The position in the container intended for this group. + */ + insertSelfAt: function(aIndex) { + let ownerList = this.ownerView._list; + let groupsArray = this._orderedGroupElementsArray; + + if (aIndex >= 0) { + ownerList.insertBefore(this._target, groupsArray[aIndex]); + groupsArray.splice(aIndex, 0, this._target); + } else { + ownerList.appendChild(this._target); + groupsArray.push(this._target); + } + }, + + /** + * Finds the expected index of this group based on its name. + * + * @return number + * The expected index. + */ + findExpectedIndexForSelf: function() { + let identifier = this.identifier; + let groupsArray = this._orderedGroupElementsArray; + + for (let group of groupsArray) { + let name = group.getAttribute("name"); + if (name > identifier && // Insertion sort at its best :) + !name.contains(identifier)) { // Least significat group should be last. + return groupsArray.indexOf(group); + } + } + return -1; + }, + + window: null, + document: null, + ownerView: null, + identifier: "", + _target: null, + _title: null, + _name: null, + _list: null +}; + +/** + * A SideMenuItem constructor for the BreadcrumbsWidget. + * + * @param SideMenuGroup aGroup + * The group to contain this menu item. + * @param string aTooltip [optional] + * A tooltip attribute for the displayed item. + * @param string | nsIDOMNode aContents + * The string or node displayed in the container. + * @param boolean aArrowFlag + * True if a horizontal arrow should be shown. + */ +function SideMenuItem(aGroup, aContents, aTooltip, aArrowFlag) { + this.document = aGroup.document; + this.window = aGroup.window; + this.ownerView = aGroup; + + // Show a horizontal arrow towards the content. + if (aArrowFlag) { + let container = this._container = this.document.createElement("hbox"); + container.className = "side-menu-widget-item"; + container.setAttribute("tooltiptext", aTooltip); + + let target = this._target = this.document.createElement("vbox"); + target.className = "side-menu-widget-item-contents"; + + let arrow = this._arrow = this.document.createElement("hbox"); + arrow.className = "side-menu-widget-item-arrow"; + + container.appendChild(target); + container.appendChild(arrow); + } + // Skip a few redundant nodes when no horizontal arrow is shown. + else { + let target = this._target = this._container = this.document.createElement("hbox"); + target.className = "side-menu-widget-item side-menu-widget-item-contents"; + } + + this._target.setAttribute("flex", "1"); + this.contents = aContents; +} + +SideMenuItem.prototype = { + get _orderedGroupElementsArray() this.ownerView._orderedGroupElementsArray, + get _orderedMenuElementsArray() this.ownerView._orderedMenuElementsArray, + + /** + * Inserts this item in the parent group at the specified index. + * + * @param number aIndex + * The position in the container intended for this item. + * @return nsIDOMNode + * The element associated with the displayed item. + */ + insertSelfAt: function(aIndex) { + let ownerList = this.ownerView._list; + let menuArray = this._orderedMenuElementsArray; + + if (aIndex >= 0) { + ownerList.insertBefore(this._container, ownerList.childNodes[aIndex]); + menuArray.splice(aIndex, 0, this._target); + } else { + ownerList.appendChild(this._container); + menuArray.push(this._target); + } + + return this._target; + }, + + /** + * Sets the contents displayed in this item's view. + * + * @param string | nsIDOMNode aContents + * The string or node displayed in the container. + */ + set contents(aContents) { + // If this item's view contents are a string, then create a label to hold + // the text displayed in this breadcrumb. + if (typeof aContents == "string") { + let label = this.document.createElement("label"); + label.className = "side-menu-widget-item-label"; + label.setAttribute("value", aContents); + label.setAttribute("crop", "start"); + label.setAttribute("flex", "1"); + this.contents = label; + return; + } + // If there are already some contents displayed, replace them. + if (this._target.hasChildNodes()) { + this._target.replaceChild(aContents, this._target.firstChild); + return; + } + // These are the first contents ever displayed. + this._target.appendChild(aContents); + }, + + window: null, + document: null, + ownerView: null, + _target: null, + _container: null, + _arrow: null +}; diff --git a/browser/devtools/shared/widgets/VariablesView.jsm b/browser/devtools/shared/widgets/VariablesView.jsm new file mode 100644 index 000000000..7a4d5531b --- /dev/null +++ b/browser/devtools/shared/widgets/VariablesView.jsm @@ -0,0 +1,3166 @@ +/* -*- 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 Ci = Components.interfaces; +const Cu = Components.utils; + +const DBG_STRINGS_URI = "chrome://browser/locale/devtools/debugger.properties"; +const LAZY_EMPTY_DELAY = 150; // ms +const LAZY_EXPAND_DELAY = 50; // ms +const LAZY_APPEND_DELAY = 100; // ms +const LAZY_APPEND_BATCH = 100; // nodes +const PAGE_SIZE_SCROLL_HEIGHT_RATIO = 100; +const PAGE_SIZE_MAX_JUMPS = 30; +const SEARCH_ACTION_MAX_DELAY = 300; // ms + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); +Cu.import("resource:///modules/devtools/shared/event-emitter.js"); +Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js"); + +XPCOMUtils.defineLazyModuleGetter(this, "NetworkHelper", + "resource://gre/modules/devtools/NetworkHelper.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "WebConsoleUtils", + "resource://gre/modules/devtools/WebConsoleUtils.jsm"); + +this.EXPORTED_SYMBOLS = ["VariablesView"]; + +/** + * Debugger localization strings. + */ +const STR = Services.strings.createBundle(DBG_STRINGS_URI); + +/** + * A tree view for inspecting scopes, objects and properties. + * Iterable via "for (let [id, scope] in instance) { }". + * Requires the devtools common.css and debugger.css skin stylesheets. + * + * To allow replacing variable or property values in this view, provide an + * "eval" function property. To allow replacing variable or property names, + * provide a "switch" function. To handle deleting variables or properties, + * provide a "delete" function. + * + * @param nsIDOMNode aParentNode + * The parent node to hold this view. + * @param object aFlags [optional] + * An object contaning initialization options for this view. + * e.g. { lazyEmpty: true, searchEnabled: true ... } + */ +this.VariablesView = function VariablesView(aParentNode, aFlags = {}) { + this._store = []; // Can't use a Map because Scope names needn't be unique. + this._itemsByElement = new WeakMap(); + this._prevHierarchy = new Map(); + this._currHierarchy = new Map(); + + this._parent = aParentNode; + this._parent.classList.add("variables-view-container"); + this._appendEmptyNotice(); + + this._onSearchboxInput = this._onSearchboxInput.bind(this); + this._onSearchboxKeyPress = this._onSearchboxKeyPress.bind(this); + this._onViewKeyPress = this._onViewKeyPress.bind(this); + + // Create an internal scrollbox container. + this._list = this.document.createElement("scrollbox"); + this._list.setAttribute("orient", "vertical"); + this._list.addEventListener("keypress", this._onViewKeyPress, false); + this._parent.appendChild(this._list); + this._boxObject = this._list.boxObject.QueryInterface(Ci.nsIScrollBoxObject); + + for (let name in aFlags) { + this[name] = aFlags[name]; + } + + EventEmitter.decorate(this); +}; + +VariablesView.prototype = { + /** + * Helper setter for populating this container with a raw object. + * + * @param object aObject + * The raw object to display. You can only provide this object + * if you want the variables view to work in sync mode. + */ + set rawObject(aObject) { + this.empty(); + this.addScope().addItem().populate(aObject, { sorted: true }); + }, + + /** + * Adds a scope to contain any inspected variables. + * + * @param string aName + * The scope's name (e.g. "Local", "Global" etc.). + * @return Scope + * The newly created Scope instance. + */ + addScope: function(aName = "") { + this._removeEmptyNotice(); + this._toggleSearchVisibility(true); + + let scope = new Scope(this, aName); + this._store.push(scope); + this._itemsByElement.set(scope._target, scope); + this._currHierarchy.set(aName, scope); + scope.header = !!aName; + return scope; + }, + + /** + * Removes all items from this container. + * + * @param number aTimeout [optional] + * The number of milliseconds to delay the operation if + * lazy emptying of this container is enabled. + */ + empty: function(aTimeout = this.lazyEmptyDelay) { + // If there are no items in this container, emptying is useless. + if (!this._store.length) { + return; + } + // Check if this empty operation may be executed lazily. + if (this.lazyEmpty && aTimeout > 0) { + this._emptySoon(aTimeout); + return; + } + + let list = this._list; + + while (list.hasChildNodes()) { + list.firstChild.remove(); + } + + this._store.length = 0; + this._itemsByElement.clear(); + + this._appendEmptyNotice(); + this._toggleSearchVisibility(false); + }, + + /** + * Emptying this container and rebuilding it immediately afterwards would + * result in a brief redraw flicker, because the previously expanded nodes + * may get asynchronously re-expanded, after fetching the prototype and + * properties from a server. + * + * To avoid such behaviour, a normal container list is rebuild, but not + * immediately attached to the parent container. The old container list + * is kept around for a short period of time, hopefully accounting for the + * data fetching delay. In the meantime, any operations can be executed + * normally. + * + * @see VariablesView.empty + * @see VariablesView.commitHierarchy + */ + _emptySoon: function(aTimeout) { + let prevList = this._list; + let currList = this._list = this.document.createElement("scrollbox"); + + this._store.length = 0; + this._itemsByElement.clear(); + + this._emptyTimeout = this.window.setTimeout(() => { + this._emptyTimeout = null; + + prevList.removeEventListener("keypress", this._onViewKeyPress, false); + currList.addEventListener("keypress", this._onViewKeyPress, false); + currList.setAttribute("orient", "vertical"); + + this._parent.removeChild(prevList); + this._parent.appendChild(currList); + this._boxObject = currList.boxObject.QueryInterface(Ci.nsIScrollBoxObject); + + if (!this._store.length) { + this._appendEmptyNotice(); + this._toggleSearchVisibility(false); + } + }, aTimeout); + }, + + /** + * The controller for this VariablesView, if it has one. + */ + controller: null, + + /** + * The amount of time (in milliseconds) it takes to empty this view lazily. + */ + lazyEmptyDelay: LAZY_EMPTY_DELAY, + + /** + * Specifies if this view may be emptied lazily. + * @see VariablesView.prototype.empty + */ + lazyEmpty: false, + + /** + * Specifies if nodes in this view may be added lazily. + * @see Scope.prototype._lazyAppend + */ + lazyAppend: true, + + /** + * Specifies if nodes in this view may be expanded lazily. + * @see Scope.prototype.expand + */ + lazyExpand: true, + + /** + * Function called each time a variable or property's value is changed via + * user interaction. If null, then value changes are disabled. + * + * This property is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + eval: null, + + /** + * Function called each time a variable or property's name is changed via + * user interaction. If null, then name changes are disabled. + * + * This property is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + switch: null, + + /** + * Function called each time a variable or property is deleted via + * user interaction. If null, then deletions are disabled. + * + * This property is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + delete: null, + + /** + * Specifies if after an eval or switch operation, the variable or property + * which has been edited should be disabled. + */ + preventDisableOnChage: false, + + /** + * Specifies if, whenever a variable or property descriptor is available, + * configurable, enumerable, writable, frozen, sealed and extensible + * attributes should not affect presentation. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + preventDescriptorModifiers: false, + + /** + * The tooltip text shown on a variable or property's value if an |eval| + * function is provided, in order to change the variable or property's value. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + editableValueTooltip: STR.GetStringFromName("variablesEditableValueTooltip"), + + /** + * The tooltip text shown on a variable or property's name if a |switch| + * function is provided, in order to change the variable or property's name. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + editableNameTooltip: STR.GetStringFromName("variablesEditableNameTooltip"), + + /** + * The tooltip text shown on a variable or property's edit button if an + * |eval| function is provided and a getter/setter descriptor is present, + * in order to change the variable or property to a plain value. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + editButtonTooltip: STR.GetStringFromName("variablesEditButtonTooltip"), + + /** + * The tooltip text shown on a variable or property's delete button if a + * |delete| function is provided, in order to delete the variable or property. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + deleteButtonTooltip: STR.GetStringFromName("variablesCloseButtonTooltip"), + + /** + * Specifies the context menu attribute set on variables and properties. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + contextMenuId: "", + + /** + * The separator label between the variables or properties name and value. + * + * This flag is applied recursively onto each scope in this view and + * affects only the child nodes when they're created. + */ + separatorStr: STR.GetStringFromName("variablesSeparatorLabel"), + + /** + * Specifies if enumerable properties and variables should be displayed. + * These variables and properties are visible by default. + * @param boolean aFlag + */ + set enumVisible(aFlag) { + this._enumVisible = aFlag; + + for (let scope of this._store) { + scope._enumVisible = aFlag; + } + }, + + /** + * Specifies if non-enumerable properties and variables should be displayed. + * These variables and properties are visible by default. + * @param boolean aFlag + */ + set nonEnumVisible(aFlag) { + this._nonEnumVisible = aFlag; + + for (let scope of this._store) { + scope._nonEnumVisible = aFlag; + } + }, + + /** + * Specifies if only enumerable properties and variables should be displayed. + * Both types of these variables and properties are visible by default. + * @param boolean aFlag + */ + set onlyEnumVisible(aFlag) { + if (aFlag) { + this.enumVisible = true; + this.nonEnumVisible = false; + } else { + this.enumVisible = true; + this.nonEnumVisible = true; + } + }, + + /** + * Sets if the variable and property searching is enabled. + * @param boolean aFlag + */ + set searchEnabled(aFlag) aFlag ? this._enableSearch() : this._disableSearch(), + + /** + * Gets if the variable and property searching is enabled. + * @return boolean + */ + get searchEnabled() !!this._searchboxContainer, + + /** + * Sets the text displayed for the searchbox in this container. + * @param string aValue + */ + set searchPlaceholder(aValue) { + if (this._searchboxNode) { + this._searchboxNode.setAttribute("placeholder", aValue); + } + this._searchboxPlaceholder = aValue; + }, + + /** + * Gets the text displayed for the searchbox in this container. + * @return string + */ + get searchPlaceholder() this._searchboxPlaceholder, + + /** + * Enables variable and property searching in this view. + * Use the "searchEnabled" setter to enable searching. + */ + _enableSearch: function() { + // If searching was already enabled, no need to re-enable it again. + if (this._searchboxContainer) { + return; + } + let document = this.document; + let ownerView = this._parent.parentNode; + + let container = this._searchboxContainer = document.createElement("hbox"); + container.className = "devtools-toolbar"; + + // Hide the variables searchbox container if there are no variables or + // properties to display. + container.hidden = !this._store.length; + + let searchbox = this._searchboxNode = document.createElement("textbox"); + searchbox.className = "variables-view-searchinput devtools-searchinput"; + searchbox.setAttribute("placeholder", this._searchboxPlaceholder); + searchbox.setAttribute("type", "search"); + searchbox.setAttribute("flex", "1"); + searchbox.addEventListener("input", this._onSearchboxInput, false); + searchbox.addEventListener("keypress", this._onSearchboxKeyPress, false); + + container.appendChild(searchbox); + ownerView.insertBefore(container, this._parent); + }, + + /** + * Disables variable and property searching in this view. + * Use the "searchEnabled" setter to disable searching. + */ + _disableSearch: function() { + // If searching was already disabled, no need to re-disable it again. + if (!this._searchboxContainer) { + return; + } + this._searchboxContainer.remove(); + this._searchboxNode.removeEventListener("input", this._onSearchboxInput, false); + this._searchboxNode.removeEventListener("keypress", this._onSearchboxKeyPress, false); + + this._searchboxContainer = null; + this._searchboxNode = null; + }, + + /** + * Sets the variables searchbox container hidden or visible. + * It's hidden by default. + * + * @param boolean aVisibleFlag + * Specifies the intended visibility. + */ + _toggleSearchVisibility: function(aVisibleFlag) { + // If searching was already disabled, there's no need to hide it. + if (!this._searchboxContainer) { + return; + } + this._searchboxContainer.hidden = !aVisibleFlag; + }, + + /** + * Listener handling the searchbox input event. + */ + _onSearchboxInput: function() { + this.performSearch(this._searchboxNode.value); + }, + + /** + * Listener handling the searchbox key press event. + */ + _onSearchboxKeyPress: function(e) { + switch(e.keyCode) { + case e.DOM_VK_RETURN: + case e.DOM_VK_ENTER: + this._onSearchboxInput(); + return; + case e.DOM_VK_ESCAPE: + this._searchboxNode.value = ""; + this._onSearchboxInput(); + return; + } + }, + + /** + * Allows searches to be scheduled and delayed to avoid redundant calls. + */ + delayedSearch: true, + + /** + * Schedules searching for variables or properties matching the query. + * + * @param string aQuery + * The variable or property to search for. + */ + scheduleSearch: function(aQuery) { + if (!this.delayedSearch) { + this.performSearch(aQuery); + return; + } + let delay = Math.max(SEARCH_ACTION_MAX_DELAY / aQuery.length, 0); + + this.window.clearTimeout(this._searchTimeout); + this._searchFunction = this._startSearch.bind(this, aQuery); + this._searchTimeout = this.window.setTimeout(this._searchFunction, delay); + }, + + /** + * Immediately searches for variables or properties matching the query. + * + * @param string aQuery + * The variable or property to search for. + */ + performSearch: function(aQuery) { + this.window.clearTimeout(this._searchTimeout); + this._searchFunction = null; + this._startSearch(aQuery); + }, + + /** + * Performs a case insensitive search for variables or properties matching + * the query, and hides non-matched items. + * + * If aQuery is empty string, then all the scopes are unhidden and expanded, + * while the available variables and properties inside those scopes are + * just unhidden. + * + * If aQuery is null or undefined, then all the scopes are just unhidden, + * and the available variables and properties inside those scopes are also + * just unhidden. + * + * @param string aQuery + * The variable or property to search for. + */ + _startSearch: function(aQuery) { + for (let scope of this._store) { + switch (aQuery) { + case "": + scope.expand(); + // fall through + case null: + case undefined: + scope._performSearch(""); + break; + default: + scope._performSearch(aQuery.toLowerCase()); + break; + } + } + }, + + /** + * Expands the first search results in this container. + */ + expandFirstSearchResults: function() { + for (let scope of this._store) { + let match = scope._firstMatch; + if (match) { + match.expand(); + } + } + }, + + /** + * Find the first item in the tree of visible items in this container that + * matches the predicate. Searches in visual order (the order seen by the + * user). Descends into each scope to check the scope and its children. + * + * @param function aPredicate + * A function that returns true when a match is found. + * @return Scope | Variable | Property + * The first visible scope, variable or property, or null if nothing + * is found. + */ + _findInVisibleItems: function(aPredicate) { + for (let scope of this._store) { + let result = scope._findInVisibleItems(aPredicate); + if (result) { + return result; + } + } + return null; + }, + + /** + * Find the last item in the tree of visible items in this container that + * matches the predicate. Searches in reverse visual order (opposite of the + * order seen by the user). Descends into each scope to check the scope and + * its children. + * + * @param function aPredicate + * A function that returns true when a match is found. + * @return Scope | Variable | Property + * The last visible scope, variable or property, or null if nothing + * is found. + */ + _findInVisibleItemsReverse: function(aPredicate) { + for (let i = this._store.length - 1; i >= 0; i--) { + let scope = this._store[i]; + let result = scope._findInVisibleItemsReverse(aPredicate); + if (result) { + return result; + } + } + return null; + }, + + /** + * Searches for the scope in this container displayed by the specified node. + * + * @param nsIDOMNode aNode + * The node to search for. + * @return Scope + * The matched scope, or null if nothing is found. + */ + getScopeForNode: function(aNode) { + let item = this._itemsByElement.get(aNode); + // Match only Scopes, not Variables or Properties. + if (item && !(item instanceof Variable)) { + return item; + } + return null; + }, + + /** + * Recursively searches this container for the scope, variable or property + * displayed by the specified node. + * + * @param nsIDOMNode aNode + * The node to search for. + * @return Scope | Variable | Property + * The matched scope, variable or property, or null if nothing is found. + */ + getItemForNode: function(aNode) { + return this._itemsByElement.get(aNode); + }, + + /** + * Gets the currently focused scope, variable or property in this view. + * + * @return Scope | Variable | Property + * The focused scope, variable or property, or null if nothing is found. + */ + getFocusedItem: function() { + let focused = this.document.commandDispatcher.focusedElement; + return this.getItemForNode(focused); + }, + + /** + * Focuses the first visible scope, variable, or property in this container. + */ + focusFirstVisibleItem: function() { + let focusableItem = this._findInVisibleItems(item => item.focusable); + if (focusableItem) { + this._focusItem(focusableItem); + } + this._parent.scrollTop = 0; + this._parent.scrollLeft = 0; + }, + + /** + * Focuses the last visible scope, variable, or property in this container. + */ + focusLastVisibleItem: function() { + let focusableItem = this._findInVisibleItemsReverse(item => item.focusable); + if (focusableItem) { + this._focusItem(focusableItem); + } + this._parent.scrollTop = this._parent.scrollHeight; + this._parent.scrollLeft = 0; + }, + + /** + * Focuses the next scope, variable or property in this view. + */ + focusNextItem: function() { + this.focusItemAtDelta(+1); + }, + + /** + * Focuses the previous scope, variable or property in this view. + */ + focusPrevItem: function() { + this.focusItemAtDelta(-1); + }, + + /** + * Focuses another scope, variable or property in this view, based on + * the index distance from the currently focused item. + * + * @param number aDelta + * A scalar specifying by how many items should the selection change. + */ + focusItemAtDelta: function(aDelta) { + let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus"; + let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta)); + while (distance--) { + if (!this._focusChange(direction)) { + break; // Out of bounds. + } + } + }, + + /** + * Focuses the next or previous scope, variable or property in this view. + * + * @param string aDirection + * Either "advanceFocus" or "rewindFocus". + * @return boolean + * False if the focus went out of bounds and the first or last element + * in this view was focused instead. + */ + _focusChange: function(aDirection) { + let commandDispatcher = this.document.commandDispatcher; + let prevFocusedElement = commandDispatcher.focusedElement; + let currFocusedItem = null; + + do { + commandDispatcher.suppressFocusScroll = true; + commandDispatcher[aDirection](); + + // Make sure the newly focused item is a part of this view. + // If the focus goes out of bounds, revert the previously focused item. + if (!(currFocusedItem = this.getFocusedItem())) { + prevFocusedElement.focus(); + return false; + } + } while (!currFocusedItem.focusable); + + // Focus remained within bounds. + return true; + }, + + /** + * Focuses a scope, variable or property and makes sure it's visible. + * + * @param aItem Scope | Variable | Property + * The item to focus. + * @param boolean aCollapseFlag + * True if the focused item should also be collapsed. + * @return boolean + * True if the item was successfully focused. + */ + _focusItem: function(aItem, aCollapseFlag) { + if (!aItem.focusable) { + return false; + } + if (aCollapseFlag) { + aItem.collapse(); + } + aItem._target.focus(); + this._boxObject.ensureElementIsVisible(aItem._arrow); + return true; + }, + + /** + * Listener handling a key press event on the view. + */ + _onViewKeyPress: function(e) { + let item = this.getFocusedItem(); + + // Prevent scrolling when pressing navigation keys. + ViewHelpers.preventScrolling(e); + + switch (e.keyCode) { + case e.DOM_VK_UP: + // Always rewind focus. + this.focusPrevItem(true); + return; + + case e.DOM_VK_DOWN: + // Always advance focus. + this.focusNextItem(true); + return; + + case e.DOM_VK_LEFT: + // Collapse scopes, variables and properties before rewinding focus. + if (item._isExpanded && item._isArrowVisible) { + item.collapse(); + } else { + this._focusItem(item.ownerView); + } + return; + + case e.DOM_VK_RIGHT: + // Nothing to do here if this item never expands. + if (!item._isArrowVisible) { + return; + } + // Expand scopes, variables and properties before advancing focus. + if (!item._isExpanded) { + item.expand(); + } else { + this.focusNextItem(true); + } + return; + + case e.DOM_VK_PAGE_UP: + // Rewind a certain number of elements based on the container height. + this.focusItemAtDelta(-(this.pageSize || Math.min(Math.floor(this._list.scrollHeight / + PAGE_SIZE_SCROLL_HEIGHT_RATIO), + PAGE_SIZE_MAX_JUMPS))); + return; + + case e.DOM_VK_PAGE_DOWN: + // Advance a certain number of elements based on the container height. + this.focusItemAtDelta(+(this.pageSize || Math.min(Math.floor(this._list.scrollHeight / + PAGE_SIZE_SCROLL_HEIGHT_RATIO), + PAGE_SIZE_MAX_JUMPS))); + return; + + case e.DOM_VK_HOME: + this.focusFirstVisibleItem(); + return; + + case e.DOM_VK_END: + this.focusLastVisibleItem(); + return; + + case e.DOM_VK_RETURN: + case e.DOM_VK_ENTER: + // Start editing the value or name of the Variable or Property. + if (item instanceof Variable) { + if (e.metaKey || e.altKey || e.shiftKey) { + item._activateNameInput(); + } else { + item._activateValueInput(); + } + } + return; + + case e.DOM_VK_DELETE: + case e.DOM_VK_BACK_SPACE: + // Delete the Variable or Property if allowed. + if (item instanceof Variable) { + item._onDelete(e); + } + return; + } + }, + + /** + * The number of elements in this container to jump when Page Up or Page Down + * keys are pressed. If falsy, then the page size will be based on the + * container height. + */ + pageSize: 0, + + /** + * Sets the text displayed in this container when there are no available items. + * @param string aValue + */ + set emptyText(aValue) { + if (this._emptyTextNode) { + this._emptyTextNode.setAttribute("value", aValue); + } + this._emptyTextValue = aValue; + this._appendEmptyNotice(); + }, + + /** + * Creates and appends a label signaling that this container is empty. + */ + _appendEmptyNotice: function() { + if (this._emptyTextNode || !this._emptyTextValue) { + return; + } + + let label = this.document.createElement("label"); + label.className = "variables-view-empty-notice"; + label.setAttribute("value", this._emptyTextValue); + + this._parent.appendChild(label); + this._emptyTextNode = label; + }, + + /** + * Removes the label signaling that this container is empty. + */ + _removeEmptyNotice: function() { + if (!this._emptyTextNode) { + return; + } + + this._parent.removeChild(this._emptyTextNode); + this._emptyTextNode = null; + }, + + /** + * Gets the parent node holding this view. + * @return nsIDOMNode + */ + get parentNode() this._parent, + + /** + * Gets the owner document holding this view. + * @return nsIHTMLDocument + */ + get document() this._document || (this._document = this._parent.ownerDocument), + + /** + * Gets the default window holding this view. + * @return nsIDOMWindow + */ + get window() this._window || (this._window = this.document.defaultView), + + _document: null, + _window: null, + + _store: null, + _prevHierarchy: null, + _currHierarchy: null, + _enumVisible: true, + _nonEnumVisible: true, + _emptyTimeout: null, + _searchTimeout: null, + _searchFunction: null, + _parent: null, + _list: null, + _boxObject: null, + _searchboxNode: null, + _searchboxContainer: null, + _searchboxPlaceholder: "", + _emptyTextNode: null, + _emptyTextValue: "" +}; + +VariablesView.NON_SORTABLE_CLASSES = [ + "Array", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array" +]; + +/** + * Determine whether an object's properties should be sorted based on its class. + * + * @param string aClassName + * The class of the object. + */ +VariablesView.isSortable = function(aClassName) { + return VariablesView.NON_SORTABLE_CLASSES.indexOf(aClassName) == -1; +}; + +/** + * Generates the string evaluated when performing simple value changes. + * + * @param Variable | Property aItem + * The current variable or property. + * @param string aCurrentString + * The trimmed user inputted string. + * @param string aPrefix [optional] + * Prefix for the symbolic name. + * @return string + * The string to be evaluated. + */ +VariablesView.simpleValueEvalMacro = function(aItem, aCurrentString, aPrefix = "") { + return aPrefix + aItem._symbolicName + "=" + aCurrentString; +}; + +/** + * Generates the string evaluated when overriding getters and setters with + * plain values. + * + * @param Property aItem + * The current getter or setter property. + * @param string aCurrentString + * The trimmed user inputted string. + * @param string aPrefix [optional] + * Prefix for the symbolic name. + * @return string + * The string to be evaluated. + */ +VariablesView.overrideValueEvalMacro = function(aItem, aCurrentString, aPrefix = "") { + let property = "\"" + aItem._nameString + "\""; + let parent = aPrefix + aItem.ownerView._symbolicName || "this"; + + return "Object.defineProperty(" + parent + "," + property + "," + + "{ value: " + aCurrentString + + ", enumerable: " + parent + ".propertyIsEnumerable(" + property + ")" + + ", configurable: true" + + ", writable: true" + + "})"; +}; + +/** + * Generates the string evaluated when performing getters and setters changes. + * + * @param Property aItem + * The current getter or setter property. + * @param string aCurrentString + * The trimmed user inputted string. + * @param string aPrefix [optional] + * Prefix for the symbolic name. + * @return string + * The string to be evaluated. + */ +VariablesView.getterOrSetterEvalMacro = function(aItem, aCurrentString, aPrefix = "") { + let type = aItem._nameString; + let propertyObject = aItem.ownerView; + let parentObject = propertyObject.ownerView; + let property = "\"" + propertyObject._nameString + "\""; + let parent = aPrefix + parentObject._symbolicName || "this"; + + switch (aCurrentString) { + case "": + case "null": + case "undefined": + let mirrorType = type == "get" ? "set" : "get"; + let mirrorLookup = type == "get" ? "__lookupSetter__" : "__lookupGetter__"; + + // If the parent object will end up without any getter or setter, + // morph it into a plain value. + if ((type == "set" && propertyObject.getter.type == "undefined") || + (type == "get" && propertyObject.setter.type == "undefined")) { + // Make sure the right getter/setter to value override macro is applied to the target object. + return propertyObject.evaluationMacro(propertyObject, "undefined", aPrefix); + } + + // Construct and return the getter/setter removal evaluation string. + // e.g: Object.defineProperty(foo, "bar", { + // get: foo.__lookupGetter__("bar"), + // set: undefined, + // enumerable: true, + // configurable: true + // }) + return "Object.defineProperty(" + parent + "," + property + "," + + "{" + mirrorType + ":" + parent + "." + mirrorLookup + "(" + property + ")" + + "," + type + ":" + undefined + + ", enumerable: " + parent + ".propertyIsEnumerable(" + property + ")" + + ", configurable: true" + + "})"; + + default: + // Wrap statements inside a function declaration if not already wrapped. + if (!aCurrentString.startsWith("function")) { + let header = "function(" + (type == "set" ? "value" : "") + ")"; + let body = ""; + // If there's a return statement explicitly written, always use the + // standard function definition syntax + if (aCurrentString.contains("return ")) { + body = "{" + aCurrentString + "}"; + } + // If block syntax is used, use the whole string as the function body. + else if (aCurrentString.startsWith("{")) { + body = aCurrentString; + } + // Prefer an expression closure. + else { + body = "(" + aCurrentString + ")"; + } + aCurrentString = header + body; + } + + // Determine if a new getter or setter should be defined. + let defineType = type == "get" ? "__defineGetter__" : "__defineSetter__"; + + // Make sure all quotes are escaped in the expression's syntax, + let defineFunc = "eval(\"(" + aCurrentString.replace(/"/g, "\\$&") + ")\")"; + + // Construct and return the getter/setter evaluation string. + // e.g: foo.__defineGetter__("bar", eval("(function() { return 42; })")) + return parent + "." + defineType + "(" + property + "," + defineFunc + ")"; + } +}; + +/** + * Function invoked when a getter or setter is deleted. + * + * @param Property aItem + * The current getter or setter property. + */ +VariablesView.getterOrSetterDeleteCallback = function(aItem) { + aItem._disable(); + + // Make sure the right getter/setter to value override macro is applied + // to the target object. + aItem.ownerView.eval(aItem.evaluationMacro(aItem, "")); + + return true; // Don't hide the element. +}; + + +/** + * A Scope is an object holding Variable instances. + * Iterable via "for (let [name, variable] in instance) { }". + * + * @param VariablesView aView + * The view to contain this scope. + * @param string aName + * The scope's name. + * @param object aFlags [optional] + * Additional options or flags for this scope. + */ +function Scope(aView, aName, aFlags = {}) { + this.ownerView = aView; + + this._onClick = this._onClick.bind(this); + this._openEnum = this._openEnum.bind(this); + this._openNonEnum = this._openNonEnum.bind(this); + this._batchAppend = this._batchAppend.bind(this); + + // Inherit properties and flags from the parent view. You can override + // each of these directly onto any scope, variable or property instance. + this.eval = aView.eval; + this.switch = aView.switch; + this.delete = aView.delete; + this.editableValueTooltip = aView.editableValueTooltip; + this.editableNameTooltip = aView.editableNameTooltip; + this.editButtonTooltip = aView.editButtonTooltip; + this.deleteButtonTooltip = aView.deleteButtonTooltip; + this.preventDescriptorModifiers = aView.preventDescriptorModifiers; + this.contextMenuId = aView.contextMenuId; + this.separatorStr = aView.separatorStr; + + // Creating maps and arrays thousands of times for variables or properties + // with a large number of children fills up a lot of memory. Make sure + // these are instantiated only if needed. + XPCOMUtils.defineLazyGetter(this, "_store", () => new Map()); + XPCOMUtils.defineLazyGetter(this, "_enumItems", () => []); + XPCOMUtils.defineLazyGetter(this, "_nonEnumItems", () => []); + XPCOMUtils.defineLazyGetter(this, "_batchItems", () => []); + + this._init(aName.trim(), aFlags); +} + +Scope.prototype = { + /** + * Whether this Scope should be prefetched when it is remoted. + */ + shouldPrefetch: true, + + /** + * Create a new Variable that is a child of this Scope. + * + * @param string aName + * The name of the new Property. + * @param object aDescriptor + * The variable's descriptor. + * @return Variable + * The newly created child Variable. + */ + _createChild: function(aName, aDescriptor) { + return new Variable(this, aName, aDescriptor); + }, + + /** + * Adds a child to contain any inspected properties. + * + * @param string aName + * The child's name. + * @param object aDescriptor + * Specifies the value and/or type & class of the child, + * or 'get' & 'set' accessor properties. If the type is implicit, + * it will be inferred from the value. + * e.g. - { value: 42 } + * - { value: true } + * - { value: "nasu" } + * - { value: { type: "undefined" } } + * - { value: { type: "null" } } + * - { value: { type: "object", class: "Object" } } + * - { get: { type: "object", class: "Function" }, + * set: { type: "undefined" } } + * @param boolean aRelaxed + * True if name duplicates should be allowed. + * @return Variable + * The newly created Variable instance, null if it already exists. + */ + addItem: function(aName = "", aDescriptor = {}, aRelaxed = false) { + if (this._store.has(aName) && !aRelaxed) { + return null; + } + + let child = this._createChild(aName, aDescriptor); + this._store.set(aName, child); + this._variablesView._itemsByElement.set(child._target, child); + this._variablesView._currHierarchy.set(child._absoluteName, child); + child.header = !!aName; + return child; + }, + + /** + * Adds items for this variable. + * + * @param object aItems + * An object containing some { name: descriptor } data properties, + * specifying the value and/or type & class of the variable, + * or 'get' & 'set' accessor properties. If the type is implicit, + * it will be inferred from the value. + * e.g. - { someProp0: { value: 42 }, + * someProp1: { value: true }, + * someProp2: { value: "nasu" }, + * someProp3: { value: { type: "undefined" } }, + * someProp4: { value: { type: "null" } }, + * someProp5: { value: { type: "object", class: "Object" } }, + * someProp6: { get: { type: "object", class: "Function" }, + * set: { type: "undefined" } } } + * @param object aOptions [optional] + * Additional options for adding the properties. Supported options: + * - sorted: true to sort all the properties before adding them + * - callback: function invoked after each item is added + */ + addItems: function(aItems, aOptions = {}) { + let names = Object.keys(aItems); + + // Sort all of the properties before adding them, if preferred. + if (aOptions.sorted) { + names.sort(); + } + // Add the properties to the current scope. + for (let name of names) { + let descriptor = aItems[name]; + let item = this.addItem(name, descriptor); + + if (aOptions.callback) { + aOptions.callback(item, descriptor.value); + } + } + }, + + /** + * Gets the variable in this container having the specified name. + * + * @param string aName + * The name of the variable to get. + * @return Variable + * The matched variable, or null if nothing is found. + */ + get: function(aName) { + return this._store.get(aName); + }, + + /** + * Recursively searches for the variable or property in this container + * displayed by the specified node. + * + * @param nsIDOMNode aNode + * The node to search for. + * @return Variable | Property + * The matched variable or property, or null if nothing is found. + */ + find: function(aNode) { + for (let [, variable] of this._store) { + let match; + if (variable._target == aNode) { + match = variable; + } else { + match = variable.find(aNode); + } + if (match) { + return match; + } + } + return null; + }, + + /** + * Determines if this scope is a direct child of a parent variables view, + * scope, variable or property. + * + * @param VariablesView | Scope | Variable | Property + * The parent to check. + * @return boolean + * True if the specified item is a direct child, false otherwise. + */ + isChildOf: function(aParent) { + return this.ownerView == aParent; + }, + + /** + * Determines if this scope is a descendant of a parent variables view, + * scope, variable or property. + * + * @param VariablesView | Scope | Variable | Property + * The parent to check. + * @return boolean + * True if the specified item is a descendant, false otherwise. + */ + isDescendantOf: function(aParent) { + if (this.isChildOf(aParent)) { + return true; + } + + // Recurse to parent if it is a Scope, Variable, or Property. + if (this.ownerView instanceof Scope) { + return this.ownerView.isDescendantOf(aParent); + } + + return false; + }, + + /** + * Shows the scope. + */ + show: function() { + this._target.hidden = false; + this._isContentVisible = true; + + if (this.onshow) { + this.onshow(this); + } + }, + + /** + * Hides the scope. + */ + hide: function() { + this._target.hidden = true; + this._isContentVisible = false; + + if (this.onhide) { + this.onhide(this); + } + }, + + /** + * Expands the scope, showing all the added details. + */ + expand: function() { + if (this._isExpanded || this._locked) { + return; + } + // If there's a large number of enumerable or non-enumerable items + // contained in this scope, painting them may take several seconds, + // even if they were already displayed before. In this case, show a throbber + // to suggest that this scope is expanding. + if (!this._isExpanding && + this._variablesView.lazyExpand && + this._store.size > LAZY_APPEND_BATCH) { + this._isExpanding = true; + + // Start spinning a throbber in this scope's title and allow a few + // milliseconds for it to be painted. + this._startThrobber(); + this.window.setTimeout(this.expand.bind(this), LAZY_EXPAND_DELAY); + return; + } + + if (this._variablesView._enumVisible) { + this._openEnum(); + } + if (this._variablesView._nonEnumVisible) { + Services.tm.currentThread.dispatch({ run: this._openNonEnum }, 0); + } + this._isExpanding = false; + this._isExpanded = true; + + if (this.onexpand) { + this.onexpand(this); + } + }, + + /** + * Collapses the scope, hiding all the added details. + */ + collapse: function() { + if (!this._isExpanded || this._locked) { + return; + } + this._arrow.removeAttribute("open"); + this._enum.removeAttribute("open"); + this._nonenum.removeAttribute("open"); + this._isExpanded = false; + + if (this.oncollapse) { + this.oncollapse(this); + } + }, + + /** + * Toggles between the scope's collapsed and expanded state. + */ + toggle: function(e) { + if (e && e.button != 0) { + // Only allow left-click to trigger this event. + return; + } + this._wasToggled = true; + this.expanded ^= 1; + + // Make sure the scope and its contents are visibile. + for (let [, variable] of this._store) { + variable.header = true; + variable._matched = true; + } + if (this.ontoggle) { + this.ontoggle(this); + } + }, + + /** + * Shows the scope's title header. + */ + showHeader: function() { + if (this._isHeaderVisible || !this._nameString) { + return; + } + this._target.removeAttribute("non-header"); + this._isHeaderVisible = true; + }, + + /** + * Hides the scope's title header. + * This action will automatically expand the scope. + */ + hideHeader: function() { + if (!this._isHeaderVisible) { + return; + } + this.expand(); + this._target.setAttribute("non-header", ""); + this._isHeaderVisible = false; + }, + + /** + * Shows the scope's expand/collapse arrow. + */ + showArrow: function() { + if (this._isArrowVisible) { + return; + } + this._arrow.removeAttribute("invisible"); + this._isArrowVisible = true; + }, + + /** + * Hides the scope's expand/collapse arrow. + */ + hideArrow: function() { + if (!this._isArrowVisible) { + return; + } + this._arrow.setAttribute("invisible", ""); + this._isArrowVisible = false; + }, + + /** + * Gets the visibility state. + * @return boolean + */ + get visible() this._isContentVisible, + + /** + * Gets the expanded state. + * @return boolean + */ + get expanded() this._isExpanded, + + /** + * Gets the header visibility state. + * @return boolean + */ + get header() this._isHeaderVisible, + + /** + * Gets the twisty visibility state. + * @return boolean + */ + get twisty() this._isArrowVisible, + + /** + * Gets the expand lock state. + * @return boolean + */ + get locked() this._locked, + + /** + * Sets the visibility state. + * @param boolean aFlag + */ + set visible(aFlag) aFlag ? this.show() : this.hide(), + + /** + * Sets the expanded state. + * @param boolean aFlag + */ + set expanded(aFlag) aFlag ? this.expand() : this.collapse(), + + /** + * Sets the header visibility state. + * @param boolean aFlag + */ + set header(aFlag) aFlag ? this.showHeader() : this.hideHeader(), + + /** + * Sets the twisty visibility state. + * @param boolean aFlag + */ + set twisty(aFlag) aFlag ? this.showArrow() : this.hideArrow(), + + /** + * Sets the expand lock state. + * @param boolean aFlag + */ + set locked(aFlag) this._locked = aFlag, + + /** + * Specifies if this target node may be focused. + * @return boolean + */ + get focusable() { + // Check if this target node is actually visibile. + if (!this._nameString || + !this._isContentVisible || + !this._isHeaderVisible || + !this._isMatch) { + return false; + } + // Check if all parent objects are expanded. + let item = this; + + // Recurse while parent is a Scope, Variable, or Property + while ((item = item.ownerView) && item instanceof Scope) { + if (!item._isExpanded) { + return false; + } + } + return true; + }, + + /** + * Focus this scope. + */ + focus: function() { + this._variablesView._focusItem(this); + }, + + /** + * Adds an event listener for a certain event on this scope's title. + * @param string aName + * @param function aCallback + * @param boolean aCapture + */ + addEventListener: function(aName, aCallback, aCapture) { + this._title.addEventListener(aName, aCallback, aCapture); + }, + + /** + * Removes an event listener for a certain event on this scope's title. + * @param string aName + * @param function aCallback + * @param boolean aCapture + */ + removeEventListener: function(aName, aCallback, aCapture) { + this._title.removeEventListener(aName, aCallback, aCapture); + }, + + /** + * Gets the id associated with this item. + * @return string + */ + get id() this._idString, + + /** + * Gets the name associated with this item. + * @return string + */ + get name() this._nameString, + + /** + * Gets the displayed value for this item. + * @return string + */ + get displayValue() this._valueString, + + /** + * Gets the class names used for the displayed value. + * @return string + */ + get displayValueClassName() this._valueClassName, + + /** + * Gets the element associated with this item. + * @return nsIDOMNode + */ + get target() this._target, + + /** + * Initializes this scope's id, view and binds event listeners. + * + * @param string aName + * The scope's name. + * @param object aFlags [optional] + * Additional options or flags for this scope. + */ + _init: function(aName, aFlags) { + this._idString = generateId(this._nameString = aName); + this._displayScope(aName, "variables-view-scope", "devtools-toolbar"); + this._addEventListeners(); + this.parentNode.appendChild(this._target); + }, + + /** + * Creates the necessary nodes for this scope. + * + * @param string aName + * The scope's name. + * @param string aClassName + * A custom class name for this scope. + * @param string aTitleClassName [optional] + * A custom class name for this scope's title. + */ + _displayScope: function(aName, aClassName, aTitleClassName) { + let document = this.document; + + let element = this._target = document.createElement("vbox"); + element.id = this._idString; + element.className = aClassName; + + let arrow = this._arrow = document.createElement("hbox"); + arrow.className = "arrow"; + + let name = this._name = document.createElement("label"); + name.className = "plain name"; + name.setAttribute("value", aName); + + let title = this._title = document.createElement("hbox"); + title.className = "title " + (aTitleClassName || ""); + title.setAttribute("align", "center"); + + let enumerable = this._enum = document.createElement("vbox"); + let nonenum = this._nonenum = document.createElement("vbox"); + enumerable.className = "variables-view-element-details enum"; + nonenum.className = "variables-view-element-details nonenum"; + + title.appendChild(arrow); + title.appendChild(name); + + element.appendChild(title); + element.appendChild(enumerable); + element.appendChild(nonenum); + }, + + /** + * Adds the necessary event listeners for this scope. + */ + _addEventListeners: function() { + this._title.addEventListener("mousedown", this._onClick, false); + }, + + /** + * The click listener for this scope's title. + */ + _onClick: function(e) { + if (e.target == this._inputNode || + e.target == this._editNode || + e.target == this._deleteNode) { + return; + } + this.toggle(); + this.focus(); + }, + + /** + * Lazily appends a node to this scope's enumerable or non-enumerable + * container. Once a certain number of nodes have been batched, they + * will be appended. + * + * @param boolean aImmediateFlag + * Set to false if append calls should be dispatched synchronously + * on the current thread, to allow for a paint flush. + * @param boolean aEnumerableFlag + * Specifies if the node to append is enumerable or non-enumerable. + * @param nsIDOMNode aChild + * The child node to append. + */ + _lazyAppend: function(aImmediateFlag, aEnumerableFlag, aChild) { + // Append immediately, don't stage items and don't allow for a paint flush. + if (aImmediateFlag || !this._variablesView.lazyAppend) { + if (aEnumerableFlag) { + this._enum.appendChild(aChild); + } else { + this._nonenum.appendChild(aChild); + } + return; + } + + let window = this.window; + let batchItems = this._batchItems; + + window.clearTimeout(this._batchTimeout); + batchItems.push({ enumerableFlag: aEnumerableFlag, child: aChild }); + + // If a certain number of nodes have been batched, append all the + // staged items now. + if (batchItems.length > LAZY_APPEND_BATCH) { + // Allow for a paint flush. + Services.tm.currentThread.dispatch({ run: this._batchAppend }, 1); + return; + } + // Postpone appending the staged items for later, to allow batching + // more nodes. + this._batchTimeout = window.setTimeout(this._batchAppend, LAZY_APPEND_DELAY); + }, + + /** + * Appends all the batched nodes to this scope's enumerable and non-enumerable + * containers. + */ + _batchAppend: function() { + let document = this.document; + let batchItems = this._batchItems; + + // Create two document fragments, one for enumerable nodes, and one + // for non-enumerable nodes. + let frags = [document.createDocumentFragment(), document.createDocumentFragment()]; + + for (let item of batchItems) { + frags[~~item.enumerableFlag].appendChild(item.child); + } + batchItems.length = 0; + this._enum.appendChild(frags[1]); + this._nonenum.appendChild(frags[0]); + }, + + /** + * Starts spinning a throbber in this scope's title. + */ + _startThrobber: function() { + if (this._throbber) { + this._throbber.hidden = false; + return; + } + let throbber = this._throbber = this.document.createElement("hbox"); + throbber.className = "variables-view-throbber"; + this._title.appendChild(throbber); + }, + + /** + * Stops spinning the throbber in this scope's title. + */ + _stopThrobber: function() { + if (!this._throbber) { + return; + } + this._throbber.hidden = true; + }, + + /** + * Opens the enumerable items container. + */ + _openEnum: function() { + this._arrow.setAttribute("open", ""); + this._enum.setAttribute("open", ""); + this._stopThrobber(); + }, + + /** + * Opens the non-enumerable items container. + */ + _openNonEnum: function() { + this._nonenum.setAttribute("open", ""); + this._stopThrobber(); + }, + + /** + * Specifies if enumerable properties and variables should be displayed. + * @param boolean aFlag + */ + set _enumVisible(aFlag) { + for (let [, variable] of this._store) { + variable._enumVisible = aFlag; + + if (!this._isExpanded) { + continue; + } + if (aFlag) { + this._enum.setAttribute("open", ""); + } else { + this._enum.removeAttribute("open"); + } + } + }, + + /** + * Specifies if non-enumerable properties and variables should be displayed. + * @param boolean aFlag + */ + set _nonEnumVisible(aFlag) { + for (let [, variable] of this._store) { + variable._nonEnumVisible = aFlag; + + if (!this._isExpanded) { + continue; + } + if (aFlag) { + this._nonenum.setAttribute("open", ""); + } else { + this._nonenum.removeAttribute("open"); + } + } + }, + + /** + * Performs a case insensitive search for variables or properties matching + * the query, and hides non-matched items. + * + * @param string aLowerCaseQuery + * The lowercased name of the variable or property to search for. + */ + _performSearch: function(aLowerCaseQuery) { + for (let [, variable] of this._store) { + let currentObject = variable; + let lowerCaseName = variable._nameString.toLowerCase(); + let lowerCaseValue = variable._valueString.toLowerCase(); + + // Non-matched variables or properties require a corresponding attribute. + if (!lowerCaseName.contains(aLowerCaseQuery) && + !lowerCaseValue.contains(aLowerCaseQuery)) { + variable._matched = false; + } + // Variable or property is matched. + else { + variable._matched = true; + + // If the variable was ever expanded, there's a possibility it may + // contain some matched properties, so make sure they're visible + // ("expand downwards"). + + if (variable._wasToggled && aLowerCaseQuery) { + variable.expand(); + } + if (variable._isExpanded && !aLowerCaseQuery) { + variable._wasToggled = true; + } + + // If the variable is contained in another Scope, Variable, or Property, + // the parent may not be a match, thus hidden. It should be visible + // ("expand upwards"). + while ((variable = variable.ownerView) && /* Parent object exists. */ + variable instanceof Scope) { + + // Show and expand the parent, as it is certainly accessible. + variable._matched = true; + aLowerCaseQuery && variable.expand(); + } + } + + // Proceed with the search recursively inside this variable or property. + if (currentObject._wasToggled || + currentObject.getter || + currentObject.setter) { + currentObject._performSearch(aLowerCaseQuery); + } + } + }, + + /** + * Sets if this object instance is a matched or non-matched item. + * @param boolean aStatus + */ + set _matched(aStatus) { + if (this._isMatch == aStatus) { + return; + } + if (aStatus) { + this._isMatch = true; + this.target.removeAttribute("non-match"); + } else { + this._isMatch = false; + this.target.setAttribute("non-match", ""); + } + }, + + /** + * Gets the first search results match in this scope. + * @return Variable | Property + */ + get _firstMatch() { + for (let [, variable] of this._store) { + let match; + if (variable._isMatch) { + match = variable; + } else { + match = variable._firstMatch; + } + if (match) { + return match; + } + } + return null; + }, + + /** + * Find the first item in the tree of visible items in this item that matches + * the predicate. Searches in visual order (the order seen by the user). + * Tests itself, then descends into first the enumerable children and then + * the non-enumerable children (since they are presented in separate groups). + * + * @param function aPredicate + * A function that returns true when a match is found. + * @return Scope | Variable | Property + * The first visible scope, variable or property, or null if nothing + * is found. + */ + _findInVisibleItems: function(aPredicate) { + if (aPredicate(this)) { + return this; + } + + if (this._isExpanded) { + if (this._variablesView._enumVisible) { + for (let item of this._enumItems) { + let result = item._findInVisibleItems(aPredicate); + if (result) { + return result; + } + } + } + + if (this._variablesView._nonEnumVisible) { + for (let item of this._nonEnumItems) { + let result = item._findInVisibleItems(aPredicate); + if (result) { + return result; + } + } + } + } + + return null; + }, + + /** + * Find the last item in the tree of visible items in this item that matches + * the predicate. Searches in reverse visual order (opposite of the order + * seen by the user). Descends into first the non-enumerable children, then + * the enumerable children (since they are presented in separate groups), and + * finally tests itself. + * + * @param function aPredicate + * A function that returns true when a match is found. + * @return Scope | Variable | Property + * The last visible scope, variable or property, or null if nothing + * is found. + */ + _findInVisibleItemsReverse: function(aPredicate) { + if (this._isExpanded) { + if (this._variablesView._nonEnumVisible) { + for (let i = this._nonEnumItems.length - 1; i >= 0; i--) { + let item = this._nonEnumItems[i]; + let result = item._findInVisibleItemsReverse(aPredicate); + if (result) { + return result; + } + } + } + + if (this._variablesView._enumVisible) { + for (let i = this._enumItems.length - 1; i >= 0; i--) { + let item = this._enumItems[i]; + let result = item._findInVisibleItemsReverse(aPredicate); + if (result) { + return result; + } + } + } + } + + if (aPredicate(this)) { + return this; + } + + return null; + }, + + /** + * Gets top level variables view instance. + * @return VariablesView + */ + get _variablesView() this._topView || (this._topView = (function(self) { + let parentView = self.ownerView; + let topView; + + while (topView = parentView.ownerView) { + parentView = topView; + } + return parentView; + })(this)), + + /** + * Gets the parent node holding this scope. + * @return nsIDOMNode + */ + get parentNode() this.ownerView._list, + + /** + * Gets the owner document holding this scope. + * @return nsIHTMLDocument + */ + get document() this._document || (this._document = this.ownerView.document), + + /** + * Gets the default window holding this scope. + * @return nsIDOMWindow + */ + get window() this._window || (this._window = this.ownerView.window), + + _topView: null, + _document: null, + _window: null, + + ownerView: null, + eval: null, + switch: null, + delete: null, + editableValueTooltip: "", + editableNameTooltip: "", + editButtonTooltip: "", + deleteButtonTooltip: "", + preventDescriptorModifiers: false, + contextMenuId: "", + separatorStr: "", + + _store: null, + _enumItems: null, + _nonEnumItems: null, + _fetched: false, + _retrieved: false, + _committed: false, + _batchItems: null, + _batchTimeout: null, + _locked: false, + _isExpanding: false, + _isExpanded: false, + _wasToggled: false, + _isContentVisible: true, + _isHeaderVisible: true, + _isArrowVisible: true, + _isMatch: true, + _idString: "", + _nameString: "", + _target: null, + _arrow: null, + _name: null, + _title: null, + _enum: null, + _nonenum: null, + _throbber: null +}; + +/** + * A Variable is a Scope holding Property instances. + * Iterable via "for (let [name, property] in instance) { }". + * + * @param Scope aScope + * The scope to contain this variable. + * @param string aName + * The variable's name. + * @param object aDescriptor + * The variable's descriptor. + */ +function Variable(aScope, aName, aDescriptor) { + this._setTooltips = this._setTooltips.bind(this); + this._activateNameInput = this._activateNameInput.bind(this); + this._activateValueInput = this._activateValueInput.bind(this); + + // Treat safe getter descriptors as descriptors with a value. + if ("getterValue" in aDescriptor) { + aDescriptor.value = aDescriptor.getterValue; + delete aDescriptor.get; + delete aDescriptor.set; + } + + Scope.call(this, aScope, aName, this._initialDescriptor = aDescriptor); + this.setGrip(aDescriptor.value); + this._symbolicName = aName; + this._absoluteName = aScope.name + "[\"" + aName + "\"]"; +} + +Variable.prototype = Heritage.extend(Scope.prototype, { + /** + * Whether this Scope should be prefetched when it is remoted. + */ + get shouldPrefetch(){ + return this.name == "window" || this.name == "this"; + }, + + /** + * Create a new Property that is a child of Variable. + * + * @param string aName + * The name of the new Property. + * @param object aDescriptor + * The property's descriptor. + * @return Property + * The newly created child Property. + */ + _createChild: function(aName, aDescriptor) { + return new Property(this, aName, aDescriptor); + }, + + /** + * Populates this variable to contain all the properties of an object. + * + * @param object aObject + * The raw object you want to display. + * @param object aOptions [optional] + * Additional options for adding the properties. Supported options: + * - sorted: true to sort all the properties before adding them + * - expanded: true to expand all the properties after adding them + */ + populate: function(aObject, aOptions = {}) { + // Retrieve the properties only once. + if (this._fetched) { + return; + } + this._fetched = true; + + let propertyNames = Object.getOwnPropertyNames(aObject); + let prototype = Object.getPrototypeOf(aObject); + + // Sort all of the properties before adding them, if preferred. + if (aOptions.sorted) { + propertyNames.sort(); + } + // Add all the variable properties. + for (let name of propertyNames) { + let descriptor = Object.getOwnPropertyDescriptor(aObject, name); + if (descriptor.get || descriptor.set) { + let prop = this._addRawNonValueProperty(name, descriptor); + if (aOptions.expanded) { + prop.expanded = true; + } + } else { + let prop = this._addRawValueProperty(name, descriptor, aObject[name]); + if (aOptions.expanded) { + prop.expanded = true; + } + } + } + // Add the variable's __proto__. + if (prototype) { + this._addRawValueProperty("__proto__", {}, prototype); + } + }, + + /** + * Populates a specific variable or property instance to contain all the + * properties of an object + * + * @param Variable | Property aVar + * The target variable to populate. + * @param object aObject [optional] + * The raw object you want to display. If unspecified, the object is + * assumed to be defined in a _sourceValue property on the target. + */ + _populateTarget: function(aVar, aObject = aVar._sourceValue) { + aVar.populate(aObject); + }, + + /** + * Adds a property for this variable based on a raw value descriptor. + * + * @param string aName + * The property's name. + * @param object aDescriptor + * Specifies the exact property descriptor as returned by a call to + * Object.getOwnPropertyDescriptor. + * @param object aValue + * The raw property value you want to display. + * @return Property + * The newly added property instance. + */ + _addRawValueProperty: function(aName, aDescriptor, aValue) { + let descriptor = Object.create(aDescriptor); + descriptor.value = VariablesView.getGrip(aValue); + + let propertyItem = this.addItem(aName, descriptor); + propertyItem._sourceValue = aValue; + + // Add an 'onexpand' callback for the property, lazily handling + // the addition of new child properties. + if (!VariablesView.isPrimitive(descriptor)) { + propertyItem.onexpand = this._populateTarget; + } + return propertyItem; + }, + + /** + * Adds a property for this variable based on a getter/setter descriptor. + * + * @param string aName + * The property's name. + * @param object aDescriptor + * Specifies the exact property descriptor as returned by a call to + * Object.getOwnPropertyDescriptor. + * @return Property + * The newly added property instance. + */ + _addRawNonValueProperty: function(aName, aDescriptor) { + let descriptor = Object.create(aDescriptor); + descriptor.get = VariablesView.getGrip(aDescriptor.get); + descriptor.set = VariablesView.getGrip(aDescriptor.set); + + return this.addItem(aName, descriptor); + }, + + /** + * Gets this variable's path to the topmost scope. + * For example, a symbolic name may look like "arguments['0']['foo']['bar']". + * @return string + */ + get symbolicName() this._symbolicName, + + /** + * Returns this variable's value from the descriptor if available. + * @return any + */ + get value() this._initialDescriptor.value, + + /** + * Returns this variable's getter from the descriptor if available. + * @return object + */ + get getter() this._initialDescriptor.get, + + /** + * Returns this variable's getter from the descriptor if available. + * @return object + */ + get setter() this._initialDescriptor.set, + + /** + * Sets the specific grip for this variable (applies the text content and + * class name to the value label). + * + * The grip should contain the value or the type & class, as defined in the + * remote debugger protocol. For convenience, undefined and null are + * both considered types. + * + * @param any aGrip + * Specifies the value and/or type & class of the variable. + * e.g. - 42 + * - true + * - "nasu" + * - { type: "undefined" } + * - { type: "null" } + * - { type: "object", class: "Object" } + */ + setGrip: function(aGrip) { + // Don't allow displaying grip information if there's no name available. + if (!this._nameString || aGrip === undefined || aGrip === null) { + return; + } + // Getters and setters should display grip information in sub-properties. + if (!this._isUndefined && (this.getter || this.setter)) { + this._valueLabel.setAttribute("value", ""); + return; + } + + // Make sure the value is escaped unicode if it's a string. + if (typeof aGrip == "string") { + aGrip = NetworkHelper.convertToUnicode(unescape(aGrip)); + } + + let prevGrip = this._valueGrip; + if (prevGrip) { + this._valueLabel.classList.remove(VariablesView.getClass(prevGrip)); + } + this._valueGrip = aGrip; + this._valueString = VariablesView.getString(aGrip); + this._valueClassName = VariablesView.getClass(aGrip); + + this._valueLabel.classList.add(this._valueClassName); + this._valueLabel.setAttribute("value", this._valueString); + }, + + /** + * Initializes this variable's id, view and binds event listeners. + * + * @param string aName + * The variable's name. + * @param object aDescriptor + * The variable's descriptor. + */ + _init: function(aName, aDescriptor) { + this._idString = generateId(this._nameString = aName); + this._displayScope(aName, "variables-view-variable variable-or-property"); + + // Don't allow displaying variable information there's no name available. + if (this._nameString) { + this._displayVariable(); + this._customizeVariable(); + this._prepareTooltips(); + this._setAttributes(); + this._addEventListeners(); + } + + this._onInit(this.ownerView._store.size < LAZY_APPEND_BATCH); + }, + + /** + * Called when this variable has finished initializing, and is ready to + * be attached to the owner view. + * + * @param boolean aImmediateFlag + * @see Scope.prototype._lazyAppend + */ + _onInit: function(aImmediateFlag) { + if (this._initialDescriptor.enumerable || + this._nameString == "this" || + this._nameString == "<return>" || + this._nameString == "<exception>") { + this.ownerView._lazyAppend(aImmediateFlag, true, this._target); + this.ownerView._enumItems.push(this); + } else { + this.ownerView._lazyAppend(aImmediateFlag, false, this._target); + this.ownerView._nonEnumItems.push(this); + } + }, + + /** + * Creates the necessary nodes for this variable. + */ + _displayVariable: function() { + let document = this.document; + let descriptor = this._initialDescriptor; + + let separatorLabel = this._separatorLabel = document.createElement("label"); + separatorLabel.className = "plain separator"; + separatorLabel.setAttribute("value", this.ownerView.separatorStr); + + let valueLabel = this._valueLabel = document.createElement("label"); + valueLabel.className = "plain value"; + valueLabel.setAttribute("crop", "center"); + valueLabel.setAttribute('flex', "1"); + + this._title.appendChild(separatorLabel); + this._title.appendChild(valueLabel); + + let isPrimitive = this._isPrimitive = VariablesView.isPrimitive(descriptor); + let isUndefined = this._isUndefined = VariablesView.isUndefined(descriptor); + + if (isPrimitive || isUndefined) { + this.hideArrow(); + } + if (!isUndefined && (descriptor.get || descriptor.set)) { + separatorLabel.hidden = true; + valueLabel.hidden = true; + + // Changing getter/setter names is never allowed. + this.switch = null; + + // Getter/setter properties require special handling when it comes to + // evaluation and deletion. + if (this.ownerView.eval) { + this.delete = VariablesView.getterOrSetterDeleteCallback; + this.evaluationMacro = VariablesView.overrideValueEvalMacro; + } + // Deleting getters and setters individually is not allowed if no + // evaluation method is provided. + else { + this.delete = null; + this.evaluationMacro = null; + } + + let getter = this.addItem("get", { value: descriptor.get }); + let setter = this.addItem("set", { value: descriptor.set }); + getter.evaluationMacro = VariablesView.getterOrSetterEvalMacro; + setter.evaluationMacro = VariablesView.getterOrSetterEvalMacro; + + getter.hideArrow(); + setter.hideArrow(); + this.expand(); + } + }, + + /** + * Adds specific nodes for this variable based on custom flags. + */ + _customizeVariable: function() { + let ownerView = this.ownerView; + let descriptor = this._initialDescriptor; + + if (ownerView.eval) { + if (!this._isUndefined && (this.getter || this.setter)) { + let editNode = this._editNode = this.document.createElement("toolbarbutton"); + editNode.className = "plain variables-view-edit"; + editNode.addEventListener("mousedown", this._onEdit.bind(this), false); + this._title.appendChild(editNode); + } + } + if (ownerView.delete) { + if (!this._isUndefined || !(ownerView.getter && ownerView.setter)) { + let deleteNode = this._deleteNode = this.document.createElement("toolbarbutton"); + deleteNode.className = "plain variables-view-delete"; + deleteNode.setAttribute("ordinal", 2); + deleteNode.addEventListener("click", this._onDelete.bind(this), false); + this._title.appendChild(deleteNode); + } + } + if (ownerView.contextMenuId) { + this._title.setAttribute("context", ownerView.contextMenuId); + } + + if (ownerView.preventDescriptorModifiers) { + return; + } + + if (!descriptor.writable && !ownerView.getter && !ownerView.setter) { + let nonWritableIcon = this.document.createElement("hbox"); + nonWritableIcon.className = "variable-or-property-non-writable-icon"; + this._title.appendChild(nonWritableIcon); + } + if (descriptor.value && typeof descriptor.value == "object") { + if (descriptor.value.frozen) { + let frozenLabel = this.document.createElement("label"); + frozenLabel.className = "plain variable-or-property-frozen-label"; + frozenLabel.setAttribute("value", "F"); + this._title.appendChild(frozenLabel); + } + if (descriptor.value.sealed) { + let sealedLabel = this.document.createElement("label"); + sealedLabel.className = "plain variable-or-property-sealed-label"; + sealedLabel.setAttribute("value", "S"); + this._title.appendChild(sealedLabel); + } + if (!descriptor.value.extensible) { + let nonExtensibleLabel = this.document.createElement("label"); + nonExtensibleLabel.className = "plain variable-or-property-non-extensible-label"; + nonExtensibleLabel.setAttribute("value", "N"); + this._title.appendChild(nonExtensibleLabel); + } + } + }, + + /** + * Prepares all tooltips for this variable. + */ + _prepareTooltips: function() { + this._target.addEventListener("mouseover", this._setTooltips, false); + }, + + /** + * Sets all tooltips for this variable. + */ + _setTooltips: function() { + this._target.removeEventListener("mouseover", this._setTooltips, false); + + let ownerView = this.ownerView; + if (ownerView.preventDescriptorModifiers) { + return; + } + + let tooltip = this.document.createElement("tooltip"); + tooltip.id = "tooltip-" + this._idString; + tooltip.setAttribute("orient", "horizontal"); + + let labels = [ + "configurable", "enumerable", "writable", + "frozen", "sealed", "extensible", "WebIDL"]; + + for (let label of labels) { + let labelElement = this.document.createElement("label"); + labelElement.setAttribute("value", label); + tooltip.appendChild(labelElement); + } + + this._target.appendChild(tooltip); + this._target.setAttribute("tooltip", tooltip.id); + + if (this._editNode && ownerView.eval) { + this._editNode.setAttribute("tooltiptext", ownerView.editButtonTooltip); + } + if (this._valueLabel && ownerView.eval) { + this._valueLabel.setAttribute("tooltiptext", ownerView.editableValueTooltip); + } + if (this._name && ownerView.switch) { + this._name.setAttribute("tooltiptext", ownerView.editableNameTooltip); + } + if (this._deleteNode && ownerView.delete) { + this._deleteNode.setAttribute("tooltiptext", ownerView.deleteButtonTooltip); + } + }, + + /** + * Sets a variable's configurable, enumerable and writable attributes, + * and specifies if it's a 'this', '<exception>' or '__proto__' reference. + */ + _setAttributes: function() { + let ownerView = this.ownerView; + if (ownerView.preventDescriptorModifiers) { + return; + } + + let descriptor = this._initialDescriptor; + let target = this._target; + let name = this._nameString; + + if (ownerView.eval) { + target.setAttribute("editable", ""); + } + + if (!descriptor.configurable) { + target.setAttribute("non-configurable", ""); + } + if (!descriptor.enumerable) { + target.setAttribute("non-enumerable", ""); + } + if (!descriptor.writable && !ownerView.getter && !ownerView.setter) { + target.setAttribute("non-writable", ""); + } + + if (descriptor.value && typeof descriptor.value == "object") { + if (descriptor.value.frozen) { + target.setAttribute("frozen", ""); + } + if (descriptor.value.sealed) { + target.setAttribute("sealed", ""); + } + if (!descriptor.value.extensible) { + target.setAttribute("non-extensible", ""); + } + } + + if (descriptor && "getterValue" in descriptor) { + target.setAttribute("safe-getter", ""); + } + if (name == "this") { + target.setAttribute("self", ""); + } + + else if (name == "<exception>") { + target.setAttribute("exception", ""); + } + else if (name == "<return>") { + target.setAttribute("return", ""); + } + else if (name == "__proto__") { + target.setAttribute("proto", ""); + } + }, + + /** + * Adds the necessary event listeners for this variable. + */ + _addEventListeners: function() { + this._name.addEventListener("dblclick", this._activateNameInput, false); + this._valueLabel.addEventListener("mousedown", this._activateValueInput, false); + this._title.addEventListener("mousedown", this._onClick, false); + }, + + /** + * Creates a textbox node in place of a label. + * + * @param nsIDOMNode aLabel + * The label to be replaced with a textbox. + * @param string aClassName + * The class to be applied to the textbox. + * @param object aCallbacks + * An object containing the onKeypress and onBlur callbacks. + */ + _activateInput: function(aLabel, aClassName, aCallbacks) { + let initialString = aLabel.getAttribute("value"); + + // Create a texbox input element which will be shown in the current + // element's specified label location. + let input = this.document.createElement("textbox"); + input.className = "plain " + aClassName; + input.setAttribute("value", initialString); + input.setAttribute("flex", "1"); + + // Replace the specified label with a textbox input element. + aLabel.parentNode.replaceChild(input, aLabel); + this._variablesView._boxObject.ensureElementIsVisible(input); + input.select(); + + // When the value is a string (displayed as "value"), then we probably want + // to change it to another string in the textbox, so to avoid typing the "" + // again, tackle with the selection bounds just a bit. + if (aLabel.getAttribute("value").match(/^".+"$/)) { + input.selectionEnd--; + input.selectionStart++; + } + + input.addEventListener("keypress", aCallbacks.onKeypress, false); + input.addEventListener("blur", aCallbacks.onBlur, false); + + this._prevExpandable = this.twisty; + this._prevExpanded = this.expanded; + this.collapse(); + this.hideArrow(); + this._locked = true; + + this._inputNode = input; + this._stopThrobber(); + }, + + /** + * Removes the textbox node in place of a label. + * + * @param nsIDOMNode aLabel + * The label which was replaced with a textbox. + * @param object aCallbacks + * An object containing the onKeypress and onBlur callbacks. + */ + _deactivateInput: function(aLabel, aInput, aCallbacks) { + aInput.parentNode.replaceChild(aLabel, aInput); + this._variablesView._boxObject.scrollBy(-this._target.clientWidth, 0); + + aInput.removeEventListener("keypress", aCallbacks.onKeypress, false); + aInput.removeEventListener("blur", aCallbacks.onBlur, false); + + this._locked = false; + this.twisty = this._prevExpandable; + this.expanded = this._prevExpanded; + + this._inputNode = null; + this._stopThrobber(); + }, + + /** + * Makes this variable's name editable. + */ + _activateNameInput: function(e) { + if (e && e.button != 0) { + // Only allow left-click to trigger this event. + return; + } + if (!this.ownerView.switch) { + return; + } + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + this._onNameInputKeyPress = this._onNameInputKeyPress.bind(this); + this._deactivateNameInput = this._deactivateNameInput.bind(this); + + this._activateInput(this._name, "element-name-input", { + onKeypress: this._onNameInputKeyPress, + onBlur: this._deactivateNameInput + }); + this._separatorLabel.hidden = true; + this._valueLabel.hidden = true; + }, + + /** + * Deactivates this variable's editable name mode. + */ + _deactivateNameInput: function(e) { + this._deactivateInput(this._name, e.target, { + onKeypress: this._onNameInputKeyPress, + onBlur: this._deactivateNameInput + }); + this._separatorLabel.hidden = false; + this._valueLabel.hidden = false; + }, + + /** + * Makes this variable's value editable. + */ + _activateValueInput: function(e) { + if (e && e.button != 0) { + // Only allow left-click to trigger this event. + return; + } + if (!this.ownerView.eval) { + return; + } + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + this._onValueInputKeyPress = this._onValueInputKeyPress.bind(this); + this._deactivateValueInput = this._deactivateValueInput.bind(this); + + this._activateInput(this._valueLabel, "element-value-input", { + onKeypress: this._onValueInputKeyPress, + onBlur: this._deactivateValueInput + }); + }, + + /** + * Deactivates this variable's editable value mode. + */ + _deactivateValueInput: function(e) { + this._deactivateInput(this._valueLabel, e.target, { + onKeypress: this._onValueInputKeyPress, + onBlur: this._deactivateValueInput + }); + }, + + /** + * Disables this variable prior to a new name switch or value evaluation. + */ + _disable: function() { + this.hideArrow(); + this._separatorLabel.hidden = true; + this._valueLabel.hidden = true; + this._enum.hidden = true; + this._nonenum.hidden = true; + + if (this._editNode) { + this._editNode.hidden = true; + } + if (this._deleteNode) { + this._deleteNode.hidden = true; + } + }, + + /** + * Deactivates this variable's editable mode and callbacks the new name. + */ + _saveNameInput: function(e) { + let input = e.target; + let initialString = this._name.getAttribute("value"); + let currentString = input.value.trim(); + this._deactivateNameInput(e); + + if (initialString != currentString) { + if (!this._variablesView.preventDisableOnChage) { + this._disable(); + this._name.value = currentString; + } + this.ownerView.switch(this, currentString); + } + }, + + /** + * Deactivates this variable's editable mode and evaluates the new value. + */ + _saveValueInput: function(e) { + let input = e.target; + let initialString = this._valueLabel.getAttribute("value"); + let currentString = input.value.trim(); + this._deactivateValueInput(e); + + if (initialString != currentString) { + if (!this._variablesView.preventDisableOnChage) { + this._disable(); + } + this.ownerView.eval(this.evaluationMacro(this, currentString.trim())); + } + }, + + /** + * The current macro used to generate the string evaluated when performing + * a variable or property value change. + */ + evaluationMacro: VariablesView.simpleValueEvalMacro, + + /** + * The key press listener for this variable's editable name textbox. + */ + _onNameInputKeyPress: function(e) { + e.stopPropagation(); + + switch(e.keyCode) { + case e.DOM_VK_RETURN: + case e.DOM_VK_ENTER: + this._saveNameInput(e); + this.focus(); + return; + case e.DOM_VK_ESCAPE: + this._deactivateNameInput(e); + this.focus(); + return; + } + }, + + /** + * The key press listener for this variable's editable value textbox. + */ + _onValueInputKeyPress: function(e) { + e.stopPropagation(); + + switch(e.keyCode) { + case e.DOM_VK_RETURN: + case e.DOM_VK_ENTER: + this._saveValueInput(e); + this.focus(); + return; + case e.DOM_VK_ESCAPE: + this._deactivateValueInput(e); + this.focus(); + return; + } + }, + + /** + * The click listener for the edit button. + */ + _onEdit: function(e) { + e.preventDefault(); + e.stopPropagation(); + this._activateValueInput(); + }, + + /** + * The click listener for the delete button. + */ + _onDelete: function(e) { + e.preventDefault(); + e.stopPropagation(); + + if (this.ownerView.delete) { + if (!this.ownerView.delete(this)) { + this.hide(); + } + } + }, + + _symbolicName: "", + _absoluteName: "", + _initialDescriptor: null, + _isPrimitive: false, + _isUndefined: false, + _separatorLabel: null, + _valueLabel: null, + _inputNode: null, + _editNode: null, + _deleteNode: null, + _tooltip: null, + _valueGrip: null, + _valueString: "", + _valueClassName: "", + _prevExpandable: false, + _prevExpanded: false +}); + +/** + * A Property is a Variable holding additional child Property instances. + * Iterable via "for (let [name, property] in instance) { }". + * + * @param Variable aVar + * The variable to contain this property. + * @param string aName + * The property's name. + * @param object aDescriptor + * The property's descriptor. + */ +function Property(aVar, aName, aDescriptor) { + Variable.call(this, aVar, aName, aDescriptor); + this._symbolicName = aVar._symbolicName + "[\"" + aName + "\"]"; + this._absoluteName = aVar._absoluteName + "[\"" + aName + "\"]"; +} + +Property.prototype = Heritage.extend(Variable.prototype, { + /** + * Initializes this property's id, view and binds event listeners. + * + * @param string aName + * The property's name. + * @param object aDescriptor + * The property's descriptor. + */ + _init: function(aName, aDescriptor) { + this._idString = generateId(this._nameString = aName); + this._displayScope(aName, "variables-view-property variable-or-property"); + + // Don't allow displaying property information there's no name available. + if (this._nameString) { + this._displayVariable(); + this._customizeVariable(); + this._prepareTooltips(); + this._setAttributes(); + this._addEventListeners(); + } + + this._onInit(this.ownerView._store.size < LAZY_APPEND_BATCH); + }, + + /** + * Called when this property has finished initializing, and is ready to + * be attached to the owner view. + * + * @param boolean aImmediateFlag + * @see Scope.prototype._lazyAppend + */ + _onInit: function(aImmediateFlag) { + if (this._initialDescriptor.enumerable) { + this.ownerView._lazyAppend(aImmediateFlag, true, this._target); + this.ownerView._enumItems.push(this); + } else { + this.ownerView._lazyAppend(aImmediateFlag, false, this._target); + this.ownerView._nonEnumItems.push(this); + } + } +}); + +/** + * A generator-iterator over the VariablesView, Scopes, Variables and Properties. + */ +VariablesView.prototype.__iterator__ = +Scope.prototype.__iterator__ = +Variable.prototype.__iterator__ = +Property.prototype.__iterator__ = function() { + for (let item of this._store) { + yield item; + } +}; + +/** + * Forget everything recorded about added scopes, variables or properties. + * @see VariablesView.createHierarchy + */ +VariablesView.prototype.clearHierarchy = function() { + this._prevHierarchy.clear(); + this._currHierarchy.clear(); +}; + +/** + * Start recording a hierarchy of any added scopes, variables or properties. + * @see VariablesView.commitHierarchy + */ +VariablesView.prototype.createHierarchy = function() { + this._prevHierarchy = this._currHierarchy; + this._currHierarchy = new Map(); // Don't clear, this is just simple swapping. +}; + +/** + * Briefly flash the variables that changed between the previous and current + * scope/variable/property hierarchies and reopen previously expanded nodes. + */ +VariablesView.prototype.commitHierarchy = function() { + let prevHierarchy = this._prevHierarchy; + let currHierarchy = this._currHierarchy; + + for (let [absoluteName, currVariable] of currHierarchy) { + // Ignore variables which were already commmitted. + if (currVariable._committed) { + continue; + } + // Avoid performing expensive operations. + if (this.commitHierarchyIgnoredItems[currVariable._nameString]) { + continue; + } + + // Try to get the previous instance of the inspected variable to + // determine the difference in state. + let prevVariable = prevHierarchy.get(absoluteName); + let expanded = false; + let changed = false; + + // If the inspected variable existed in a previous hierarchy, check if + // the displayed value (a representation of the grip) has changed and if + // it was previously expanded. + if (prevVariable) { + expanded = prevVariable._isExpanded; + + // Only analyze Variables and Properties for displayed value changes. + if (currVariable instanceof Variable) { + changed = prevVariable._valueString != currVariable._valueString; + } + } + + // Make sure this variable is not handled in ulteror commits for the + // same hierarchy. + currVariable._committed = true; + + // Re-expand the variable if not previously collapsed. + if (expanded) { + currVariable._wasToggled = prevVariable._wasToggled; + currVariable.expand(); + } + // This variable was either not changed or removed, no need to continue. + if (!changed) { + continue; + } + + // Apply an attribute determining the flash type and duration. + // Dispatch this action after all the nodes have been drawn, so that + // the transition efects can take place. + this.window.setTimeout(function(aTarget) { + aTarget.addEventListener("transitionend", function onEvent() { + aTarget.removeEventListener("transitionend", onEvent, false); + aTarget.removeAttribute("changed"); + }, false); + aTarget.setAttribute("changed", ""); + }.bind(this, currVariable.target), this.lazyEmptyDelay + 1); + } +}; + +// Some variables are likely to contain a very large number of properties. +// It would be a bad idea to re-expand them or perform expensive operations. +VariablesView.prototype.commitHierarchyIgnoredItems = Object.create(null, { + "window": { value: true } +}); + +/** + * Returns true if the descriptor represents an undefined, null or + * primitive value. + * + * @param object aDescriptor + * The variable's descriptor. + */ +VariablesView.isPrimitive = function(aDescriptor) { + // For accessor property descriptors, the getter and setter need to be + // contained in 'get' and 'set' properties. + let getter = aDescriptor.get; + let setter = aDescriptor.set; + if (getter || setter) { + return false; + } + + // As described in the remote debugger protocol, the value grip + // must be contained in a 'value' property. + let grip = aDescriptor.value; + if (typeof grip != "object") { + return true; + } + + // For convenience, undefined, null and long strings are considered types. + let type = grip.type; + if (type == "undefined" || type == "null" || type == "longString") { + return true; + } + + return false; +}; + +/** + * Returns true if the descriptor represents an undefined value. + * + * @param object aDescriptor + * The variable's descriptor. + */ +VariablesView.isUndefined = function(aDescriptor) { + // For accessor property descriptors, the getter and setter need to be + // contained in 'get' and 'set' properties. + let getter = aDescriptor.get; + let setter = aDescriptor.set; + if (typeof getter == "object" && getter.type == "undefined" && + typeof setter == "object" && setter.type == "undefined") { + return true; + } + + // As described in the remote debugger protocol, the value grip + // must be contained in a 'value' property. + let grip = aDescriptor.value; + if (typeof grip == "object" && grip.type == "undefined") { + return true; + } + + return false; +}; + +/** + * Returns true if the descriptor represents a falsy value. + * + * @param object aDescriptor + * The variable's descriptor. + */ +VariablesView.isFalsy = function(aDescriptor) { + // As described in the remote debugger protocol, the value grip + // must be contained in a 'value' property. + let grip = aDescriptor.value; + if (typeof grip != "object") { + return !grip; + } + + // For convenience, undefined and null are both considered types. + let type = grip.type; + if (type == "undefined" || type == "null") { + return true; + } + + return false; +}; + +/** + * Returns true if the value is an instance of Variable or Property. + * + * @param any aValue + * The value to test. + */ +VariablesView.isVariable = function(aValue) { + return aValue instanceof Variable; +}; + +/** + * Returns a standard grip for a value. + * + * @param any aValue + * The raw value to get a grip for. + * @return any + * The value's grip. + */ +VariablesView.getGrip = function(aValue) { + if (aValue === undefined) { + return { type: "undefined" }; + } + if (aValue === null) { + return { type: "null" }; + } + if (typeof aValue == "object" || typeof aValue == "function") { + return { type: "object", class: WebConsoleUtils.getObjectClassName(aValue) }; + } + return aValue; +}; + +/** + * Returns a custom formatted property string for a grip. + * + * @param any aGrip + * @see Variable.setGrip + * @param boolean aConciseFlag + * Return a concisely formatted property string. + * @return string + * The formatted property string. + */ +VariablesView.getString = function(aGrip, aConciseFlag) { + if (aGrip && typeof aGrip == "object") { + switch (aGrip.type) { + case "undefined": + return "undefined"; + case "null": + return "null"; + case "longString": + return "\"" + aGrip.initial + "\""; + default: + if (!aConciseFlag) { + return "[" + aGrip.type + " " + aGrip.class + "]"; + } else { + return aGrip.class; + } + } + } else { + switch (typeof aGrip) { + case "string": + return "\"" + aGrip + "\""; + case "boolean": + return aGrip ? "true" : "false"; + } + } + return aGrip + ""; +}; + +/** + * Returns a custom class style for a grip. + * + * @param any aGrip + * @see Variable.setGrip + * @return string + * The custom class style. + */ +VariablesView.getClass = function(aGrip) { + if (aGrip && typeof aGrip == "object") { + switch (aGrip.type) { + case "undefined": + return "token-undefined"; + case "null": + return "token-null"; + case "longString": + return "token-string"; + } + } else { + switch (typeof aGrip) { + case "string": + return "token-string"; + case "boolean": + return "token-boolean"; + case "number": + return "token-number"; + } + } + return "token-other"; +}; + +/** + * A monotonically-increasing counter, that guarantees the uniqueness of scope, + * variables and properties ids. + * + * @param string aName + * An optional string to prefix the id with. + * @return number + * A unique id. + */ +let generateId = (function() { + let count = 0; + return function(aName = "") { + return aName.toLowerCase().trim().replace(/\s+/g, "-") + (++count); + }; +})(); diff --git a/browser/devtools/shared/widgets/VariablesView.xul b/browser/devtools/shared/widgets/VariablesView.xul new file mode 100644 index 000000000..2269e65c9 --- /dev/null +++ b/browser/devtools/shared/widgets/VariablesView.xul @@ -0,0 +1,16 @@ +<?xml version="1.0"?> +<!-- 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://global/skin/global.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"?> +<!DOCTYPE window [ + <!ENTITY % viewDTD SYSTEM "chrome://browser/locale/devtools/VariablesView.dtd"> + %viewDTD; +]> +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="&PropertiesViewWindowTitle;"> + <vbox id="variables" flex="1"/> +</window> diff --git a/browser/devtools/shared/widgets/VariablesViewController.jsm b/browser/devtools/shared/widgets/VariablesViewController.jsm new file mode 100644 index 000000000..56704b2d2 --- /dev/null +++ b/browser/devtools/shared/widgets/VariablesViewController.jsm @@ -0,0 +1,350 @@ +/* -*- 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 { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js"); +Cu.import("resource:///modules/devtools/VariablesView.jsm"); +Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); +Cu.import("resource://gre/modules/devtools/WebConsoleUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "VARIABLES_SORTING_ENABLED", () => + Services.prefs.getBoolPref("devtools.debugger.ui.variables-sorting-enabled") +); + +const MAX_LONG_STRING_LENGTH = 200000; + +this.EXPORTED_SYMBOLS = ["VariablesViewController"]; + + +/** + * Controller for a VariablesView that handles interfacing with the debugger + * protocol. Is able to populate scopes and variables via the protocol as well + * as manage actor lifespans. + * + * @param VariablesView aView + * The view to attach to. + * @param object aOptions + * Options for configuring the controller. Supported options: + * - getGripClient: callback for creating an object grip client + * - getLongStringClient: callback for creating a long string grip client + * - releaseActor: callback for releasing an actor when it's no longer needed + * - overrideValueEvalMacro: callback for creating an overriding eval macro + * - getterOrSetterEvalMacro: callback for creating a getter/setter eval macro + * - simpleValueEvalMacro: callback for creating a simple value eval macro + */ +function VariablesViewController(aView, aOptions) { + this.addExpander = this.addExpander.bind(this); + + this._getGripClient = aOptions.getGripClient; + this._getLongStringClient = aOptions.getLongStringClient; + this._releaseActor = aOptions.releaseActor; + + if (aOptions.overrideValueEvalMacro) { + this._overrideValueEvalMacro = aOptions.overrideValueEvalMacro; + } + if (aOptions.getterOrSetterEvalMacro) { + this._getterOrSetterEvalMacro = aOptions.getterOrSetterEvalMacro; + } + if (aOptions.simpleValueEvalMacro) { + this._simpleValueEvalMacro = aOptions.simpleValueEvalMacro; + } + + this._actors = new Set(); + this.view = aView; + this.view.controller = this; +} + +VariablesViewController.prototype = { + /** + * The default getter/setter evaluation macro. + */ + _getterOrSetterEvalMacro: VariablesView.getterOrSetterEvalMacro, + + /** + * The default override value evaluation macro. + */ + _overrideValueEvalMacro: VariablesView.overrideValueEvalMacro, + + /** + * The default simple value evaluation macro. + */ + _simpleValueEvalMacro: VariablesView.simpleValueEvalMacro, + + /** + * Populate a long string into a target using a grip. + * + * @param Variable aTarget + * The target Variable/Property to put the retrieved string into. + * @param LongStringActor aGrip + * The long string grip that use to retrieve the full string. + * @return Promise + * The promise that will be resolved when the string is retrieved. + */ + _populateFromLongString: function(aTarget, aGrip){ + let deferred = Promise.defer(); + + let from = aGrip.initial.length; + let to = Math.min(aGrip.length, MAX_LONG_STRING_LENGTH); + + this._getLongStringClient(aGrip).substring(from, to, aResponse => { + // Stop tracking the actor because it's no longer needed. + this.releaseActor(aGrip); + + // Replace the preview with the full string and make it non-expandable. + aTarget.onexpand = null; + aTarget.setGrip(aGrip.initial + aResponse.substring); + aTarget.hideArrow(); + + // Mark the string as having retrieved. + aTarget._retrieved = true; + deferred.resolve(); + }); + + return deferred.promise; + }, + + /** + * Adds properties to a Scope, Variable, or Property in the view. Triggered + * when a scope is expanded or certain variables are hovered. + * + * @param Scope aTarget + * The Scope where the properties will be placed into. + * @param object aGrip + * The grip to use to populate the target. + */ + _populateFromObject: function(aTarget, aGrip) { + let deferred = Promise.defer(); + + this._getGripClient(aGrip).getPrototypeAndProperties(aResponse => { + let { ownProperties, prototype, safeGetterValues } = aResponse; + let sortable = VariablesView.isSortable(aGrip.class); + + // Merge the safe getter values into one object such that we can use it + // in VariablesView. + for (let name of Object.keys(safeGetterValues)) { + if (name in ownProperties) { + ownProperties[name].getterValue = safeGetterValues[name].getterValue; + ownProperties[name].getterPrototypeLevel = safeGetterValues[name] + .getterPrototypeLevel; + } else { + ownProperties[name] = safeGetterValues[name]; + } + } + + // Add all the variable properties. + if (ownProperties) { + aTarget.addItems(ownProperties, { + // Not all variables need to force sorted properties. + sorted: sortable, + // Expansion handlers must be set after the properties are added. + callback: this.addExpander + }); + } + + // Add the variable's __proto__. + if (prototype && prototype.type != "null") { + let proto = aTarget.addItem("__proto__", { value: prototype }); + // Expansion handlers must be set after the properties are added. + this.addExpander(proto, prototype); + } + + // Mark the variable as having retrieved all its properties. + aTarget._retrieved = true; + this.view.commitHierarchy(); + deferred.resolve(); + }); + + return deferred.promise; + }, + + /** + * Adds an 'onexpand' callback for a variable, lazily handling + * the addition of new properties. + * + * @param Variable aVar + * The variable where the properties will be placed into. + * @param any aSource + * The source to use to populate the target. + */ + addExpander: function(aTarget, aSource) { + // Attach evaluation macros as necessary. + if (aTarget.getter || aTarget.setter) { + aTarget.evaluationMacro = this._overrideValueEvalMacro; + + let getter = aTarget.get("get"); + if (getter) { + getter.evaluationMacro = this._getterOrSetterEvalMacro; + } + + let setter = aTarget.get("set"); + if (setter) { + setter.evaluationMacro = this._getterOrSetterEvalMacro; + } + } else { + aTarget.evaluationMacro = this._simpleValueEvalMacro; + } + + // If the source is primitive then an expander is not needed. + if (VariablesView.isPrimitive({ value: aSource })) { + return; + } + + // If the source is a long string then show the arrow. + if (WebConsoleUtils.isActorGrip(aSource) && aSource.type == "longString") { + aTarget.showArrow(); + } + + // Make sure that properties are always available on expansion. + aTarget.onexpand = () => this.expand(aTarget, aSource); + + // Some variables are likely to contain a very large number of properties. + // It's a good idea to be prepared in case of an expansion. + if (aTarget.shouldPrefetch) { + aTarget.addEventListener("mouseover", aTarget.onexpand, false); + } + + // Register all the actors that this controller now depends on. + for (let grip of [aTarget.value, aTarget.getter, aTarget.setter]) { + if (WebConsoleUtils.isActorGrip(grip)) { + this._actors.add(grip.actor); + } + } + }, + + /** + * Adds properties to a Scope, Variable, or Property in the view. Triggered + * when a scope is expanded or certain variables are hovered. + * + * @param Scope aTarget + * The Scope to be expanded. + * @param object aSource + * The source to use to populate the target. + * @return Promise + * The promise that is resolved once the target has been expanded. + */ + expand: function(aTarget, aSource) { + // Fetch the variables only once. + if (aTarget._fetched) { + return aTarget._fetched; + } + + let deferred = Promise.defer(); + aTarget._fetched = deferred.promise; + + if (!aSource) { + throw new Error("No actor grip was given for the variable."); + } + + // If the target a Variable or Property then we're fetching properties + if (VariablesView.isVariable(aTarget)) { + this._populateFromObject(aTarget, aSource).then(() => { + deferred.resolve(); + // Signal that properties have been fetched. + this.view.emit("fetched", "properties", aTarget); + }); + return deferred.promise; + } + + switch (aSource.type) { + case "longString": + this._populateFromLongString(aTarget, aSource).then(() => { + deferred.resolve(); + // Signal that a long string has been fetched. + this.view.emit("fetched", "longString", aTarget); + }); + break; + case "with": + case "object": + this._populateFromObject(aTarget, aSource.object).then(() => { + deferred.resolve(); + // Signal that variables have been fetched. + this.view.emit("fetched", "variables", aTarget); + }); + break; + case "block": + case "function": + // Add nodes for every argument and every other variable in scope. + let args = aSource.bindings.arguments; + if (args) { + for (let arg of args) { + let name = Object.getOwnPropertyNames(arg)[0]; + let ref = aTarget.addItem(name, arg[name]); + let val = arg[name].value; + this.addExpander(ref, val); + } + } + + aTarget.addItems(aSource.bindings.variables, { + // Not all variables need to force sorted properties. + sorted: VARIABLES_SORTING_ENABLED, + // Expansion handlers must be set after the properties are added. + callback: this.addExpander + }); + + // No need to signal that variables have been fetched, since + // the scope arguments and variables are already attached to the + // environment bindings, so pausing the active thread is unnecessary. + + deferred.resolve(); + break; + default: + let error = "Unknown Debugger.Environment type: " + aSource.type; + Cu.reportError(error); + deferred.reject(error); + } + + return deferred.promise; + }, + + /** + * Release an actor from the controller. + * + * @param object aActor + * The actor to release. + */ + releaseActor: function(aActor){ + if (this._releaseActor) { + this._releaseActor(aActor); + } + this._actors.delete(aActor); + }, + + /** + * Release all the actors referenced by the controller, optionally filtered. + * + * @param function aFilter [optional] + * Callback to filter which actors are released. + */ + releaseActors: function(aFilter) { + for (let actor of this._actors) { + if (!aFilter || aFilter(actor)) { + this.releaseActor(actor); + } + } + }, +}; + + +/** + * Attaches a VariablesViewController to a VariablesView if it doesn't already + * have one. + * + * @param VariablesView aView + * The view to attach to. + * @param object aOptions + * The options to use in creating the controller. + * @return VariablesViewController + */ +VariablesViewController.attach = function(aView, aOptions) { + if (aView.controller) { + return aView.controller; + } + return new VariablesViewController(aView, aOptions); +}; diff --git a/browser/devtools/shared/widgets/ViewHelpers.jsm b/browser/devtools/shared/widgets/ViewHelpers.jsm new file mode 100644 index 000000000..848fabd89 --- /dev/null +++ b/browser/devtools/shared/widgets/ViewHelpers.jsm @@ -0,0 +1,1606 @@ +/* -*- 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 = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +const PANE_APPEARANCE_DELAY = 50; +const PAGE_SIZE_ITEM_COUNT_RATIO = 5; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +this.EXPORTED_SYMBOLS = ["Heritage", "ViewHelpers", "WidgetMethods"]; + +/** + * Inheritance helpers from the addon SDK's core/heritage. + * Remove these when all devtools are loadered. + */ +this.Heritage = { + /** + * @see extend in sdk/core/heritage. + */ + extend: function(aPrototype, aProperties = {}) { + return Object.create(aPrototype, this.getOwnPropertyDescriptors(aProperties)); + }, + + /** + * @see getOwnPropertyDescriptors in sdk/core/heritage. + */ + getOwnPropertyDescriptors: function(aObject) { + return Object.getOwnPropertyNames(aObject).reduce((aDescriptor, aName) => { + aDescriptor[aName] = Object.getOwnPropertyDescriptor(aObject, aName); + return aDescriptor; + }, {}); + } +}; + +/** + * Helpers for creating and messaging between UI components. + */ +this.ViewHelpers = { + /** + * Convenience method, dispatching a custom event. + * + * @param nsIDOMNode aTarget + * A custom target element to dispatch the event from. + * @param string aType + * The name of the event. + * @param any aDetail + * The data passed when initializing the event. + * @return boolean + * True if the event was cancelled or a registered handler + * called preventDefault. + */ + dispatchEvent: function(aTarget, aType, aDetail) { + if (!(aTarget instanceof Ci.nsIDOMNode)) { + return true; // Event cancelled. + } + let document = aTarget.ownerDocument || aTarget; + let dispatcher = aTarget.ownerDocument ? aTarget : document.documentElement; + + let event = document.createEvent("CustomEvent"); + event.initCustomEvent(aType, true, true, aDetail); + return dispatcher.dispatchEvent(event); + }, + + /** + * Helper delegating some of the DOM attribute methods of a node to a widget. + * + * @param object aWidget + * The widget to assign the methods to. + * @param nsIDOMNode aNode + * A node to delegate the methods to. + */ + delegateWidgetAttributeMethods: function(aWidget, aNode) { + aWidget.getAttribute = aNode.getAttribute.bind(aNode); + aWidget.setAttribute = aNode.setAttribute.bind(aNode); + aWidget.removeAttribute = aNode.removeAttribute.bind(aNode); + }, + + /** + * Helper delegating some of the DOM event methods of a node to a widget. + * + * @param object aWidget + * The widget to assign the methods to. + * @param nsIDOMNode aNode + * A node to delegate the methods to. + */ + delegateWidgetEventMethods: function(aWidget, aNode) { + aWidget.addEventListener = aNode.addEventListener.bind(aNode); + aWidget.removeEventListener = aNode.removeEventListener.bind(aNode); + }, + + /** + * Checks if the specified object looks like it's been decorated by an + * event emitter. + * + * @return boolean + * True if it looks, walks and quacks like an event emitter. + */ + isEventEmitter: function(aObject) { + return aObject && aObject.on && aObject.off && aObject.once && aObject.emit; + }, + + /** + * Checks if the specified object is an instance of a DOM node. + * + * @return boolean + * True if it's a node, false otherwise. + */ + isNode: function(aObject) { + return aObject instanceof Ci.nsIDOMNode || + aObject instanceof Ci.nsIDOMElement || + aObject instanceof Ci.nsIDOMDocumentFragment; + }, + + /** + * Prevents event propagation when navigation keys are pressed. + * + * @param Event e + * The event to be prevented. + */ + preventScrolling: function(e) { + switch (e.keyCode) { + case e.DOM_VK_UP: + case e.DOM_VK_DOWN: + case e.DOM_VK_LEFT: + case e.DOM_VK_RIGHT: + case e.DOM_VK_PAGE_UP: + case e.DOM_VK_PAGE_DOWN: + case e.DOM_VK_HOME: + case e.DOM_VK_END: + e.preventDefault(); + e.stopPropagation(); + } + }, + + /** + * Sets a side pane hidden or visible. + * + * @param object aFlags + * An object containing some of the following properties: + * - visible: true if the pane should be shown, false to hide + * - animated: true to display an animation on toggle + * - delayed: true to wait a few cycles before toggle + * - callback: a function to invoke when the toggle finishes + * @param nsIDOMNode aPane + * The element representing the pane to toggle. + */ + togglePane: function(aFlags, aPane) { + // Hiding is always handled via margins, not the hidden attribute. + aPane.removeAttribute("hidden"); + + // Add a class to the pane to handle min-widths, margins and animations. + if (!aPane.classList.contains("generic-toggled-side-pane")) { + aPane.classList.add("generic-toggled-side-pane"); + } + + // Avoid useless toggles. + if (aFlags.visible == !aPane.hasAttribute("pane-collapsed")) { + if (aFlags.callback) aFlags.callback(); + return; + } + + // Computes and sets the pane margins in order to hide or show it. + function set() { + if (aFlags.visible) { + aPane.style.marginLeft = "0"; + aPane.style.marginRight = "0"; + aPane.removeAttribute("pane-collapsed"); + } else { + let margin = ~~(aPane.getAttribute("width")) + 1; + aPane.style.marginLeft = -margin + "px"; + aPane.style.marginRight = -margin + "px"; + aPane.setAttribute("pane-collapsed", ""); + } + + // Invoke the callback when the transition ended. + if (aFlags.animated) { + aPane.addEventListener("transitionend", function onEvent() { + aPane.removeEventListener("transitionend", onEvent, false); + if (aFlags.callback) aFlags.callback(); + }, false); + } + // Invoke the callback immediately since there's no transition. + else { + if (aFlags.callback) aFlags.callback(); + } + } + + // The "animated" attributes enables animated toggles (slide in-out). + if (aFlags.animated) { + aPane.setAttribute("animated", ""); + } else { + aPane.removeAttribute("animated"); + } + + // Sometimes it's useful delaying the toggle a few ticks to ensure + // a smoother slide in-out animation. + if (aFlags.delayed) { + aPane.ownerDocument.defaultView.setTimeout(set.bind(this), PANE_APPEARANCE_DELAY); + } else { + set.call(this); + } + } +}; + +/** + * Localization convenience methods. + * + * @param string aStringBundleName + * The desired string bundle's name. + */ +ViewHelpers.L10N = function(aStringBundleName) { + XPCOMUtils.defineLazyGetter(this, "stringBundle", () => + Services.strings.createBundle(aStringBundleName)); + + XPCOMUtils.defineLazyGetter(this, "ellipsis", () => + Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data); +}; + +ViewHelpers.L10N.prototype = { + stringBundle: null, + + /** + * L10N shortcut function. + * + * @param string aName + * @return string + */ + getStr: function(aName) { + return this.stringBundle.GetStringFromName(aName); + }, + + /** + * L10N shortcut function. + * + * @param string aName + * @param array aArgs + * @return string + */ + getFormatStr: function(aName, ...aArgs) { + return this.stringBundle.formatStringFromName(aName, aArgs, aArgs.length); + }, + + /** + * L10N shortcut function for numeric arguments that need to be formatted. + * All numeric arguments will be fixed to 2 decimals and given a localized + * decimal separator. Other arguments will be left alone. + * + * @param string aName + * @param array aArgs + * @return string + */ + getFormatStrWithNumbers: function(aName, ...aArgs) { + let newArgs = aArgs.map(x => typeof x == "number" ? this.numberWithDecimals(x, 2) : x); + return this.stringBundle.formatStringFromName(aName, newArgs, newArgs.length); + }, + + /** + * Converts a number to a locale-aware string format and keeps a certain + * number of decimals. + * + * @param number aNumber + * The number to convert. + * @param number aDecimals [optional] + * Total decimals to keep. + * @return string + * The localized number as a string. + */ + numberWithDecimals: function(aNumber, aDecimals = 0) { + // If this is an integer, don't do anything special. + if (aNumber == (aNumber | 0)) { + return aNumber; + } + // Remove {n} trailing decimals. Can't use toFixed(n) because + // toLocaleString converts the number to a string. Also can't use + // toLocaleString(, { maximumFractionDigits: n }) because it's not + // implemented on OS X (bug 368838). Gross. + let localized = aNumber.toLocaleString(); // localize + let padded = localized + new Array(aDecimals).join("0"); // pad with zeros + let match = padded.match("([^]*?\\d{" + aDecimals + "})\\d*$"); + return match.pop(); + } +}; + +/** + * Shortcuts for lazily accessing and setting various preferences. + * Usage: + * let prefs = new ViewHelpers.Prefs("root.path.to.branch", { + * myIntPref: ["Int", "leaf.path.to.my-int-pref"], + * myCharPref: ["Char", "leaf.path.to.my-char-pref"], + * ... + * }); + * + * prefs.myCharPref = "foo"; + * let aux = prefs.myCharPref; + * + * @param string aPrefsRoot + * The root path to the required preferences branch. + * @param object aPrefsObject + * An object containing { accessorName: [prefType, prefName] } keys. + */ +ViewHelpers.Prefs = function(aPrefsRoot = "", aPrefsObject = {}) { + this.root = aPrefsRoot; + + for (let accessorName in aPrefsObject) { + let [prefType, prefName] = aPrefsObject[accessorName]; + this.map(accessorName, prefType, prefName); + } +}; + +ViewHelpers.Prefs.prototype = { + /** + * Helper method for getting a pref value. + * + * @param string aType + * @param string aPrefName + * @return any + */ + _get: function(aType, aPrefName) { + if (this[aPrefName] === undefined) { + this[aPrefName] = Services.prefs["get" + aType + "Pref"](aPrefName); + } + return this[aPrefName]; + }, + + /** + * Helper method for setting a pref value. + * + * @param string aType + * @param string aPrefName + * @param any aValue + */ + _set: function(aType, aPrefName, aValue) { + Services.prefs["set" + aType + "Pref"](aPrefName, aValue); + this[aPrefName] = aValue; + }, + + /** + * Maps a property name to a pref, defining lazy getters and setters. + * + * @param string aAccessorName + * @param string aType + * @param string aPrefName + */ + map: function(aAccessorName, aType, aPrefName) { + Object.defineProperty(this, aAccessorName, { + get: () => this._get(aType, [this.root, aPrefName].join(".")), + set: (aValue) => this._set(aType, [this.root, aPrefName].join("."), aValue) + }); + } +}; + +/** + * A generic Item is used to describe children present in a Widget. + * The label, value and description properties are necessarily strings. + * Iterable via "for (let childItem in parentItem) { }". + * + * @param object aOwnerView + * The owner view creating this item. + * @param any aAttachment + * Some attached primitive/object. + * @param nsIDOMNode | nsIDOMDocumentFragment | array aContents [optional] + * A prebuilt node, or an array containing the following properties: + * - aLabel: the label displayed in the widget + * - aValue: the actual internal value of the item + * - aDescription: an optional description of the item + */ +function Item(aOwnerView, aAttachment, aContents = []) { + this.ownerView = aOwnerView; + this.attachment = aAttachment; + + let [aLabel, aValue, aDescription] = aContents; + this._label = aLabel + ""; + this._value = aValue + ""; + this._description = (aDescription || "") + ""; + + // Allow the insertion of prebuilt nodes, otherwise delegate the item view + // creation to a widget. + if (ViewHelpers.isNode(aLabel)) { + this._prebuiltTarget = aLabel; + } + + XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map()); +}; + +Item.prototype = { + get label() this._label, + get value() this._value, + get description() this._description, + get target() this._target, + + /** + * Immediately appends a child item to this item. + * + * @param nsIDOMNode aElement + * An nsIDOMNode representing the child element to append. + * @param object aOptions [optional] + * Additional options or flags supported by this operation: + * - attachment: some attached primitive/object for the item + * - attributes: a batch of attributes set to the displayed element + * - finalize: function invoked when the child item is removed + * @return Item + * The item associated with the displayed element. + */ + append: function(aElement, aOptions = {}) { + let item = new Item(this, aOptions.attachment); + + // Entangle the item with the newly inserted child node. + this._entangleItem(item, this._target.appendChild(aElement)); + + // Handle any additional options after entangling the item. + if (aOptions.attributes) { + aOptions.attributes.forEach(e => item._target.setAttribute(e[0], e[1])); + } + if (aOptions.finalize) { + item.finalize = aOptions.finalize; + } + + // Return the item associated with the displayed element. + return item; + }, + + /** + * Immediately removes the specified child item from this item. + * + * @param Item aItem + * The item associated with the element to remove. + */ + remove: function(aItem) { + if (!aItem) { + return; + } + this._target.removeChild(aItem._target); + this._untangleItem(aItem); + }, + + /** + * Entangles an item (model) with a displayed node element (view). + * + * @param Item aItem + * The item describing a target element. + * @param nsIDOMNode aElement + * The element displaying the item. + */ + _entangleItem: function(aItem, aElement) { + this._itemsByElement.set(aElement, aItem); + aItem._target = aElement; + }, + + /** + * Untangles an item (model) from a displayed node element (view). + * + * @param Item aItem + * The item describing a target element. + */ + _untangleItem: function(aItem) { + if (aItem.finalize) { + aItem.finalize(aItem); + } + for (let childItem in aItem) { + aItem.remove(childItem); + } + + this._unlinkItem(aItem); + aItem._prebuiltTarget = null; + aItem._target = null; + }, + + /** + * Deletes an item from the its parent's storage maps. + * + * @param Item aItem + * The item describing a target element. + */ + _unlinkItem: function(aItem) { + this._itemsByElement.delete(aItem._target); + }, + + /** + * Returns a string representing the object. + * @return string + */ + toString: function() { + if (this._label && this._value) { + return this._label + " -> " + this._value; + } + if (this.attachment) { + return this.attachment.toString(); + } + return "(null)"; + }, + + _label: "", + _value: "", + _description: "", + _prebuiltTarget: null, + _target: null, + finalize: null, + attachment: null +}; + +/** + * Some generic Widget methods handling Item instances. + * Iterable via "for (let childItem in wrappedView) { }". + * + * Usage: + * function MyView() { + * this.widget = new MyWidget(document.querySelector(".my-node")); + * } + * + * MyView.prototype = Heritage.extend(WidgetMethods, { + * myMethod: function() {}, + * ... + * }); + * + * See https://gist.github.com/victorporof/5749386 for more details. + * + * Language: + * - An "item" is an instance of an Item. + * - An "element" or "node" is a nsIDOMNode. + * + * The supplied element node or widget can either be a <xul:menulist>, or any + * other object interfacing the following methods: + * - function:nsIDOMNode insertItemAt(aIndex:number, aLabel:string, aValue:string) + * - function:nsIDOMNode getItemAtIndex(aIndex:number) + * - function removeChild(aChild:nsIDOMNode) + * - function removeAllItems() + * - get:nsIDOMNode selectedItem() + * - set selectedItem(aChild:nsIDOMNode) + * - function getAttribute(aName:string) + * - function setAttribute(aName:string, aValue:string) + * - function removeAttribute(aName:string) + * - function addEventListener(aName:string, aCallback:function, aBubbleFlag:boolean) + * - function removeEventListener(aName:string, aCallback:function, aBubbleFlag:boolean) + * + * For automagical keyboard and mouse accessibility, the element node or widget + * should be an event emitter with the following events: + * - "keyPress" -> (aName:string, aEvent:KeyboardEvent) + * - "mousePress" -> (aName:string, aEvent:MouseEvent) + */ +this.WidgetMethods = { + /** + * Sets the element node or widget associated with this container. + * @param nsIDOMNode | object aWidget + */ + set widget(aWidget) { + this._widget = aWidget; + + // Can't use WeakMaps for itemsByLabel or itemsByValue because + // keys are strings, and itemsByElement needs to be iterable. + XPCOMUtils.defineLazyGetter(this, "_itemsByLabel", () => new Map()); + XPCOMUtils.defineLazyGetter(this, "_itemsByValue", () => new Map()); + XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map()); + XPCOMUtils.defineLazyGetter(this, "_stagedItems", () => []); + + // Handle internal events emitted by the widget if necessary. + if (ViewHelpers.isEventEmitter(aWidget)) { + aWidget.on("keyPress", this._onWidgetKeyPress.bind(this)); + aWidget.on("mousePress", this._onWidgetMousePress.bind(this)); + } + }, + + /** + * Gets the element node or widget associated with this container. + * @return nsIDOMNode | object + */ + get widget() this._widget, + + /** + * Prepares an item to be added to this container. This allows, for example, + * for a large number of items to be batched up before being sorted & added. + * + * If the "staged" flag is *not* set to true, the item will be immediately + * inserted at the correct position in this container, so that all the items + * still remain sorted. This can (possibly) be much slower than batching up + * multiple items. + * + * By default, this container assumes that all the items should be displayed + * sorted by their label. This can be overridden with the "index" flag, + * specifying on which position should an item be appended. The "staged" and + * "index" flags are mutually exclusive, meaning that all staged items + * will always be appended. + * + * Furthermore, this container makes sure that all the items are unique + * (two items with the same label or value are not allowed) and non-degenerate + * (items with "undefined" or "null" labels/values). This can, as well, be + * overridden via the "relaxed" flag. + * + * @param nsIDOMNode | nsIDOMDocumentFragment array aContents + * A prebuilt node, or an array containing the following properties: + * - label: the label displayed in the container + * - value: the actual internal value of the item + * - description: an optional description of the item + * @param object aOptions [optional] + * Additional options or flags supported by this operation: + * - staged: true to stage the item to be appended later + * - index: specifies on which position should the item be appended + * - relaxed: true if this container should allow dupes & degenerates + * - attachment: some attached primitive/object for the item + * - attributes: a batch of attributes set to the displayed element + * - finalize: function invoked when the item is removed + * @return Item + * The item associated with the displayed element if an unstaged push, + * undefined if the item was staged for a later commit. + */ + push: function(aContents, aOptions = {}) { + let item = new Item(this, aOptions.attachment, aContents); + + // Batch the item to be added later. + if (aOptions.staged) { + // An ulterior commit operation will ignore any specified index. + delete aOptions.index; + return void this._stagedItems.push({ item: item, options: aOptions }); + } + // Find the target position in this container and insert the item there. + if (!("index" in aOptions)) { + return this._insertItemAt(this._findExpectedIndexFor(item), item, aOptions); + } + // Insert the item at the specified index. If negative or out of bounds, + // the item will be simply appended. + return this._insertItemAt(aOptions.index, item, aOptions); + }, + + /** + * Flushes all the prepared items into this container. + * Any specified index on the items will be ignored. Everything is appended. + * + * @param object aOptions [optional] + * Additional options or flags supported by this operation: + * - sorted: true to sort all the items before adding them + */ + commit: function(aOptions = {}) { + let stagedItems = this._stagedItems; + + // Sort the items before adding them to this container, if preferred. + if (aOptions.sorted) { + stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item)); + } + // Append the prepared items to this container. + for (let { item, options } of stagedItems) { + this._insertItemAt(-1, item, options); + } + // Recreate the temporary items list for ulterior pushes. + this._stagedItems.length = 0; + }, + + /** + * Updates this container to reflect the information provided by the + * currently selected item. + * + * @return boolean + * True if a selected item was available, false otherwise. + */ + refresh: function() { + let selectedItem = this.selectedItem; + if (!selectedItem) { + return false; + } + this._widget.removeAttribute("notice"); + this._widget.setAttribute("label", selectedItem._label); + this._widget.setAttribute("tooltiptext", selectedItem._value); + return true; + }, + + /** + * Immediately removes the specified item from this container. + * + * @param Item aItem + * The item associated with the element to remove. + */ + remove: function(aItem) { + if (!aItem) { + return; + } + this._widget.removeChild(aItem._target); + this._untangleItem(aItem); + }, + + /** + * Removes the item at the specified index from this container. + * + * @param number aIndex + * The index of the item to remove. + */ + removeAt: function(aIndex) { + this.remove(this.getItemAtIndex(aIndex)); + }, + + /** + * Removes all items from this container. + */ + empty: function() { + this._preferredValue = this.selectedValue; + this._widget.selectedItem = null; + this._widget.removeAllItems(); + this._widget.setAttribute("notice", this.emptyText); + this._widget.setAttribute("label", this.emptyText); + this._widget.removeAttribute("tooltiptext"); + + for (let [, item] of this._itemsByElement) { + this._untangleItem(item); + } + + this._itemsByLabel.clear(); + this._itemsByValue.clear(); + this._itemsByElement.clear(); + this._stagedItems.length = 0; + }, + + /** + * Does not remove any item in this container. Instead, it overrides the + * current label to signal that it is unavailable and removes the tooltip. + */ + setUnavailable: function() { + this._widget.setAttribute("notice", this.unavailableText); + this._widget.setAttribute("label", this.unavailableText); + this._widget.removeAttribute("tooltiptext"); + }, + + /** + * The label string automatically added to this container when there are + * no child nodes present. + */ + emptyText: "", + + /** + * The label string added to this container when it is marked as unavailable. + */ + unavailableText: "", + + /** + * Toggles all the items in this container hidden or visible. + * + * This does not change the default filtering predicate, so newly inserted + * items will always be visible. Use WidgetMethods.filterContents if you care. + * + * @param boolean aVisibleFlag + * Specifies the intended visibility. + */ + toggleContents: function(aVisibleFlag) { + for (let [element, item] of this._itemsByElement) { + element.hidden = !aVisibleFlag; + } + }, + + /** + * Toggles all items in this container hidden or visible based on a predicate. + * + * @param function aPredicate [optional] + * Items are toggled according to the return value of this function, + * which will become the new default filtering predicate in this container. + * If unspecified, all items will be toggled visible. + */ + filterContents: function(aPredicate = this._currentFilterPredicate) { + this._currentFilterPredicate = aPredicate; + + for (let [element, item] of this._itemsByElement) { + element.hidden = !aPredicate(item); + } + }, + + /** + * Sorts all the items in this container based on a predicate. + * + * @param function aPredicate [optional] + * Items are sorted according to the return value of the function, + * which will become the new default sorting predicate in this container. + * If unspecified, all items will be sorted by their label. + */ + sortContents: function(aPredicate = this._currentSortPredicate) { + let sortedItems = this.orderedItems.sort(this._currentSortPredicate = aPredicate); + + for (let i = 0, len = sortedItems.length; i < len; i++) { + this.swapItems(this.getItemAtIndex(i), sortedItems[i]); + } + }, + + /** + * Visually swaps two items in this container. + * + * @param Item aFirst + * The first item to be swapped. + * @param Item aSecond + * The second item to be swapped. + */ + swapItems: function(aFirst, aSecond) { + if (aFirst == aSecond) { // We're just dandy, thank you. + return; + } + let { _prebuiltTarget: firstPrebuiltTarget, target: firstTarget } = aFirst; + let { _prebuiltTarget: secondPrebuiltTarget, target: secondTarget } = aSecond; + + // If the two items were constructed with prebuilt nodes as DocumentFragments, + // then those DocumentFragments are now empty and need to be reassembled. + if (firstPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) { + for (let node of firstTarget.childNodes) { + firstPrebuiltTarget.appendChild(node.cloneNode(true)); + } + } + if (secondPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) { + for (let node of secondTarget.childNodes) { + secondPrebuiltTarget.appendChild(node.cloneNode(true)); + } + } + + // 1. Get the indices of the two items to swap. + let i = this._indexOfElement(firstTarget); + let j = this._indexOfElement(secondTarget); + + // 2. Remeber the selection index, to reselect an item, if necessary. + let selectedTarget = this._widget.selectedItem; + let selectedIndex = -1; + if (selectedTarget == firstTarget) { + selectedIndex = i; + } else if (selectedTarget == secondTarget) { + selectedIndex = j; + } + + // 3. Silently nuke both items, nobody needs to know about this. + this._widget.removeChild(firstTarget); + this._widget.removeChild(secondTarget); + this._unlinkItem(aFirst); + this._unlinkItem(aSecond); + + // 4. Add the items again, but reversing their indices. + this._insertItemAt.apply(this, i < j ? [i, aSecond] : [j, aFirst]); + this._insertItemAt.apply(this, i < j ? [j, aFirst] : [i, aSecond]); + + // 5. Restore the previous selection, if necessary. + if (selectedIndex == i) { + this._widget.selectedItem = aFirst._target; + } else if (selectedIndex == j) { + this._widget.selectedItem = aSecond._target; + } + }, + + /** + * Visually swaps two items in this container at specific indices. + * + * @param number aFirst + * The index of the first item to be swapped. + * @param number aSecond + * The index of the second item to be swapped. + */ + swapItemsAtIndices: function(aFirst, aSecond) { + this.swapItems(this.getItemAtIndex(aFirst), this.getItemAtIndex(aSecond)); + }, + + /** + * Checks whether an item with the specified label is among the elements + * shown in this container. + * + * @param string aLabel + * The item's label. + * @return boolean + * True if the label is known, false otherwise. + */ + containsLabel: function(aLabel) { + return this._itemsByLabel.has(aLabel) || + this._stagedItems.some(({ item }) => item._label == aLabel); + }, + + /** + * Checks whether an item with the specified value is among the elements + * shown in this container. + * + * @param string aValue + * The item's value. + * @return boolean + * True if the value is known, false otherwise. + */ + containsValue: function(aValue) { + return this._itemsByValue.has(aValue) || + this._stagedItems.some(({ item }) => item._value == aValue); + }, + + /** + * Gets the "preferred value". This is the latest selected item's value, + * remembered just before emptying this container. + * @return string + */ + get preferredValue() this._preferredValue, + + /** + * Retrieves the item associated with the selected element. + * @return Item + */ + get selectedItem() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._itemsByElement.get(selectedElement); + } + return null; + }, + + /** + * Retrieves the selected element's index in this container. + * @return number + */ + get selectedIndex() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._indexOfElement(selectedElement); + } + return -1; + }, + + /** + * Retrieves the label of the selected element. + * @return string + */ + get selectedLabel() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._itemsByElement.get(selectedElement)._label; + } + return ""; + }, + + /** + * Retrieves the value of the selected element. + * @return string + */ + get selectedValue() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._itemsByElement.get(selectedElement)._value; + } + return ""; + }, + + /** + * Selects the element with the entangled item in this container. + * @param Item | function aItem + */ + set selectedItem(aItem) { + // A predicate is allowed to select a specific item. + // If no item is matched, then the current selection is removed. + if (typeof aItem == "function") { + aItem = this.getItemForPredicate(aItem); + } + + // A falsy item is allowed to invalidate the current selection. + let targetElement = aItem ? aItem._target : null; + let prevElement = this._widget.selectedItem; + + // Make sure the currently selected item's target element is also focused. + if (this.autoFocusOnSelection && targetElement) { + targetElement.focus(); + } + + // Prevent selecting the same item again and avoid dispatching + // a redundant selection event, so return early. + if (targetElement == prevElement) { + return; + } + this._widget.selectedItem = targetElement; + ViewHelpers.dispatchEvent(targetElement || prevElement, "select", aItem); + + // Updates this container to reflect the information provided by the + // currently selected item. + this.refresh(); + }, + + /** + * Selects the element at the specified index in this container. + * @param number aIndex + */ + set selectedIndex(aIndex) { + let targetElement = this._widget.getItemAtIndex(aIndex); + if (targetElement) { + this.selectedItem = this._itemsByElement.get(targetElement); + return; + } + this.selectedItem = null; + }, + + /** + * Selects the element with the specified label in this container. + * @param string aLabel + */ + set selectedLabel(aLabel) + this.selectedItem = this._itemsByLabel.get(aLabel), + + /** + * Selects the element with the specified value in this container. + * @param string aValue + */ + set selectedValue(aValue) + this.selectedItem = this._itemsByValue.get(aValue), + + /** + * Focus this container the first time an element is inserted? + * + * If this flag is set to true, then when the first item is inserted in + * this container (and thus it's the only item available), its corresponding + * target element is focused as well. + */ + autoFocusOnFirstItem: true, + + /** + * Focus on selection? + * + * If this flag is set to true, then whenever an item is selected in + * this container (e.g. via the selectedIndex or selectedItem setters), + * its corresponding target element is focused as well. + * + * You can disable this flag, for example, to maintain a certain node + * focused but visually indicate a different selection in this container. + */ + autoFocusOnSelection: true, + + /** + * Focus on input (e.g. mouse click)? + * + * If this flag is set to true, then whenever an item receives user input in + * this container, its corresponding target element is focused as well. + */ + autoFocusOnInput: true, + + /** + * The number of elements in this container to jump when Page Up or Page Down + * keys are pressed. If falsy, then the page size will be based on the + * number of visible items in the container. + */ + pageSize: 0, + + /** + * Focuses the first visible item in this container. + */ + focusFirstVisibleItem: function() { + this.focusItemAtDelta(-this.itemCount); + }, + + /** + * Focuses the last visible item in this container. + */ + focusLastVisibleItem: function() { + this.focusItemAtDelta(+this.itemCount); + }, + + /** + * Focuses the next item in this container. + */ + focusNextItem: function() { + this.focusItemAtDelta(+1); + }, + + /** + * Focuses the previous item in this container. + */ + focusPrevItem: function() { + this.focusItemAtDelta(-1); + }, + + /** + * Focuses another item in this container based on the index distance + * from the currently focused item. + * + * @param number aDelta + * A scalar specifying by how many items should the selection change. + */ + focusItemAtDelta: function(aDelta) { + // Make sure the currently selected item is also focused, so that the + // command dispatcher mechanism has a relative node to work with. + // If there's no selection, just select an item at a corresponding index + // (e.g. the first item in this container if aDelta <= 1). + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + selectedElement.focus(); + } else { + this.selectedIndex = Math.max(0, aDelta - 1); + return; + } + + let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus"; + let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta)); + while (distance--) { + if (!this._focusChange(direction)) { + break; // Out of bounds. + } + } + + // Synchronize the selected item as being the currently focused element. + this.selectedItem = this.getItemForElement(this._focusedElement); + }, + + /** + * Focuses the next or previous item in this container. + * + * @param string aDirection + * Either "advanceFocus" or "rewindFocus". + * @return boolean + * False if the focus went out of bounds and the first or last item + * in this container was focused instead. + */ + _focusChange: function(aDirection) { + let commandDispatcher = this._commandDispatcher; + let prevFocusedElement = commandDispatcher.focusedElement; + + commandDispatcher.suppressFocusScroll = true; + commandDispatcher[aDirection](); + + // Make sure the newly focused item is a part of this container. + // If the focus goes out of bounds, revert the previously focused item. + if (!this.getItemForElement(commandDispatcher.focusedElement)) { + prevFocusedElement.focus(); + return false; + } + // Focus remained within bounds. + return true; + }, + + /** + * Gets the command dispatcher instance associated with this container's DOM. + * If there are no items displayed in this container, null is returned. + * @return nsIDOMXULCommandDispatcher | null + */ + get _commandDispatcher() { + if (this._cachedCommandDispatcher) { + return this._cachedCommandDispatcher; + } + let someElement = this._widget.getItemAtIndex(0); + if (someElement) { + let commandDispatcher = someElement.ownerDocument.commandDispatcher; + return this._cachedCommandDispatcher = commandDispatcher; + } + return null; + }, + + /** + * Gets the currently focused element in this container. + * + * @return nsIDOMNode + * The focused element, or null if nothing is found. + */ + get _focusedElement() { + let commandDispatcher = this._commandDispatcher; + if (commandDispatcher) { + return commandDispatcher.focusedElement; + } + return null; + }, + + /** + * Gets the item in the container having the specified index. + * + * @param number aIndex + * The index used to identify the element. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemAtIndex: function(aIndex) { + return this.getItemForElement(this._widget.getItemAtIndex(aIndex)); + }, + + /** + * Gets the item in the container having the specified label. + * + * @param string aLabel + * The label used to identify the element. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemByLabel: function(aLabel) { + return this._itemsByLabel.get(aLabel); + }, + + /** + * Gets the item in the container having the specified value. + * + * @param string aValue + * The value used to identify the element. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemByValue: function(aValue) { + return this._itemsByValue.get(aValue); + }, + + /** + * Gets the item in the container associated with the specified element. + * + * @param nsIDOMNode aElement + * The element used to identify the item. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemForElement: function(aElement) { + while (aElement) { + let item = this._itemsByElement.get(aElement); + if (item) { + return item; + } + aElement = aElement.parentNode; + } + return null; + }, + + /** + * Gets a visible item in this container validating a specified predicate. + * + * @param function aPredicate + * The first item which validates this predicate is returned + * @return Item + * The matched item, or null if nothing is found. + */ + getItemForPredicate: function(aPredicate, aOwner = this) { + for (let [element, item] of aOwner._itemsByElement) { + let match; + if (aPredicate(item) && !element.hidden) { + match = item; + } else { + match = this.getItemForPredicate(aPredicate, item); + } + if (match) { + return match; + } + } + return null; + }, + + /** + * Finds the index of an item in the container. + * + * @param Item aItem + * The item get the index for. + * @return number + * The index of the matched item, or -1 if nothing is found. + */ + indexOfItem: function(aItem) { + return this._indexOfElement(aItem._target); + }, + + /** + * Finds the index of an element in the container. + * + * @param nsIDOMNode aElement + * The element get the index for. + * @return number + * The index of the matched element, or -1 if nothing is found. + */ + _indexOfElement: function(aElement) { + for (let i = 0; i < this._itemsByElement.size; i++) { + if (this._widget.getItemAtIndex(i) == aElement) { + return i; + } + } + return -1; + }, + + /** + * Gets the total number of items in this container. + * @return number + */ + get itemCount() this._itemsByElement.size, + + /** + * Returns a list of items in this container, in no particular order. + * @return array + */ + get items() { + let items = []; + for (let [, item] of this._itemsByElement) { + items.push(item); + } + return items; + }, + + /** + * Returns a list of labels in this container, in no particular order. + * @return array + */ + get labels() { + let labels = []; + for (let [label] of this._itemsByLabel) { + labels.push(label); + } + return labels; + }, + + /** + * Returns a list of values in this container, in no particular order. + * @return array + */ + get values() { + let values = []; + for (let [value] of this._itemsByValue) { + values.push(value); + } + return values; + }, + + /** + * Returns a list of all the visible (non-hidden) items in this container, + * in no particular order. + * @return array + */ + get visibleItems() { + let items = []; + for (let [element, item] of this._itemsByElement) { + if (!element.hidden) { + items.push(item); + } + } + return items; + }, + + /** + * Returns a list of all items in this container, in the displayed order. + * @return array + */ + get orderedItems() { + let items = []; + let itemCount = this.itemCount; + for (let i = 0; i < itemCount; i++) { + items.push(this.getItemAtIndex(i)); + } + return items; + }, + + /** + * Returns a list of all the visible (non-hidden) items in this container, + * in the displayed order + * @return array + */ + get orderedVisibleItems() { + let items = []; + let itemCount = this.itemCount; + for (let i = 0; i < itemCount; i++) { + let item = this.getItemAtIndex(i); + if (!item._target.hidden) { + items.push(item); + } + } + return items; + }, + + /** + * Specifies the required conditions for an item to be considered unique. + * Possible values: + * - 1: label AND value are different from all other items + * - 2: label OR value are different from all other items + * - 3: only label is required to be different + * - 4: only value is required to be different + */ + uniquenessQualifier: 1, + + /** + * Checks if an item is unique in this container. + * + * @param Item aItem + * The item for which to verify uniqueness. + * @return boolean + * True if the item is unique, false otherwise. + */ + isUnique: function(aItem) { + switch (this.uniquenessQualifier) { + case 1: + return !this._itemsByLabel.has(aItem._label) && + !this._itemsByValue.has(aItem._value); + case 2: + return !this._itemsByLabel.has(aItem._label) || + !this._itemsByValue.has(aItem._value); + case 3: + return !this._itemsByLabel.has(aItem._label); + case 4: + return !this._itemsByValue.has(aItem._value); + } + return false; + }, + + /** + * Checks if an item is eligible for this container. + * + * @param Item aItem + * The item for which to verify eligibility. + * @return boolean + * True if the item is eligible, false otherwise. + */ + isEligible: function(aItem) { + let isUnique = this.isUnique(aItem); + let isPrebuilt = !!aItem._prebuiltTarget; + let isDegenerate = aItem._label == "undefined" || aItem._label == "null" || + aItem._value == "undefined" || aItem._value == "null"; + + return isPrebuilt || (isUnique && !isDegenerate); + }, + + /** + * Finds the expected item index in this container based on the default + * sort predicate. + * + * @param Item aItem + * The item for which to get the expected index. + * @return number + * The expected item index. + */ + _findExpectedIndexFor: function(aItem) { + let itemCount = this.itemCount; + + for (let i = 0; i < itemCount; i++) { + if (this._currentSortPredicate(this.getItemAtIndex(i), aItem) > 0) { + return i; + } + } + return itemCount; + }, + + /** + * Immediately inserts an item in this container at the specified index. + * + * @param number aIndex + * The position in the container intended for this item. + * @param Item aItem + * An object containing a label and a value property (at least). + * @param object aOptions [optional] + * Additional options or flags supported by this operation: + * - node: allows the insertion of prebuilt nodes instead of labels + * - relaxed: true if this container should allow dupes & degenerates + * - attributes: a batch of attributes set to the displayed element + * - finalize: function when the item is untangled (removed) + * @return Item + * The item associated with the displayed element, null if rejected. + */ + _insertItemAt: function(aIndex, aItem, aOptions = {}) { + // Relaxed nodes may be appended without verifying their eligibility. + if (!aOptions.relaxed && !this.isEligible(aItem)) { + return null; + } + + // Entangle the item with the newly inserted node. + this._entangleItem(aItem, this._widget.insertItemAt(aIndex, + aItem._prebuiltTarget || aItem._label, // Allow the insertion of prebuilt nodes. + aItem._value, + aItem._description, + aItem.attachment)); + + // Handle any additional options after entangling the item. + if (!this._currentFilterPredicate(aItem)) { + aItem._target.hidden = true; + } + if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) { + aItem._target.focus(); + } + if (aOptions.attributes) { + aOptions.attributes.forEach(e => aItem._target.setAttribute(e[0], e[1])); + } + if (aOptions.finalize) { + aItem.finalize = aOptions.finalize; + } + + // Return the item associated with the displayed element. + return aItem; + }, + + /** + * Entangles an item (model) with a displayed node element (view). + * + * @param Item aItem + * The item describing a target element. + * @param nsIDOMNode aElement + * The element displaying the item. + */ + _entangleItem: function(aItem, aElement) { + this._itemsByLabel.set(aItem._label, aItem); + this._itemsByValue.set(aItem._value, aItem); + this._itemsByElement.set(aElement, aItem); + aItem._target = aElement; + }, + + /** + * Untangles an item (model) from a displayed node element (view). + * + * @param Item aItem + * The item describing a target element. + */ + _untangleItem: function(aItem) { + if (aItem.finalize) { + aItem.finalize(aItem); + } + for (let childItem in aItem) { + aItem.remove(childItem); + } + + this._unlinkItem(aItem); + aItem._prebuiltTarget = null; + aItem._target = null; + }, + + /** + * Deletes an item from the its parent's storage maps. + * + * @param Item aItem + * The item describing a target element. + */ + _unlinkItem: function(aItem) { + this._itemsByLabel.delete(aItem._label); + this._itemsByValue.delete(aItem._value); + this._itemsByElement.delete(aItem._target); + }, + + /** + * The keyPress event listener for this container. + * @param string aName + * @param KeyboardEvent aEvent + */ + _onWidgetKeyPress: function(aName, aEvent) { + // Prevent scrolling when pressing navigation keys. + ViewHelpers.preventScrolling(aEvent); + + switch (aEvent.keyCode) { + case aEvent.DOM_VK_UP: + case aEvent.DOM_VK_LEFT: + this.focusPrevItem(); + return; + case aEvent.DOM_VK_DOWN: + case aEvent.DOM_VK_RIGHT: + this.focusNextItem(); + return; + case aEvent.DOM_VK_PAGE_UP: + this.focusItemAtDelta(-(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); + return; + case aEvent.DOM_VK_PAGE_DOWN: + this.focusItemAtDelta(+(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); + return; + case aEvent.DOM_VK_HOME: + this.focusFirstVisibleItem(); + return; + case aEvent.DOM_VK_END: + this.focusLastVisibleItem(); + return; + } + }, + + /** + * The keyPress event listener for this container. + * @param string aName + * @param MouseEvent aEvent + */ + _onWidgetMousePress: function(aName, aEvent) { + if (aEvent.button != 0) { + // Only allow left-click to trigger this event. + return; + } + + let item = this.getItemForElement(aEvent.target); + if (item) { + // The container is not empty and we clicked on an actual item. + this.selectedItem = item; + // Make sure the current event's target element is also focused. + this.autoFocusOnInput && item._target.focus(); + } + }, + + /** + * The predicate used when filtering items. By default, all items in this + * view are visible. + * + * @param Item aItem + * The item passing through the filter. + * @return boolean + * True if the item should be visible, false otherwise. + */ + _currentFilterPredicate: function(aItem) { + return true; + }, + + /** + * The predicate used when sorting items. By default, items in this view + * are sorted by their label. + * + * @param Item aFirst + * The first item used in the comparison. + * @param Item aSecond + * The second item used in the comparison. + * @return number + * -1 to sort aFirst to a lower index than aSecond + * 0 to leave aFirst and aSecond unchanged with respect to each other + * 1 to sort aSecond to a lower index than aFirst + */ + _currentSortPredicate: function(aFirst, aSecond) { + return +(aFirst._label.toLowerCase() > aSecond._label.toLowerCase()); + }, + + _widget: null, + _preferredValue: null, + _cachedCommandDispatcher: null +}; + +/** + * A generator-iterator over all the items in this container. + */ +Item.prototype.__iterator__ = +WidgetMethods.__iterator__ = function() { + for (let [, item] of this._itemsByElement) { + yield item; + } +}; diff --git a/browser/devtools/shared/widgets/widgets.css b/browser/devtools/shared/widgets/widgets.css new file mode 100644 index 000000000..5ee65ea91 --- /dev/null +++ b/browser/devtools/shared/widgets/widgets.css @@ -0,0 +1,59 @@ +/* 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/. */ + +/* BreacrumbsWidget */ + +.breadcrumbs-widget-item { + direction: ltr; +} + +.breadcrumbs-widget-item { + -moz-user-focus: normal; +} + +/* SideMenuWidget */ + +.side-menu-widget-container { + overflow-x: hidden; + overflow-y: auto; +} + +.side-menu-widget-item-contents { + -moz-user-focus: normal; +} + +/* VariablesView */ + +.variables-view-container { + overflow-x: hidden; + overflow-y: auto; +} + +.variables-view-element-details:not([open]) { + display: none; +} + +.variables-view-scope, +.variable-or-property { + -moz-user-focus: normal; +} + +.variables-view-scope > .title, +.variable-or-property > .title { + overflow: hidden; +} + +.variables-view-scope[non-header] > .title, +.variable-or-property[non-header] > .title, +.variable-or-property[non-match] > .title { + display: none; +} + +.variable-or-property:not([safe-getter]) > tooltip > label[value=WebIDL], +.variable-or-property:not([non-extensible]) > tooltip > label[value=extensible], +.variable-or-property:not([frozen]) > tooltip > label[value=frozen], +.variable-or-property:not([sealed]) > tooltip > label[value=sealed] { + display: none; +} |