Mypal/devtools/client/framework/toolbox.js

2413 lines
78 KiB
JavaScript

/* 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 = Services.prefs.getBoolPref(visibilityswitch, true);
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);
},
};