diff options
Diffstat (limited to 'devtools/client/framework/toolbox.js')
-rw-r--r-- | devtools/client/framework/toolbox.js | 2417 |
1 files changed, 2417 insertions, 0 deletions
diff --git a/devtools/client/framework/toolbox.js b/devtools/client/framework/toolbox.js new file mode 100644 index 000000000..82d5d2915 --- /dev/null +++ b/devtools/client/framework/toolbox.js @@ -0,0 +1,2417 @@ +/* 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 MAX_ORDINAL = 99; +const SPLITCONSOLE_ENABLED_PREF = "devtools.toolbox.splitconsoleEnabled"; +const SPLITCONSOLE_HEIGHT_PREF = "devtools.toolbox.splitconsoleHeight"; +const OS_HISTOGRAM = "DEVTOOLS_OS_ENUMERATED_PER_USER"; +const OS_IS_64_BITS = "DEVTOOLS_OS_IS_64_BITS_PER_USER"; +const HOST_HISTOGRAM = "DEVTOOLS_TOOLBOX_HOST"; +const SCREENSIZE_HISTOGRAM = "DEVTOOLS_SCREEN_RESOLUTION_ENUMERATED_PER_USER"; +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const { SourceMapService } = require("./source-map-service"); + +var {Ci, Cu} = require("chrome"); +var promise = require("promise"); +var defer = require("devtools/shared/defer"); +var Services = require("Services"); +var {Task} = require("devtools/shared/task"); +var {gDevTools} = require("devtools/client/framework/devtools"); +var EventEmitter = require("devtools/shared/event-emitter"); +var Telemetry = require("devtools/client/shared/telemetry"); +var HUDService = require("devtools/client/webconsole/hudservice"); +var viewSource = require("devtools/client/shared/view-source"); +var { attachThread, detachThread } = require("./attach-thread"); +var Menu = require("devtools/client/framework/menu"); +var MenuItem = require("devtools/client/framework/menu-item"); +var { DOMHelpers } = require("resource://devtools/client/shared/DOMHelpers.jsm"); +const { KeyCodes } = require("devtools/client/shared/keycodes"); + +const { BrowserLoader } = + Cu.import("resource://devtools/client/shared/browser-loader.js", {}); + +const {LocalizationHelper} = require("devtools/shared/l10n"); +const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties"); + +loader.lazyRequireGetter(this, "CommandUtils", + "devtools/client/shared/developer-toolbar", true); +loader.lazyRequireGetter(this, "getHighlighterUtils", + "devtools/client/framework/toolbox-highlighter-utils", true); +loader.lazyRequireGetter(this, "Selection", + "devtools/client/framework/selection", true); +loader.lazyRequireGetter(this, "InspectorFront", + "devtools/shared/fronts/inspector", true); +loader.lazyRequireGetter(this, "flags", + "devtools/shared/flags"); +loader.lazyRequireGetter(this, "showDoorhanger", + "devtools/client/shared/doorhanger", true); +loader.lazyRequireGetter(this, "createPerformanceFront", + "devtools/shared/fronts/performance", true); +loader.lazyRequireGetter(this, "system", + "devtools/shared/system"); +loader.lazyRequireGetter(this, "getPreferenceFront", + "devtools/shared/fronts/preference", true); +loader.lazyRequireGetter(this, "KeyShortcuts", + "devtools/client/shared/key-shortcuts", true); +loader.lazyRequireGetter(this, "ZoomKeys", + "devtools/client/shared/zoom-keys"); +loader.lazyRequireGetter(this, "settleAll", + "devtools/shared/ThreadSafeDevToolsUtils", true); +loader.lazyRequireGetter(this, "ToolboxButtons", + "devtools/client/definitions", true); + +loader.lazyGetter(this, "registerHarOverlay", () => { + return require("devtools/client/netmonitor/har/toolbox-overlay").register; +}); + +/** + * A "Toolbox" is the component that holds all the tools for one specific + * target. Visually, it's a document that includes the tools tabs and all + * the iframes where the tool panels will be living in. + * + * @param {object} target + * The object the toolbox is debugging. + * @param {string} selectedTool + * Tool to select initially + * @param {Toolbox.HostType} hostType + * Type of host that will host the toolbox (e.g. sidebar, window) + * @param {DOMWindow} contentWindow + * The window object of the toolbox document + * @param {string} frameId + * A unique identifier to differentiate toolbox documents from the + * chrome codebase when passing DOM messages + */ +function Toolbox(target, selectedTool, hostType, contentWindow, frameId) { + this._target = target; + this._win = contentWindow; + this.frameId = frameId; + + this._toolPanels = new Map(); + this._telemetry = new Telemetry(); + if (Services.prefs.getBoolPref("devtools.sourcemap.locations.enabled")) { + this._sourceMapService = new SourceMapService(this._target); + } + + this._initInspector = null; + this._inspector = null; + + // Map of frames (id => frame-info) and currently selected frame id. + this.frameMap = new Map(); + this.selectedFrameId = null; + + this._toolRegistered = this._toolRegistered.bind(this); + this._toolUnregistered = this._toolUnregistered.bind(this); + this._refreshHostTitle = this._refreshHostTitle.bind(this); + this._toggleAutohide = this._toggleAutohide.bind(this); + this.showFramesMenu = this.showFramesMenu.bind(this); + this._updateFrames = this._updateFrames.bind(this); + this._splitConsoleOnKeypress = this._splitConsoleOnKeypress.bind(this); + this.destroy = this.destroy.bind(this); + this.highlighterUtils = getHighlighterUtils(this); + this._highlighterReady = this._highlighterReady.bind(this); + this._highlighterHidden = this._highlighterHidden.bind(this); + this._prefChanged = this._prefChanged.bind(this); + this._saveSplitConsoleHeight = this._saveSplitConsoleHeight.bind(this); + this._onFocus = this._onFocus.bind(this); + this._onBrowserMessage = this._onBrowserMessage.bind(this); + this._showDevEditionPromo = this._showDevEditionPromo.bind(this); + this._updateTextBoxMenuItems = this._updateTextBoxMenuItems.bind(this); + this._onBottomHostMinimized = this._onBottomHostMinimized.bind(this); + this._onBottomHostMaximized = this._onBottomHostMaximized.bind(this); + this._onToolSelectWhileMinimized = this._onToolSelectWhileMinimized.bind(this); + this._onPerformanceFrontEvent = this._onPerformanceFrontEvent.bind(this); + this._onBottomHostWillChange = this._onBottomHostWillChange.bind(this); + this._toggleMinimizeMode = this._toggleMinimizeMode.bind(this); + this._onTabbarFocus = this._onTabbarFocus.bind(this); + this._onTabbarArrowKeypress = this._onTabbarArrowKeypress.bind(this); + this._onPickerClick = this._onPickerClick.bind(this); + this._onPickerKeypress = this._onPickerKeypress.bind(this); + this._onPickerStarted = this._onPickerStarted.bind(this); + this._onPickerStopped = this._onPickerStopped.bind(this); + + this._target.on("close", this.destroy); + + if (!selectedTool) { + selectedTool = Services.prefs.getCharPref(this._prefs.LAST_TOOL); + } + this._defaultToolId = selectedTool; + + this._hostType = hostType; + + EventEmitter.decorate(this); + + this._target.on("navigate", this._refreshHostTitle); + this._target.on("frame-update", this._updateFrames); + + this.on("host-changed", this._refreshHostTitle); + this.on("select", this._refreshHostTitle); + + this.on("ready", this._showDevEditionPromo); + + gDevTools.on("tool-registered", this._toolRegistered); + gDevTools.on("tool-unregistered", this._toolUnregistered); + + this.on("picker-started", this._onPickerStarted); + this.on("picker-stopped", this._onPickerStopped); +} +exports.Toolbox = Toolbox; + +/** + * The toolbox can be 'hosted' either embedded in a browser window + * or in a separate window. + */ +Toolbox.HostType = { + BOTTOM: "bottom", + SIDE: "side", + WINDOW: "window", + CUSTOM: "custom" +}; + +Toolbox.prototype = { + _URL: "about:devtools-toolbox", + + _prefs: { + LAST_TOOL: "devtools.toolbox.selectedTool", + SIDE_ENABLED: "devtools.toolbox.sideEnabled", + }, + + currentToolId: null, + lastUsedToolId: null, + + /** + * Returns a *copy* of the _toolPanels collection. + * + * @return {Map} panels + * All the running panels in the toolbox + */ + getToolPanels: function () { + return new Map(this._toolPanels); + }, + + /** + * Access the panel for a given tool + */ + getPanel: function (id) { + return this._toolPanels.get(id); + }, + + /** + * Get the panel instance for a given tool once it is ready. + * If the tool is already opened, the promise will resolve immediately, + * otherwise it will wait until the tool has been opened before resolving. + * + * Note that this does not open the tool, use selectTool if you'd + * like to select the tool right away. + * + * @param {String} id + * The id of the panel, for example "jsdebugger". + * @returns Promise + * A promise that resolves once the panel is ready. + */ + getPanelWhenReady: function (id) { + let deferred = defer(); + let panel = this.getPanel(id); + if (panel) { + deferred.resolve(panel); + } else { + this.on(id + "-ready", (e, initializedPanel) => { + deferred.resolve(initializedPanel); + }); + } + + return deferred.promise; + }, + + /** + * This is a shortcut for getPanel(currentToolId) because it is much more + * likely that we're going to want to get the panel that we've just made + * visible + */ + getCurrentPanel: function () { + return this._toolPanels.get(this.currentToolId); + }, + + /** + * Get/alter the target of a Toolbox so we're debugging something different. + * See Target.jsm for more details. + * TODO: Do we allow |toolbox.target = null;| ? + */ + get target() { + return this._target; + }, + + get threadClient() { + return this._threadClient; + }, + + /** + * Get/alter the host of a Toolbox, i.e. is it in browser or in a separate + * tab. See HostType for more details. + */ + get hostType() { + return this._hostType; + }, + + /** + * Shortcut to the window containing the toolbox UI + */ + get win() { + return this._win; + }, + + /** + * Shortcut to the document containing the toolbox UI + */ + get doc() { + return this.win.document; + }, + + /** + * Get the toolbox highlighter front. Note that it may not always have been + * initialized first. Use `initInspector()` if needed. + * Consider using highlighterUtils instead, it exposes the highlighter API in + * a useful way for the toolbox panels + */ + get highlighter() { + return this._highlighter; + }, + + /** + * Get the toolbox's performance front. Note that it may not always have been + * initialized first. Use `initPerformance()` if needed. + */ + get performance() { + return this._performance; + }, + + /** + * Get the toolbox's inspector front. Note that it may not always have been + * initialized first. Use `initInspector()` if needed. + */ + get inspector() { + return this._inspector; + }, + + /** + * Get the toolbox's walker front. Note that it may not always have been + * initialized first. Use `initInspector()` if needed. + */ + get walker() { + return this._walker; + }, + + /** + * Get the toolbox's node selection. Note that it may not always have been + * initialized first. Use `initInspector()` if needed. + */ + get selection() { + return this._selection; + }, + + /** + * Get the toggled state of the split console + */ + get splitConsole() { + return this._splitConsole; + }, + + /** + * Get the focused state of the split console + */ + isSplitConsoleFocused: function () { + if (!this._splitConsole) { + return false; + } + let focusedWin = Services.focus.focusedWindow; + return focusedWin && focusedWin === + this.doc.querySelector("#toolbox-panel-iframe-webconsole").contentWindow; + }, + + /** + * Open the toolbox + */ + open: function () { + return Task.spawn(function* () { + this.browserRequire = BrowserLoader({ + window: this.doc.defaultView, + useOnlyShared: true + }).require; + + if (this.win.location.href.startsWith(this._URL)) { + // Update the URL so that onceDOMReady watch for the right url. + this._URL = this.win.location.href; + } + + let domReady = defer(); + let domHelper = new DOMHelpers(this.win); + domHelper.onceDOMReady(() => { + domReady.resolve(); + }, this._URL); + + // Optimization: fire up a few other things before waiting on + // the iframe being ready (makes startup faster) + + // Load the toolbox-level actor fronts and utilities now + yield this._target.makeRemote(); + + // Attach the thread + this._threadClient = yield attachThread(this); + yield domReady.promise; + + this.isReady = true; + let framesPromise = this._listFrames(); + + this.closeButton = this.doc.getElementById("toolbox-close"); + this.closeButton.addEventListener("click", this.destroy, true); + + gDevTools.on("pref-changed", this._prefChanged); + + let framesMenu = this.doc.getElementById("command-button-frames"); + framesMenu.addEventListener("click", this.showFramesMenu, false); + + let noautohideMenu = this.doc.getElementById("command-button-noautohide"); + noautohideMenu.addEventListener("click", this._toggleAutohide, true); + + this.textBoxContextMenuPopup = + this.doc.getElementById("toolbox-textbox-context-popup"); + this.textBoxContextMenuPopup.addEventListener("popupshowing", + this._updateTextBoxMenuItems, true); + + this.shortcuts = new KeyShortcuts({ + window: this.doc.defaultView + }); + this._buildDockButtons(); + this._buildOptions(); + this._buildTabs(); + this._applyCacheSettings(); + this._applyServiceWorkersTestingSettings(); + this._addKeysToWindow(); + this._addReloadKeys(); + this._addHostListeners(); + this._registerOverlays(); + if (!this._hostOptions || this._hostOptions.zoom === true) { + ZoomKeys.register(this.win); + } + + this.tabbar = this.doc.querySelector(".devtools-tabbar"); + this.tabbar.addEventListener("focus", this._onTabbarFocus, true); + this.tabbar.addEventListener("click", this._onTabbarFocus, true); + this.tabbar.addEventListener("keypress", this._onTabbarArrowKeypress); + + this.webconsolePanel = this.doc.querySelector("#toolbox-panel-webconsole"); + this.webconsolePanel.height = Services.prefs.getIntPref(SPLITCONSOLE_HEIGHT_PREF); + this.webconsolePanel.addEventListener("resize", this._saveSplitConsoleHeight); + + let buttonsPromise = this._buildButtons(); + + this._pingTelemetry(); + + // The isTargetSupported check needs to happen after the target is + // remoted, otherwise we could have done it in the toolbox constructor + // (bug 1072764). + let toolDef = gDevTools.getToolDefinition(this._defaultToolId); + if (!toolDef || !toolDef.isTargetSupported(this._target)) { + this._defaultToolId = "webconsole"; + } + + yield this.selectTool(this._defaultToolId); + + // Wait until the original tool is selected so that the split + // console input will receive focus. + let splitConsolePromise = promise.resolve(); + if (Services.prefs.getBoolPref(SPLITCONSOLE_ENABLED_PREF)) { + splitConsolePromise = this.openSplitConsole(); + } + + yield promise.all([ + splitConsolePromise, + buttonsPromise, + framesPromise + ]); + + // Lazily connect to the profiler here and don't wait for it to complete, + // used to intercept console.profile calls before the performance tools are open. + let performanceFrontConnection = this.initPerformance(); + + // If in testing environment, wait for performance connection to finish, + // so we don't have to explicitly wait for this in tests; ideally, all tests + // will handle this on their own, but each have their own tear down function. + if (flags.testing) { + yield performanceFrontConnection; + } + + this.emit("ready"); + }.bind(this)).then(null, console.error.bind(console)); + }, + + /** + * loading React modules when needed (to avoid performance penalties + * during Firefox start up time). + */ + get React() { + return this.browserRequire("devtools/client/shared/vendor/react"); + }, + + get ReactDOM() { + return this.browserRequire("devtools/client/shared/vendor/react-dom"); + }, + + get ReactRedux() { + return this.browserRequire("devtools/client/shared/vendor/react-redux"); + }, + + // Return HostType id for telemetry + _getTelemetryHostId: function () { + switch (this.hostType) { + case Toolbox.HostType.BOTTOM: return 0; + case Toolbox.HostType.SIDE: return 1; + case Toolbox.HostType.WINDOW: return 2; + case Toolbox.HostType.CUSTOM: return 3; + default: return 9; + } + }, + + _pingTelemetry: function () { + this._telemetry.toolOpened("toolbox"); + + this._telemetry.logOncePerBrowserVersion(OS_HISTOGRAM, system.getOSCPU()); + this._telemetry.logOncePerBrowserVersion(OS_IS_64_BITS, + Services.appinfo.is64Bit ? 1 : 0); + this._telemetry.logOncePerBrowserVersion(SCREENSIZE_HISTOGRAM, + system.getScreenDimensions()); + this._telemetry.log(HOST_HISTOGRAM, this._getTelemetryHostId()); + }, + + /** + * Because our panels are lazy loaded this is a good place to watch for + * "pref-changed" events. + * @param {String} event + * The event type, "pref-changed". + * @param {Object} data + * { + * newValue: The new value + * oldValue: The old value + * pref: The name of the preference that has changed + * } + */ + _prefChanged: function (event, data) { + switch (data.pref) { + case "devtools.cache.disabled": + this._applyCacheSettings(); + break; + case "devtools.serviceWorkers.testing.enabled": + this._applyServiceWorkersTestingSettings(); + break; + } + }, + + _buildOptions: function () { + let selectOptions = (name, event) => { + // Flip back to the last used panel if we are already + // on the options panel. + if (this.currentToolId === "options" && + gDevTools.getToolDefinition(this.lastUsedToolId)) { + this.selectTool(this.lastUsedToolId); + } else { + this.selectTool("options"); + } + // Prevent the opening of bookmarks window on toolbox.options.key + event.preventDefault(); + }; + this.shortcuts.on(L10N.getStr("toolbox.options.key"), selectOptions); + this.shortcuts.on(L10N.getStr("toolbox.help.key"), selectOptions); + }, + + _splitConsoleOnKeypress: function (e) { + if (e.keyCode === KeyCodes.DOM_VK_ESCAPE) { + this.toggleSplitConsole(); + // If the debugger is paused, don't let the ESC key stop any pending + // navigation. + if (this._threadClient.state == "paused") { + e.preventDefault(); + } + } + }, + + /** + * Add a shortcut key that should work when a split console + * has focus to the toolbox. + * + * @param {String} key + * The electron key shortcut. + * @param {Function} handler + * The callback that should be called when the provided key shortcut is pressed. + * @param {String} whichTool + * The tool the key belongs to. The corresponding handler will only be triggered + * if this tool is active. + */ + useKeyWithSplitConsole: function (key, handler, whichTool) { + this.shortcuts.on(key, (name, event) => { + if (this.currentToolId === whichTool && this.isSplitConsoleFocused()) { + handler(); + event.preventDefault(); + } + }); + }, + + _addReloadKeys: function () { + [ + ["reload", false], + ["reload2", false], + ["forceReload", true], + ["forceReload2", true] + ].forEach(([id, force]) => { + let key = L10N.getStr("toolbox." + id + ".key"); + this.shortcuts.on(key, (name, event) => { + this.reloadTarget(force); + + // Prevent Firefox shortcuts from reloading the page + event.preventDefault(); + }); + }); + }, + + _addHostListeners: function () { + this.shortcuts.on(L10N.getStr("toolbox.nextTool.key"), + (name, event) => { + this.selectNextTool(); + event.preventDefault(); + }); + this.shortcuts.on(L10N.getStr("toolbox.previousTool.key"), + (name, event) => { + this.selectPreviousTool(); + event.preventDefault(); + }); + this.shortcuts.on(L10N.getStr("toolbox.minimize.key"), + (name, event) => { + this._toggleMinimizeMode(); + event.preventDefault(); + }); + this.shortcuts.on(L10N.getStr("toolbox.toggleHost.key"), + (name, event) => { + this.switchToPreviousHost(); + event.preventDefault(); + }); + + this.doc.addEventListener("keypress", this._splitConsoleOnKeypress, false); + this.doc.addEventListener("focus", this._onFocus, true); + this.win.addEventListener("unload", this.destroy); + this.win.addEventListener("message", this._onBrowserMessage, true); + }, + + _removeHostListeners: function () { + // The host iframe's contentDocument may already be gone. + if (this.doc) { + this.doc.removeEventListener("keypress", this._splitConsoleOnKeypress, false); + this.doc.removeEventListener("focus", this._onFocus, true); + this.win.removeEventListener("unload", this.destroy); + this.win.removeEventListener("message", this._onBrowserMessage, true); + } + }, + + // Called whenever the chrome send a message + _onBrowserMessage: function (event) { + if (!event.data) { + return; + } + switch (event.data.name) { + case "switched-host": + this._onSwitchedHost(event.data); + break; + case "host-minimized": + if (this.hostType == Toolbox.HostType.BOTTOM) { + this._onBottomHostMinimized(); + } + break; + case "host-maximized": + if (this.hostType == Toolbox.HostType.BOTTOM) { + this._onBottomHostMaximized(); + } + break; + } + }, + + _registerOverlays: function () { + registerHarOverlay(this); + }, + + _saveSplitConsoleHeight: function () { + Services.prefs.setIntPref(SPLITCONSOLE_HEIGHT_PREF, + this.webconsolePanel.height); + }, + + /** + * Make sure that the console is showing up properly based on all the + * possible conditions. + * 1) If the console tab is selected, then regardless of split state + * it should take up the full height of the deck, and we should + * hide the deck and splitter. + * 2) If the console tab is not selected and it is split, then we should + * show the splitter, deck, and console. + * 3) If the console tab is not selected and it is *not* split, + * then we should hide the console and splitter, and show the deck + * at full height. + */ + _refreshConsoleDisplay: function () { + let deck = this.doc.getElementById("toolbox-deck"); + let webconsolePanel = this.webconsolePanel; + let splitter = this.doc.getElementById("toolbox-console-splitter"); + let openedConsolePanel = this.currentToolId === "webconsole"; + + if (openedConsolePanel) { + deck.setAttribute("collapsed", "true"); + splitter.setAttribute("hidden", "true"); + webconsolePanel.removeAttribute("collapsed"); + } else { + deck.removeAttribute("collapsed"); + if (this.splitConsole) { + webconsolePanel.removeAttribute("collapsed"); + splitter.removeAttribute("hidden"); + } else { + webconsolePanel.setAttribute("collapsed", "true"); + splitter.setAttribute("hidden", "true"); + } + } + }, + + /** + * Adds the keys and commands to the Toolbox Window in window mode. + */ + _addKeysToWindow: function () { + if (this.hostType != Toolbox.HostType.WINDOW) { + return; + } + + let doc = this.win.parent.document; + + for (let [id, toolDefinition] of gDevTools.getToolDefinitionMap()) { + // Prevent multiple entries for the same tool. + if (!toolDefinition.key || doc.getElementById("key_" + id)) { + continue; + } + + let toolId = id; + let key = doc.createElement("key"); + + key.id = "key_" + toolId; + + if (toolDefinition.key.startsWith("VK_")) { + key.setAttribute("keycode", toolDefinition.key); + } else { + key.setAttribute("key", toolDefinition.key); + } + + key.setAttribute("modifiers", toolDefinition.modifiers); + // needed. See bug 371900 + key.setAttribute("oncommand", "void(0);"); + key.addEventListener("command", () => { + this.selectTool(toolId).then(() => this.fireCustomKey(toolId)); + }, true); + doc.getElementById("toolbox-keyset").appendChild(key); + } + + // Add key for toggling the browser console from the detached window + if (!doc.getElementById("key_browserconsole")) { + let key = doc.createElement("key"); + key.id = "key_browserconsole"; + + key.setAttribute("key", L10N.getStr("browserConsoleCmd.commandkey")); + key.setAttribute("modifiers", "accel,shift"); + // needed. See bug 371900 + key.setAttribute("oncommand", "void(0)"); + key.addEventListener("command", () => { + HUDService.toggleBrowserConsole(); + }, true); + doc.getElementById("toolbox-keyset").appendChild(key); + } + }, + + /** + * Handle any custom key events. Returns true if there was a custom key + * binding run. + * @param {string} toolId Which tool to run the command on (skip if not + * current) + */ + fireCustomKey: function (toolId) { + let toolDefinition = gDevTools.getToolDefinition(toolId); + + if (toolDefinition.onkey && + ((this.currentToolId === toolId) || + (toolId == "webconsole" && this.splitConsole))) { + toolDefinition.onkey(this.getCurrentPanel(), this); + } + }, + + /** + * Build the notification box as soon as needed. + */ + get notificationBox() { + if (!this._notificationBox) { + let { NotificationBox, PriorityLevels } = + this.browserRequire( + "devtools/client/shared/components/notification-box"); + + NotificationBox = this.React.createFactory(NotificationBox); + + // Render NotificationBox and assign priority levels to it. + let box = this.doc.getElementById("toolbox-notificationbox"); + this._notificationBox = Object.assign( + this.ReactDOM.render(NotificationBox({}), box), + PriorityLevels); + } + return this._notificationBox; + }, + + /** + * Build the buttons for changing hosts. Called every time + * the host changes. + */ + _buildDockButtons: function () { + let dockBox = this.doc.getElementById("toolbox-dock-buttons"); + + while (dockBox.firstChild) { + dockBox.removeChild(dockBox.firstChild); + } + + if (!this._target.isLocalTab) { + return; + } + + // Bottom-type host can be minimized, add a button for this. + if (this.hostType == Toolbox.HostType.BOTTOM) { + let minimizeBtn = this.doc.createElementNS(HTML_NS, "button"); + minimizeBtn.id = "toolbox-dock-bottom-minimize"; + minimizeBtn.className = "devtools-button"; + /* Bug 1177463 - The minimize button is currently hidden until we agree on + the UI for it, and until bug 1173849 is fixed too. */ + minimizeBtn.setAttribute("hidden", "true"); + + minimizeBtn.addEventListener("click", this._toggleMinimizeMode); + dockBox.appendChild(minimizeBtn); + // Show the button in its maximized state. + this._onBottomHostMaximized(); + + // Maximize again when a tool gets selected. + this.on("before-select", this._onToolSelectWhileMinimized); + // Maximize and stop listening before the host type changes. + this.once("host-will-change", this._onBottomHostWillChange); + } + + if (this.hostType == Toolbox.HostType.WINDOW) { + this.closeButton.setAttribute("hidden", "true"); + } else { + this.closeButton.removeAttribute("hidden"); + } + + let sideEnabled = Services.prefs.getBoolPref(this._prefs.SIDE_ENABLED); + + for (let type in Toolbox.HostType) { + let position = Toolbox.HostType[type]; + if (position == this.hostType || + position == Toolbox.HostType.CUSTOM || + (!sideEnabled && position == Toolbox.HostType.SIDE)) { + continue; + } + + let button = this.doc.createElementNS(HTML_NS, "button"); + button.id = "toolbox-dock-" + position; + button.className = "toolbox-dock-button devtools-button"; + button.setAttribute("title", L10N.getStr("toolboxDockButtons." + + position + ".tooltip")); + button.addEventListener("click", this.switchHost.bind(this, position)); + + dockBox.appendChild(button); + } + }, + + _getMinimizeButtonShortcutTooltip: function () { + let str = L10N.getStr("toolbox.minimize.key"); + let key = KeyShortcuts.parseElectronKey(this.win, str); + return "(" + KeyShortcuts.stringify(key) + ")"; + }, + + _onBottomHostMinimized: function () { + let btn = this.doc.querySelector("#toolbox-dock-bottom-minimize"); + btn.className = "minimized"; + + btn.setAttribute("title", + L10N.getStr("toolboxDockButtons.bottom.maximize") + " " + + this._getMinimizeButtonShortcutTooltip()); + }, + + _onBottomHostMaximized: function () { + let btn = this.doc.querySelector("#toolbox-dock-bottom-minimize"); + btn.className = "maximized"; + + btn.setAttribute("title", + L10N.getStr("toolboxDockButtons.bottom.minimize") + " " + + this._getMinimizeButtonShortcutTooltip()); + }, + + _onToolSelectWhileMinimized: function () { + this.postMessage({ + name: "maximize-host" + }); + }, + + postMessage: function (msg) { + // We sometime try to send messages in middle of destroy(), where the + // toolbox iframe may already be detached and no longer have a parent. + if (this.win.parent) { + // Toolbox document is still chrome and disallow identifying message + // origin via event.source as it is null. So use a custom id. + msg.frameId = this.frameId; + this.win.parent.postMessage(msg, "*"); + } + }, + + _onBottomHostWillChange: function () { + this.postMessage({ + name: "maximize-host" + }); + + this.off("before-select", this._onToolSelectWhileMinimized); + }, + + _toggleMinimizeMode: function () { + if (this.hostType !== Toolbox.HostType.BOTTOM) { + return; + } + + // Calculate the height to which the host should be minimized so the + // tabbar is still visible. + let toolbarHeight = this.tabbar.getBoxQuads({box: "content"})[0].bounds + .height; + this.postMessage({ + name: "toggle-minimize-mode", + toolbarHeight + }); + }, + + /** + * Add tabs to the toolbox UI for registered tools + */ + _buildTabs: function () { + for (let definition of gDevTools.getToolDefinitionArray()) { + this._buildTabForTool(definition); + } + }, + + /** + * Get all dev tools tab bar focusable elements. These are visible elements + * such as buttons or elements with tabindex. + */ + get tabbarFocusableElms() { + return [...this.tabbar.querySelectorAll( + "[tabindex]:not([hidden]), button:not([hidden])")]; + }, + + /** + * Reset tabindex attributes across all focusable elements inside the tabbar. + * Only have one element with tabindex=0 at a time to make sure that tabbing + * results in navigating away from the tabbar container. + * @param {FocusEvent} event + */ + _onTabbarFocus: function (event) { + this.tabbarFocusableElms.forEach(elm => + elm.setAttribute("tabindex", event.target === elm ? "0" : "-1")); + }, + + /** + * On left/right arrow press, attempt to move the focus inside the tabbar to + * the previous/next focusable element. + * @param {KeyboardEvent} event + */ + _onTabbarArrowKeypress: function (event) { + let { key, target, ctrlKey, shiftKey, altKey, metaKey } = event; + + // If any of the modifier keys are pressed do not attempt navigation as it + // might conflict with global shortcuts (Bug 1327972). + if (ctrlKey || shiftKey || altKey || metaKey) { + return; + } + + let focusableElms = this.tabbarFocusableElms; + let curIndex = focusableElms.indexOf(target); + + if (curIndex === -1) { + console.warn(target + " is not found among Developer Tools tab bar " + + "focusable elements. It needs to either be a button or have " + + "tabindex. If it is intended to be hidden, 'hidden' attribute must " + + "be used."); + return; + } + + let newTarget; + + if (key === "ArrowLeft") { + // Do nothing if already at the beginning. + if (curIndex === 0) { + return; + } + newTarget = focusableElms[curIndex - 1]; + } else if (key === "ArrowRight") { + // Do nothing if already at the end. + if (curIndex === focusableElms.length - 1) { + return; + } + newTarget = focusableElms[curIndex + 1]; + } else { + return; + } + + focusableElms.forEach(elm => + elm.setAttribute("tabindex", newTarget === elm ? "0" : "-1")); + newTarget.focus(); + + event.preventDefault(); + event.stopPropagation(); + }, + + /** + * Add buttons to the UI as specified in the devtools.toolbox.toolbarSpec pref + */ + _buildButtons: function () { + if (this.target.getTrait("highlightable")) { + this._buildPickerButton(); + } + + this.setToolboxButtonsVisibility(); + + // Old servers don't have a GCLI Actor, so just return + if (!this.target.hasActor("gcli")) { + return promise.resolve(); + } + // Disable gcli in browser toolbox until there is usages of it + if (this.target.chrome) { + return promise.resolve(); + } + + const options = { + environment: CommandUtils.createEnvironment(this, "_target") + }; + return CommandUtils.createRequisition(this.target, options).then(requisition => { + this._requisition = requisition; + + const spec = CommandUtils.getCommandbarSpec("devtools.toolbox.toolbarSpec"); + return CommandUtils.createButtons(spec, this.target, this.doc, requisition) + .then(buttons => { + let container = this.doc.getElementById("toolbox-buttons"); + buttons.forEach(button => { + if (button) { + container.appendChild(button); + } + }); + this.setToolboxButtonsVisibility(); + }); + }); + }, + + /** + * Adding the element picker button is done here unlike the other buttons + * since we want it to work for remote targets too + */ + _buildPickerButton: function () { + this._pickerButton = this.doc.createElementNS(HTML_NS, "button"); + this._pickerButton.id = "command-button-pick"; + this._pickerButton.className = + "command-button command-button-invertable devtools-button"; + this._pickerButton.setAttribute("title", L10N.getStr("pickButton.tooltip")); + + let container = this.doc.querySelector("#toolbox-picker-container"); + container.appendChild(this._pickerButton); + + this._pickerButton.addEventListener("click", this._onPickerClick, false); + }, + + /** + * Toggle the picker, but also decide whether or not the highlighter should + * focus the window. This is only desirable when the toolbox is mounted to the + * window. When devtools is free floating, then the target window should not + * pop in front of the viewer when the picker is clicked. + */ + _onPickerClick: function () { + let focus = this.hostType === Toolbox.HostType.BOTTOM || + this.hostType === Toolbox.HostType.SIDE; + this.highlighterUtils.togglePicker(focus); + }, + + /** + * If the picker is activated, then allow the Escape key to deactivate the + * functionality instead of the default behavior of toggling the console. + */ + _onPickerKeypress: function (event) { + if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) { + this.highlighterUtils.cancelPicker(); + // Stop the console from toggling. + event.stopImmediatePropagation(); + } + }, + + _onPickerStarted: function () { + this.doc.addEventListener("keypress", this._onPickerKeypress, true); + }, + + _onPickerStopped: function () { + this.doc.removeEventListener("keypress", this._onPickerKeypress, true); + }, + + /** + * Apply the current cache setting from devtools.cache.disabled to this + * toolbox's tab. + */ + _applyCacheSettings: function () { + let pref = "devtools.cache.disabled"; + let cacheDisabled = Services.prefs.getBoolPref(pref); + + if (this.target.activeTab) { + this.target.activeTab.reconfigure({"cacheDisabled": cacheDisabled}); + } + }, + + /** + * Apply the current service workers testing setting from + * devtools.serviceWorkers.testing.enabled to this toolbox's tab. + */ + _applyServiceWorkersTestingSettings: function () { + let pref = "devtools.serviceWorkers.testing.enabled"; + let serviceWorkersTestingEnabled = + Services.prefs.getBoolPref(pref) || false; + + if (this.target.activeTab) { + this.target.activeTab.reconfigure({ + "serviceWorkersTestingEnabled": serviceWorkersTestingEnabled + }); + } + }, + + /** + * Setter for the checked state of the picker button in the toolbar + * @param {Boolean} isChecked + */ + set pickerButtonChecked(isChecked) { + if (isChecked) { + this._pickerButton.setAttribute("checked", "true"); + } else { + this._pickerButton.removeAttribute("checked"); + } + }, + + /** + * Return all toolbox buttons (command buttons, plus any others that were + * added manually). + */ + get toolboxButtons() { + return ToolboxButtons.map(options => { + let button = this.doc.getElementById(options.id); + // Some buttons may not exist inside of Browser Toolbox + if (!button) { + return false; + } + + return { + id: options.id, + button: button, + label: button.getAttribute("title"), + visibilityswitch: "devtools." + options.id + ".enabled", + isTargetSupported: options.isTargetSupported + ? options.isTargetSupported + : target => target.isLocalTab, + }; + }).filter(button=>button); + }, + + /** + * Ensure the visibility of each toolbox button matches the + * preference value. Simply hide buttons that are preffed off. + */ + setToolboxButtonsVisibility: function () { + this.toolboxButtons.forEach(buttonSpec => { + let { visibilityswitch, button, isTargetSupported } = buttonSpec; + let on = true; + try { + on = Services.prefs.getBoolPref(visibilityswitch); + } catch (ex) { + // Do nothing. + } + + on = on && isTargetSupported(this.target); + + if (button) { + if (on) { + button.removeAttribute("hidden"); + } else { + button.setAttribute("hidden", "true"); + } + } + }); + + this._updateNoautohideButton(); + }, + + /** + * Build a tab for one tool definition and add to the toolbox + * + * @param {string} toolDefinition + * Tool definition of the tool to build a tab for. + */ + _buildTabForTool: function (toolDefinition) { + if (!toolDefinition.isTargetSupported(this._target)) { + return; + } + + let tabs = this.doc.getElementById("toolbox-tabs"); + let deck = this.doc.getElementById("toolbox-deck"); + + let id = toolDefinition.id; + + if (toolDefinition.ordinal == undefined || toolDefinition.ordinal < 0) { + toolDefinition.ordinal = MAX_ORDINAL; + } + + let radio = this.doc.createElement("radio"); + // The radio element is not being used in the conventional way, thus + // the devtools-tab class replaces the radio XBL binding with its base + // binding (the control-item binding). + radio.className = "devtools-tab"; + radio.id = "toolbox-tab-" + id; + radio.setAttribute("toolid", id); + radio.setAttribute("tabindex", "0"); + radio.setAttribute("ordinal", toolDefinition.ordinal); + radio.setAttribute("tooltiptext", toolDefinition.tooltip); + if (toolDefinition.invertIconForLightTheme) { + radio.setAttribute("icon-invertable", "light-theme"); + } else if (toolDefinition.invertIconForDarkTheme) { + radio.setAttribute("icon-invertable", "dark-theme"); + } + + radio.addEventListener("command", this.selectTool.bind(this, id)); + + // spacer lets us center the image and label, while allowing cropping + let spacer = this.doc.createElement("spacer"); + spacer.setAttribute("flex", "1"); + radio.appendChild(spacer); + + if (toolDefinition.icon) { + let image = this.doc.createElement("image"); + image.className = "default-icon"; + image.setAttribute("src", + toolDefinition.icon || toolDefinition.highlightedicon); + radio.appendChild(image); + // Adding the highlighted icon image + image = this.doc.createElement("image"); + image.className = "highlighted-icon"; + image.setAttribute("src", + toolDefinition.highlightedicon || toolDefinition.icon); + radio.appendChild(image); + } + + if (toolDefinition.label && !toolDefinition.iconOnly) { + let label = this.doc.createElement("label"); + label.setAttribute("value", toolDefinition.label); + label.setAttribute("crop", "end"); + label.setAttribute("flex", "1"); + radio.appendChild(label); + } + + if (!toolDefinition.bgTheme) { + toolDefinition.bgTheme = "theme-toolbar"; + } + let vbox = this.doc.createElement("vbox"); + vbox.className = "toolbox-panel " + toolDefinition.bgTheme; + + // There is already a container for the webconsole frame. + if (!this.doc.getElementById("toolbox-panel-" + id)) { + vbox.id = "toolbox-panel-" + id; + } + + if (id === "options") { + // Options panel is special. It doesn't belong in the same container as + // the other tabs. + radio.setAttribute("role", "button"); + let optionTabContainer = this.doc.getElementById("toolbox-option-container"); + optionTabContainer.appendChild(radio); + deck.appendChild(vbox); + } else { + radio.setAttribute("role", "tab"); + + // If there is no tab yet, or the ordinal to be added is the largest one. + if (tabs.childNodes.length == 0 || + tabs.lastChild.getAttribute("ordinal") <= toolDefinition.ordinal) { + tabs.appendChild(radio); + deck.appendChild(vbox); + } else { + // else, iterate over all the tabs to get the correct location. + Array.some(tabs.childNodes, (node, i) => { + if (+node.getAttribute("ordinal") > toolDefinition.ordinal) { + tabs.insertBefore(radio, node); + deck.insertBefore(vbox, deck.childNodes[i]); + return true; + } + return false; + }); + } + } + + this._addKeysToWindow(); + }, + + /** + * Ensure the tool with the given id is loaded. + * + * @param {string} id + * The id of the tool to load. + */ + loadTool: function (id) { + if (id === "inspector" && !this._inspector) { + return this.initInspector().then(() => { + return this.loadTool(id); + }); + } + + let deferred = defer(); + let iframe = this.doc.getElementById("toolbox-panel-iframe-" + id); + + if (iframe) { + let panel = this._toolPanels.get(id); + if (panel) { + deferred.resolve(panel); + } else { + this.once(id + "-ready", initializedPanel => { + deferred.resolve(initializedPanel); + }); + } + return deferred.promise; + } + + let definition = gDevTools.getToolDefinition(id); + if (!definition) { + deferred.reject(new Error("no such tool id " + id)); + return deferred.promise; + } + + iframe = this.doc.createElement("iframe"); + iframe.className = "toolbox-panel-iframe"; + iframe.id = "toolbox-panel-iframe-" + id; + iframe.setAttribute("flex", 1); + iframe.setAttribute("forceOwnRefreshDriver", ""); + iframe.tooltip = "aHTMLTooltip"; + iframe.style.visibility = "hidden"; + + gDevTools.emit(id + "-init", this, iframe); + this.emit(id + "-init", iframe); + + // If no parent yet, append the frame into default location. + if (!iframe.parentNode) { + let vbox = this.doc.getElementById("toolbox-panel-" + id); + vbox.appendChild(iframe); + } + + let onLoad = () => { + // Prevent flicker while loading by waiting to make visible until now. + iframe.style.visibility = "visible"; + + // Try to set the dir attribute as early as possible. + this.setIframeDocumentDir(iframe); + + // The build method should return a panel instance, so events can + // be fired with the panel as an argument. However, in order to keep + // backward compatibility with existing extensions do a check + // for a promise return value. + let built = definition.build(iframe.contentWindow, this); + + if (!(typeof built.then == "function")) { + let panel = built; + iframe.panel = panel; + + // The panel instance is expected to fire (and listen to) various + // framework events, so make sure it's properly decorated with + // appropriate API (on, off, once, emit). + // In this case we decorate panel instances directly returned by + // the tool definition 'build' method. + if (typeof panel.emit == "undefined") { + EventEmitter.decorate(panel); + } + + gDevTools.emit(id + "-build", this, panel); + this.emit(id + "-build", panel); + + // The panel can implement an 'open' method for asynchronous + // initialization sequence. + if (typeof panel.open == "function") { + built = panel.open(); + } else { + let buildDeferred = defer(); + buildDeferred.resolve(panel); + built = buildDeferred.promise; + } + } + + // Wait till the panel is fully ready and fire 'ready' events. + promise.resolve(built).then((panel) => { + this._toolPanels.set(id, panel); + + // Make sure to decorate panel object with event API also in case + // where the tool definition 'build' method returns only a promise + // and the actual panel instance is available as soon as the + // promise is resolved. + if (typeof panel.emit == "undefined") { + EventEmitter.decorate(panel); + } + + gDevTools.emit(id + "-ready", this, panel); + this.emit(id + "-ready", panel); + + deferred.resolve(panel); + }, console.error); + }; + + iframe.setAttribute("src", definition.url); + if (definition.panelLabel) { + iframe.setAttribute("aria-label", definition.panelLabel); + } + + // Depending on the host, iframe.contentWindow is not always + // defined at this moment. If it is not defined, we use an + // event listener on the iframe DOM node. If it's defined, + // we use the chromeEventHandler. We can't use a listener + // on the DOM node every time because this won't work + // if the (xul chrome) iframe is loaded in a content docshell. + if (iframe.contentWindow) { + let domHelper = new DOMHelpers(iframe.contentWindow); + domHelper.onceDOMReady(onLoad); + } else { + let callback = () => { + iframe.removeEventListener("DOMContentLoaded", callback); + onLoad(); + }; + + iframe.addEventListener("DOMContentLoaded", callback); + } + + return deferred.promise; + }, + + /** + * Set the dir attribute on the content document element of the provided iframe. + * + * @param {IFrameElement} iframe + */ + setIframeDocumentDir: function (iframe) { + let docEl = iframe.contentWindow && iframe.contentWindow.document.documentElement; + if (!docEl || docEl.namespaceURI !== HTML_NS) { + // Bail out if the content window or document is not ready or if the document is not + // HTML. + return; + } + + if (docEl.hasAttribute("dir")) { + // Set the dir attribute value only if dir is already present on the document. + let top = this.win.top; + let topDocEl = top.document.documentElement; + let isRtl = top.getComputedStyle(topDocEl).direction === "rtl"; + docEl.setAttribute("dir", isRtl ? "rtl" : "ltr"); + } + }, + + /** + * Mark all in collection as unselected; and id as selected + * @param {string} collection + * DOM collection of items + * @param {string} id + * The Id of the item within the collection to select + */ + selectSingleNode: function (collection, id) { + [...collection].forEach(node => { + if (node.id === id) { + node.setAttribute("selected", "true"); + node.setAttribute("aria-selected", "true"); + } else { + node.removeAttribute("selected"); + node.removeAttribute("aria-selected"); + } + }); + }, + + /** + * Switch to the tool with the given id + * + * @param {string} id + * The id of the tool to switch to + */ + selectTool: function (id) { + this.emit("before-select", id); + + let tabs = this.doc.querySelectorAll(".devtools-tab"); + this.selectSingleNode(tabs, "toolbox-tab-" + id); + + // If options is selected, the separator between it and the + // command buttons should be hidden. + let sep = this.doc.getElementById("toolbox-controls-separator"); + if (id === "options") { + sep.setAttribute("invisible", "true"); + } else { + sep.removeAttribute("invisible"); + } + + if (this.currentToolId == id) { + let panel = this._toolPanels.get(id); + if (panel) { + // We have a panel instance, so the tool is already fully loaded. + + // re-focus tool to get key events again + this.focusTool(id); + + // Return the existing panel in order to have a consistent return value. + return promise.resolve(panel); + } + // Otherwise, if there is no panel instance, it is still loading, + // so we are racing another call to selectTool with the same id. + return this.once("select").then(() => promise.resolve(this._toolPanels.get(id))); + } + + if (!this.isReady) { + throw new Error("Can't select tool, wait for toolbox 'ready' event"); + } + + let tab = this.doc.getElementById("toolbox-tab-" + id); + + if (tab) { + if (this.currentToolId) { + this._telemetry.toolClosed(this.currentToolId); + } + this._telemetry.toolOpened(id); + } else { + throw new Error("No tool found"); + } + + let tabstrip = this.doc.getElementById("toolbox-tabs"); + + // select the right tab, making 0th index the default tab if right tab not + // found. + tabstrip.selectedItem = tab || tabstrip.childNodes[0]; + + // and select the right iframe + let toolboxPanels = this.doc.querySelectorAll(".toolbox-panel"); + this.selectSingleNode(toolboxPanels, "toolbox-panel-" + id); + + this.lastUsedToolId = this.currentToolId; + this.currentToolId = id; + this._refreshConsoleDisplay(); + if (id != "options") { + Services.prefs.setCharPref(this._prefs.LAST_TOOL, id); + } + + return this.loadTool(id).then(panel => { + // focus the tool's frame to start receiving key events + this.focusTool(id); + + this.emit("select", id); + this.emit(id + "-selected", panel); + return panel; + }); + }, + + /** + * Focus a tool's panel by id + * @param {string} id + * The id of tool to focus + */ + focusTool: function (id, state = true) { + let iframe = this.doc.getElementById("toolbox-panel-iframe-" + id); + + if (state) { + iframe.focus(); + } else { + iframe.blur(); + } + }, + + /** + * Focus split console's input line + */ + focusConsoleInput: function () { + let consolePanel = this.getPanel("webconsole"); + if (consolePanel) { + consolePanel.focusInput(); + } + }, + + /** + * If the console is split and we are focusing an element outside + * of the console, then store the newly focused element, so that + * it can be restored once the split console closes. + */ + _onFocus: function ({originalTarget}) { + // Ignore any non element nodes, or any elements contained + // within the webconsole frame. + let webconsoleURL = gDevTools.getToolDefinition("webconsole").url; + if (originalTarget.nodeType !== 1 || + originalTarget.baseURI === webconsoleURL) { + return; + } + + this._lastFocusedElement = originalTarget; + }, + + /** + * Opens the split console. + * + * @returns {Promise} a promise that resolves once the tool has been + * loaded and focused. + */ + openSplitConsole: function () { + this._splitConsole = true; + Services.prefs.setBoolPref(SPLITCONSOLE_ENABLED_PREF, true); + this._refreshConsoleDisplay(); + this.emit("split-console"); + + return this.loadTool("webconsole").then(() => { + this.focusConsoleInput(); + }); + }, + + /** + * Closes the split console. + * + * @returns {Promise} a promise that resolves once the tool has been + * closed. + */ + closeSplitConsole: function () { + this._splitConsole = false; + Services.prefs.setBoolPref(SPLITCONSOLE_ENABLED_PREF, false); + this._refreshConsoleDisplay(); + this.emit("split-console"); + + if (this._lastFocusedElement) { + this._lastFocusedElement.focus(); + } + return promise.resolve(); + }, + + /** + * Toggles the split state of the webconsole. If the webconsole panel + * is already selected then this command is ignored. + * + * @returns {Promise} a promise that resolves once the tool has been + * opened or closed. + */ + toggleSplitConsole: function () { + if (this.currentToolId !== "webconsole") { + return this.splitConsole ? + this.closeSplitConsole() : + this.openSplitConsole(); + } + + return promise.resolve(); + }, + + /** + * Tells the target tab to reload. + */ + reloadTarget: function (force) { + this.target.activeTab.reload({ force: force }); + }, + + /** + * Loads the tool next to the currently selected tool. + */ + selectNextTool: function () { + let tools = this.doc.querySelectorAll(".devtools-tab"); + let selected = this.doc.querySelector(".devtools-tab[selected]"); + let nextIndex = [...tools].indexOf(selected) + 1; + let next = tools[nextIndex] || tools[0]; + let tool = next.getAttribute("toolid"); + return this.selectTool(tool); + }, + + /** + * Loads the tool just left to the currently selected tool. + */ + selectPreviousTool: function () { + let tools = this.doc.querySelectorAll(".devtools-tab"); + let selected = this.doc.querySelector(".devtools-tab[selected]"); + let prevIndex = [...tools].indexOf(selected) - 1; + let prev = tools[prevIndex] || tools[tools.length - 1]; + let tool = prev.getAttribute("toolid"); + return this.selectTool(tool); + }, + + /** + * Highlights the tool's tab if it is not the currently selected tool. + * + * @param {string} id + * The id of the tool to highlight + */ + highlightTool: function (id) { + let tab = this.doc.getElementById("toolbox-tab-" + id); + tab && tab.setAttribute("highlighted", "true"); + }, + + /** + * De-highlights the tool's tab. + * + * @param {string} id + * The id of the tool to unhighlight + */ + unhighlightTool: function (id) { + let tab = this.doc.getElementById("toolbox-tab-" + id); + tab && tab.removeAttribute("highlighted"); + }, + + /** + * Raise the toolbox host. + */ + raise: function () { + this.postMessage({ + name: "raise-host" + }); + }, + + /** + * Refresh the host's title. + */ + _refreshHostTitle: function () { + let title; + if (this.target.name && this.target.name != this.target.url) { + title = L10N.getFormatStr("toolbox.titleTemplate2", this.target.name, + this.target.url); + } else { + title = L10N.getFormatStr("toolbox.titleTemplate1", this.target.url); + } + this.postMessage({ + name: "set-host-title", + title + }); + }, + + // Returns an instance of the preference actor + get _preferenceFront() { + return this.target.root.then(rootForm => { + return getPreferenceFront(this.target.client, rootForm); + }); + }, + + _toggleAutohide: Task.async(function* () { + let prefName = "ui.popup.disable_autohide"; + let front = yield this._preferenceFront; + let current = yield front.getBoolPref(prefName); + yield front.setBoolPref(prefName, !current); + + this._updateNoautohideButton(); + }), + + _updateNoautohideButton: Task.async(function* () { + let menu = this.doc.getElementById("command-button-noautohide"); + if (menu.getAttribute("hidden") === "true") { + return; + } + if (!this.target.root) { + return; + } + let prefName = "ui.popup.disable_autohide"; + let front = yield this._preferenceFront; + let current = yield front.getBoolPref(prefName); + if (current) { + menu.setAttribute("checked", "true"); + } else { + menu.removeAttribute("checked"); + } + }), + + _listFrames: function (event) { + if (!this._target.activeTab || !this._target.activeTab.traits.frames) { + // We are not targetting a regular TabActor + // it can be either an addon or browser toolbox actor + return promise.resolve(); + } + let packet = { + to: this._target.form.actor, + type: "listFrames" + }; + return this._target.client.request(packet, resp => { + this._updateFrames(null, { frames: resp.frames }); + }); + }, + + /** + * Show a drop down menu that allows the user to switch frames. + */ + showFramesMenu: function (event) { + let menu = new Menu(); + let target = event.target; + + // Generate list of menu items from the list of frames. + this.frameMap.forEach(frame => { + // A frame is checked if it's the selected one. + let checked = frame.id == this.selectedFrameId; + + // Create menu item. + menu.append(new MenuItem({ + label: frame.url, + type: "radio", + checked, + click: () => { + this.onSelectFrame(frame.id); + } + })); + }); + + menu.once("open").then(() => { + target.setAttribute("open", "true"); + }); + + menu.once("close").then(() => { + target.removeAttribute("open"); + }); + + // Show a drop down menu with frames. + // XXX Missing menu API for specifying target (anchor) + // and relative position to it. See also: + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/Method/openPopup + // https://bugzilla.mozilla.org/show_bug.cgi?id=1274551 + let rect = target.getBoundingClientRect(); + let screenX = target.ownerDocument.defaultView.mozInnerScreenX; + let screenY = target.ownerDocument.defaultView.mozInnerScreenY; + menu.popup(rect.left + screenX, rect.bottom + screenY, this); + + return menu; + }, + + /** + * Select a frame by sending 'switchToFrame' packet to the backend. + */ + onSelectFrame: function (frameId) { + // Send packet to the backend to select specified frame and + // wait for 'frameUpdate' event packet to update the UI. + let packet = { + to: this._target.form.actor, + type: "switchToFrame", + windowId: frameId + }; + this._target.client.request(packet); + }, + + /** + * A handler for 'frameUpdate' packets received from the backend. + * Following properties might be set on the packet: + * + * destroyAll {Boolean}: All frames have been destroyed. + * selected {Number}: A frame has been selected + * frames {Array}: list of frames. Every frame can have: + * id {Number}: frame ID + * url {String}: frame URL + * title {String}: frame title + * destroy {Boolean}: Set to true if destroyed + * parentID {Number}: ID of the parent frame (not set + * for top level window) + */ + _updateFrames: function (event, data) { + if (!Services.prefs.getBoolPref("devtools.command-button-frames.enabled")) { + return; + } + + // We may receive this event before the toolbox is ready. + if (!this.isReady) { + return; + } + + // Store (synchronize) data about all existing frames on the backend + if (data.destroyAll) { + this.frameMap.clear(); + this.selectedFrameId = null; + } else if (data.selected) { + this.selectedFrameId = data.selected; + } else if (data.frames) { + data.frames.forEach(frame => { + if (frame.destroy) { + this.frameMap.delete(frame.id); + + // Reset the currently selected frame if it's destroyed. + if (this.selectedFrameId == frame.id) { + this.selectedFrameId = null; + } + } else { + this.frameMap.set(frame.id, frame); + } + }); + } + + // If there is no selected frame select the first top level + // frame by default. Note that there might be more top level + // frames in case of the BrowserToolbox. + if (!this.selectedFrameId) { + let frames = [...this.frameMap.values()]; + let topFrames = frames.filter(frame => !frame.parentID); + this.selectedFrameId = topFrames.length ? topFrames[0].id : null; + } + + // Check out whether top frame is currently selected. + // Note that only child frame has parentID. + let frame = this.frameMap.get(this.selectedFrameId); + let topFrameSelected = frame ? !frame.parentID : false; + let button = this.doc.getElementById("command-button-frames"); + button.removeAttribute("checked"); + + // If non-top level frame is selected the toolbar button is + // marked as 'checked' indicating that a child frame is active. + if (!topFrameSelected && this.selectedFrameId) { + button.setAttribute("checked", "true"); + } + }, + + /** + * Switch to the last used host for the toolbox UI. + */ + switchToPreviousHost: function () { + return this.switchHost("previous"); + }, + + /** + * Switch to a new host for the toolbox UI. E.g. bottom, sidebar, window, + * and focus the window when done. + * + * @param {string} hostType + * The host type of the new host object + */ + switchHost: function (hostType) { + if (hostType == this.hostType || !this._target.isLocalTab) { + return null; + } + + this.emit("host-will-change", hostType); + + // ToolboxHostManager is going to call swapFrameLoaders which mess up with + // focus. We have to blur before calling it in order to be able to restore + // the focus after, in _onSwitchedHost. + this.focusTool(this.currentToolId, false); + + // Host code on the chrome side will send back a message once the host + // switched + this.postMessage({ + name: "switch-host", + hostType + }); + + return this.once("host-changed"); + }, + + _onSwitchedHost: function ({ hostType }) { + this._hostType = hostType; + + this._buildDockButtons(); + this._addKeysToWindow(); + + // We blurred the tools at start of switchHost, but also when clicking on + // host switching button. We now have to restore the focus. + this.focusTool(this.currentToolId, true); + + this.emit("host-changed"); + this._telemetry.log(HOST_HISTOGRAM, this._getTelemetryHostId()); + }, + + /** + * Return if the tool is available as a tab (i.e. if it's checked + * in the options panel). This is different from Toolbox.getPanel - + * a tool could be registered but not yet opened in which case + * isToolRegistered would return true but getPanel would return false. + */ + isToolRegistered: function (toolId) { + return gDevTools.getToolDefinitionMap().has(toolId); + }, + + /** + * Handler for the tool-registered event. + * @param {string} event + * Name of the event ("tool-registered") + * @param {string} toolId + * Id of the tool that was registered + */ + _toolRegistered: function (event, toolId) { + let tool = gDevTools.getToolDefinition(toolId); + this._buildTabForTool(tool); + // Emit the event so tools can listen to it from the toolbox level + // instead of gDevTools + this.emit("tool-registered", toolId); + }, + + /** + * Handler for the tool-unregistered event. + * @param {string} event + * Name of the event ("tool-unregistered") + * @param {string|object} toolId + * Definition or id of the tool that was unregistered. Passing the + * tool id should be avoided as it is a temporary measure. + */ + _toolUnregistered: function (event, toolId) { + if (typeof toolId != "string") { + toolId = toolId.id; + } + + if (this._toolPanels.has(toolId)) { + let instance = this._toolPanels.get(toolId); + instance.destroy(); + this._toolPanels.delete(toolId); + } + + let radio = this.doc.getElementById("toolbox-tab-" + toolId); + let panel = this.doc.getElementById("toolbox-panel-" + toolId); + + if (radio) { + if (this.currentToolId == toolId) { + let nextToolName = null; + if (radio.nextSibling) { + nextToolName = radio.nextSibling.getAttribute("toolid"); + } + if (radio.previousSibling) { + nextToolName = radio.previousSibling.getAttribute("toolid"); + } + if (nextToolName) { + this.selectTool(nextToolName); + } + } + radio.parentNode.removeChild(radio); + } + + if (panel) { + panel.parentNode.removeChild(panel); + } + + if (this.hostType == Toolbox.HostType.WINDOW) { + let doc = this.win.parent.document; + let key = doc.getElementById("key_" + toolId); + if (key) { + key.parentNode.removeChild(key); + } + } + // Emit the event so tools can listen to it from the toolbox level + // instead of gDevTools + this.emit("tool-unregistered", toolId); + }, + + /** + * Initialize the inspector/walker/selection/highlighter fronts. + * Returns a promise that resolves when the fronts are initialized + */ + initInspector: function () { + if (!this._initInspector) { + this._initInspector = Task.spawn(function* () { + this._inspector = InspectorFront(this._target.client, this._target.form); + let pref = "devtools.inspector.showAllAnonymousContent"; + let showAllAnonymousContent = Services.prefs.getBoolPref(pref); + this._walker = yield this._inspector.getWalker({ showAllAnonymousContent }); + this._selection = new Selection(this._walker); + + if (this.highlighterUtils.isRemoteHighlightable()) { + this.walker.on("highlighter-ready", this._highlighterReady); + this.walker.on("highlighter-hide", this._highlighterHidden); + + let autohide = !flags.testing; + this._highlighter = yield this._inspector.getHighlighter(autohide); + } + }.bind(this)); + } + return this._initInspector; + }, + + /** + * Destroy the inspector/walker/selection fronts + * Returns a promise that resolves when the fronts are destroyed + */ + destroyInspector: function () { + if (this._destroyingInspector) { + return this._destroyingInspector; + } + + this._destroyingInspector = Task.spawn(function* () { + if (!this._inspector) { + return; + } + + // Releasing the walker (if it has been created) + // This can fail, but in any case, we want to continue destroying the + // inspector/highlighter/selection + // FF42+: Inspector actor starts managing Walker actor and auto destroy it. + if (this._walker && !this.walker.traits.autoReleased) { + try { + yield this._walker.release(); + } catch (e) { + // Do nothing; + } + } + + yield this.highlighterUtils.stopPicker(); + yield this._inspector.destroy(); + if (this._highlighter) { + // Note that if the toolbox is closed, this will work fine, but will fail + // in case the browser is closed and will trigger a noSuchActor message. + // We ignore the promise that |_hideBoxModel| returns, since we should still + // proceed with the rest of destruction if it fails. + // FF42+ now does the cleanup from the actor. + if (!this.highlighter.traits.autoHideOnDestroy) { + this.highlighterUtils.unhighlight(); + } + yield this._highlighter.destroy(); + } + if (this._selection) { + this._selection.destroy(); + } + + if (this.walker) { + this.walker.off("highlighter-ready", this._highlighterReady); + this.walker.off("highlighter-hide", this._highlighterHidden); + } + + this._inspector = null; + this._highlighter = null; + this._selection = null; + this._walker = null; + }.bind(this)); + return this._destroyingInspector; + }, + + /** + * Get the toolbox's notification component + * + * @return The notification box component. + */ + getNotificationBox: function () { + return this.notificationBox; + }, + + /** + * Remove all UI elements, detach from target and clear up + */ + destroy: function () { + // If several things call destroy then we give them all the same + // destruction promise so we're sure to destroy only once + if (this._destroyer) { + return this._destroyer; + } + let deferred = defer(); + this._destroyer = deferred.promise; + + this.emit("destroy"); + + this._target.off("navigate", this._refreshHostTitle); + this._target.off("frame-update", this._updateFrames); + this.off("select", this._refreshHostTitle); + this.off("host-changed", this._refreshHostTitle); + this.off("ready", this._showDevEditionPromo); + + gDevTools.off("tool-registered", this._toolRegistered); + gDevTools.off("tool-unregistered", this._toolUnregistered); + + gDevTools.off("pref-changed", this._prefChanged); + + this._lastFocusedElement = null; + if (this._sourceMapService) { + this._sourceMapService.destroy(); + this._sourceMapService = null; + } + + if (this.webconsolePanel) { + this._saveSplitConsoleHeight(); + this.webconsolePanel.removeEventListener("resize", + this._saveSplitConsoleHeight); + this.webconsolePanel = null; + } + if (this.closeButton) { + this.closeButton.removeEventListener("click", this.destroy, true); + this.closeButton = null; + } + if (this.textBoxContextMenuPopup) { + this.textBoxContextMenuPopup.removeEventListener("popupshowing", + this._updateTextBoxMenuItems, true); + this.textBoxContextMenuPopup = null; + } + if (this.tabbar) { + this.tabbar.removeEventListener("focus", this._onTabbarFocus, true); + this.tabbar.removeEventListener("click", this._onTabbarFocus, true); + this.tabbar.removeEventListener("keypress", this._onTabbarArrowKeypress); + this.tabbar = null; + } + + let outstanding = []; + for (let [id, panel] of this._toolPanels) { + try { + gDevTools.emit(id + "-destroy", this, panel); + this.emit(id + "-destroy", panel); + + outstanding.push(panel.destroy()); + } catch (e) { + // We don't want to stop here if any panel fail to close. + console.error("Panel " + id + ":", e); + } + } + + this.browserRequire = null; + + // Now that we are closing the toolbox we can re-enable the cache settings + // and disable the service workers testing settings for the current tab. + // FF41+ automatically cleans up state in actor on disconnect. + if (this.target.activeTab && !this.target.activeTab.traits.noTabReconfigureOnClose) { + this.target.activeTab.reconfigure({ + "cacheDisabled": false, + "serviceWorkersTestingEnabled": false + }); + } + + // Destroying the walker and inspector fronts + outstanding.push(this.destroyInspector().then(() => { + // Removing buttons + if (this._pickerButton) { + this._pickerButton.removeEventListener("click", this._togglePicker, false); + this._pickerButton = null; + } + })); + + // Destroy the profiler connection + outstanding.push(this.destroyPerformance()); + + // Detach the thread + detachThread(this._threadClient); + this._threadClient = null; + + // We need to grab a reference to win before this._host is destroyed. + let win = this.win; + + if (this._requisition) { + CommandUtils.destroyRequisition(this._requisition, this.target); + } + this._telemetry.toolClosed("toolbox"); + this._telemetry.destroy(); + + // Finish all outstanding tasks (which means finish destroying panels and + // then destroying the host, successfully or not) before destroying the + // target. + deferred.resolve(settleAll(outstanding) + .catch(console.error) + .then(() => { + this._removeHostListeners(); + + // `location` may already be null if the toolbox document is already + // in process of destruction. Otherwise if it is still around, ensure + // releasing toolbox document and triggering cleanup thanks to unload + // event. We do that precisely here, before nullifying the target as + // various cleanup code depends on the target attribute to be still + // defined. + if (win.location) { + win.location.replace("about:blank"); + } + + // Targets need to be notified that the toolbox is being torn down. + // This is done after other destruction tasks since it may tear down + // fronts and the debugger transport which earlier destroy methods may + // require to complete. + if (!this._target) { + return null; + } + let target = this._target; + this._target = null; + this.highlighterUtils.release(); + target.off("close", this.destroy); + return target.destroy(); + }, console.error).then(() => { + this.emit("destroyed"); + + // Free _host after the call to destroyed in order to let a chance + // to destroyed listeners to still query toolbox attributes + this._host = null; + this._win = null; + this._toolPanels.clear(); + + // Force GC to prevent long GC pauses when running tests and to free up + // memory in general when the toolbox is closed. + if (flags.testing) { + win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .garbageCollect(); + } + }).then(null, console.error)); + + let leakCheckObserver = ({wrappedJSObject: barrier}) => { + // Make the leak detector wait until this toolbox is properly destroyed. + barrier.client.addBlocker("DevTools: Wait until toolbox is destroyed", + this._destroyer); + }; + + let topic = "shutdown-leaks-before-check"; + Services.obs.addObserver(leakCheckObserver, topic, false); + this._destroyer.then(() => { + Services.obs.removeObserver(leakCheckObserver, topic); + }); + + return this._destroyer; + }, + + _highlighterReady: function () { + this.emit("highlighter-ready"); + }, + + _highlighterHidden: function () { + this.emit("highlighter-hide"); + }, + + /** + * For displaying the promotional Doorhanger on first opening of + * the developer tools, promoting the Developer Edition. + */ + _showDevEditionPromo: function () { + // Do not display in browser toolbox + if (this.target.chrome) { + return; + } + showDoorhanger({ window: this.win, type: "deveditionpromo" }); + }, + + /** + * Enable / disable necessary textbox menu items using globalOverlay.js. + */ + _updateTextBoxMenuItems: function () { + let window = this.win; + ["cmd_undo", "cmd_delete", "cmd_cut", + "cmd_copy", "cmd_paste", "cmd_selectAll"].forEach(window.goUpdateCommand); + }, + + /** + * Open the textbox context menu at given coordinates. + * Panels in the toolbox can call this on contextmenu events with event.screenX/Y + * instead of having to implement their own copy/paste/selectAll menu. + * @param {Number} x + * @param {Number} y + */ + openTextBoxContextMenu: function (x, y) { + this.textBoxContextMenuPopup.openPopupAtScreen(x, y, true); + }, + + /** + * Connects to the SPS profiler when the developer tools are open. This is + * necessary because of the WebConsole's `profile` and `profileEnd` methods. + */ + initPerformance: Task.async(function* () { + // If target does not have profiler actor (addons), do not + // even register the shared performance connection. + if (!this.target.hasActor("profiler")) { + return promise.resolve(); + } + + if (this._performanceFrontConnection) { + return this._performanceFrontConnection.promise; + } + + this._performanceFrontConnection = defer(); + this._performance = createPerformanceFront(this._target); + yield this.performance.connect(); + + // Emit an event when connected, but don't wait on startup for this. + this.emit("profiler-connected"); + + this.performance.on("*", this._onPerformanceFrontEvent); + this._performanceFrontConnection.resolve(this.performance); + return this._performanceFrontConnection.promise; + }), + + /** + * Disconnects the underlying Performance actor. If the connection + * has not finished initializing, as opening a toolbox does not wait, + * the performance connection destroy method will wait for it on its own. + */ + destroyPerformance: Task.async(function* () { + if (!this.performance) { + return; + } + // If still connecting to performance actor, allow the + // actor to resolve its connection before attempting to destroy. + if (this._performanceFrontConnection) { + yield this._performanceFrontConnection.promise; + } + this.performance.off("*", this._onPerformanceFrontEvent); + yield this.performance.destroy(); + this._performance = null; + }), + + /** + * Called when any event comes from the PerformanceFront. If the performance tool is + * already loaded when the first event comes in, immediately unbind this handler, as + * this is only used to queue up observed recordings before the performance tool can + * handle them, which will only occur when `console.profile()` recordings are started + * before the tool loads. + */ + _onPerformanceFrontEvent: Task.async(function* (eventName, recording) { + if (this.getPanel("performance")) { + this.performance.off("*", this._onPerformanceFrontEvent); + return; + } + + this._performanceQueuedRecordings = this._performanceQueuedRecordings || []; + let recordings = this._performanceQueuedRecordings; + + // Before any console recordings, we'll get a `console-profile-start` event + // warning us that a recording will come later (via `recording-started`), so + // start to boot up the tool and populate the tool with any other recordings + // observed during that time. + if (eventName === "console-profile-start" && !this._performanceToolOpenedViaConsole) { + this._performanceToolOpenedViaConsole = this.loadTool("performance"); + let panel = yield this._performanceToolOpenedViaConsole; + yield panel.open(); + + panel.panelWin.PerformanceController.populateWithRecordings(recordings); + this.performance.off("*", this._onPerformanceFrontEvent); + } + + // Otherwise, if it's a recording-started event, we've already started loading + // the tool, so just store this recording in our array to be later populated + // once the tool loads. + if (eventName === "recording-started") { + recordings.push(recording); + } + }), + + /** + * Returns gViewSourceUtils for viewing source. + */ + get gViewSourceUtils() { + return this.win.gViewSourceUtils; + }, + + /** + * Opens source in style editor. Falls back to plain "view-source:". + * @see devtools/client/shared/source-utils.js + */ + viewSourceInStyleEditor: function (sourceURL, sourceLine) { + return viewSource.viewSourceInStyleEditor(this, sourceURL, sourceLine); + }, + + /** + * Opens source in debugger. Falls back to plain "view-source:". + * @see devtools/client/shared/source-utils.js + */ + viewSourceInDebugger: function (sourceURL, sourceLine) { + return viewSource.viewSourceInDebugger(this, sourceURL, sourceLine); + }, + + /** + * Opens source in scratchpad. Falls back to plain "view-source:". + * TODO The `sourceURL` for scratchpad instances are like `Scratchpad/1`. + * If instances are scoped one-per-browser-window, then we should be able + * to infer the URL from this toolbox, or use the built in scratchpad IN + * the toolbox. + * + * @see devtools/client/shared/source-utils.js + */ + viewSourceInScratchpad: function (sourceURL, sourceLine) { + return viewSource.viewSourceInScratchpad(sourceURL, sourceLine); + }, + + /** + * Opens source in plain "view-source:". + * @see devtools/client/shared/source-utils.js + */ + viewSource: function (sourceURL, sourceLine) { + return viewSource.viewSource(this, sourceURL, sourceLine); + }, +}; |