fune/devtools/client/webconsole/webconsole-output-wrapper.js

476 lines
17 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 { createElement, createFactory } = require("devtools/client/shared/vendor/react");
const ReactDOM = require("devtools/client/shared/vendor/react-dom");
const { Provider } = require("devtools/client/shared/vendor/react-redux");
const actions = require("devtools/client/webconsole/actions/index");
const { createContextMenu, createEditContextMenu } = require("devtools/client/webconsole/utils/context-menu");
const { configureStore } = require("devtools/client/webconsole/store");
const { isPacketPrivate } = require("devtools/client/webconsole/utils/messages");
const { getAllMessagesById, getMessage } = require("devtools/client/webconsole/selectors/messages");
const Telemetry = require("devtools/client/shared/telemetry");
const EventEmitter = require("devtools/shared/event-emitter");
const App = createFactory(require("devtools/client/webconsole/components/App"));
let store = null;
function WebConsoleOutputWrapper(parentNode, hud, toolbox, owner, document) {
EventEmitter.decorate(this);
this.parentNode = parentNode;
this.hud = hud;
this.toolbox = toolbox;
this.owner = owner;
this.document = document;
this.init = this.init.bind(this);
this.queuedMessageAdds = [];
this.queuedMessageUpdates = [];
this.queuedRequestUpdates = [];
this.throttledDispatchPromise = null;
this.telemetry = new Telemetry();
store = configureStore(this.hud, {
// We may not have access to the toolbox (e.g. in the browser console).
sessionId: this.toolbox && this.toolbox.sessionId || -1,
telemetry: this.telemetry,
});
}
WebConsoleOutputWrapper.prototype = {
init: function() {
return new Promise((resolve) => {
const attachRefToHud = (id, node) => {
this.hud[id] = node;
};
// Focus the input line whenever the output area is clicked.
this.parentNode.addEventListener("click", (event) => {
// Do not focus on middle/right-click or 2+ clicks.
if (event.detail !== 1 || event.button !== 0) {
return;
}
// Do not focus if a link was clicked
const target = event.originalTarget || event.target;
if (target.closest("a")) {
return;
}
// Do not focus if an input field was clicked
if (target.closest("input")) {
return;
}
// Do not focus if something other than the output region was clicked
// (including e.g. the clear messages button in toolbar)
if (!target.closest(".webconsole-output-wrapper")) {
return;
}
// Do not focus if something is selected
const selection = this.document.defaultView.getSelection();
if (selection && !selection.isCollapsed) {
return;
}
if (this.hud && this.hud.jsterm) {
this.hud.jsterm.focus();
}
});
const { hud } = this;
const serviceContainer = {
attachRefToHud,
emitNewMessage: (node, messageId, timeStamp) => {
hud.emit("new-messages", new Set([{
node,
messageId,
timeStamp,
}]));
},
hudProxy: hud.proxy,
openLink: (url, e) => {
hud.owner.openLink(url, e);
},
createElement: nodename => {
return this.document.createElement(nodename);
},
getLongString: (grip) => {
return hud.proxy.webConsoleClient.getString(grip);
},
requestData(id, type) {
return hud.proxy.networkDataProvider.requestData(id, type);
},
onViewSource(frame) {
if (hud && hud.owner && hud.owner.viewSource) {
hud.owner.viewSource(frame.url, frame.line);
}
},
recordTelemetryEvent: (eventName, extra = {}) => {
this.telemetry.recordEvent("devtools.main", eventName, "webconsole", null, {
...extra,
"session_id": this.toolbox && this.toolbox.sessionId || -1
});
}
};
// Set `openContextMenu` this way so, `serviceContainer` variable
// is available in the current scope and we can pass it into
// `createContextMenu` method.
serviceContainer.openContextMenu = (e, message) => {
const { screenX, screenY, target } = e;
const messageEl = target.closest(".message");
const clipboardText = messageEl ? messageEl.textContent : null;
const messageVariable = target.closest(".objectBox");
// Ensure that console.group and console.groupCollapsed commands are not captured
const variableText = (messageVariable
&& !(messageEl.classList.contains("startGroup"))
&& !(messageEl.classList.contains("startGroupCollapsed")))
? messageVariable.textContent : null;
// Retrieve closes actor id from the DOM.
const actorEl = target.closest("[data-link-actor-id]") ||
target.querySelector("[data-link-actor-id]");
const actor = actorEl ? actorEl.dataset.linkActorId : null;
const rootObjectInspector = target.closest(".object-inspector");
const rootActor = rootObjectInspector ?
rootObjectInspector.querySelector("[data-link-actor-id]") : null;
const rootActorId = rootActor ? rootActor.dataset.linkActorId : null;
const sidebarTogglePref = store.getState().prefs.sidebarToggle;
const openSidebar = sidebarTogglePref ? (messageId) => {
store.dispatch(actions.showMessageObjectInSidebar(rootActorId, messageId));
} : null;
const menu = createContextMenu(this.hud, this.parentNode, {
actor,
clipboardText,
variableText,
message,
serviceContainer,
openSidebar,
rootActorId
});
// Emit the "menu-open" event for testing.
menu.once("open", () => this.emit("menu-open"));
menu.popup(screenX, screenY, { doc: this.owner.chromeWindow.document });
return menu;
};
serviceContainer.openEditContextMenu = (e) => {
const { screenX, screenY } = e;
const menu = createEditContextMenu();
// Emit the "menu-open" event for testing.
menu.once("open", () => this.emit("menu-open"));
menu.popup(screenX, screenY, { doc: this.owner.chromeWindow.document });
return menu;
};
if (this.toolbox) {
Object.assign(serviceContainer, {
onViewSourceInDebugger: frame => {
this.toolbox.viewSourceInDebugger(frame.url, frame.line).then(() => {
this.telemetry.recordEvent("devtools.main", "jump_to_source", "webconsole",
null, { "session_id": this.toolbox.sessionId }
);
this.hud.emit("source-in-debugger-opened");
});
},
onViewSourceInScratchpad: frame => this.toolbox.viewSourceInScratchpad(
frame.url,
frame.line
).then(() => {
this.telemetry.recordEvent("devtools.main", "jump_to_source", "webconsole",
null, { "session_id": this.toolbox.sessionId }
);
}),
onViewSourceInStyleEditor: frame => this.toolbox.viewSourceInStyleEditor(
frame.url,
frame.line
).then(() => {
this.telemetry.recordEvent("devtools.main", "jump_to_source", "webconsole",
null, { "session_id": this.toolbox.sessionId }
);
}),
openNetworkPanel: (requestId) => {
return this.toolbox.selectTool("netmonitor").then((panel) => {
return panel.panelWin.Netmonitor.inspectRequest(requestId);
});
},
sourceMapService: this.toolbox ? this.toolbox.sourceMapURLService : null,
highlightDomElement: (grip, options = {}) => {
return this.toolbox.highlighterUtils
? this.toolbox.highlighterUtils.highlightDomValueGrip(grip, options)
: null;
},
unHighlightDomElement: (forceHide = false) => {
return this.toolbox.highlighterUtils
? this.toolbox.highlighterUtils.unhighlight(forceHide)
: null;
},
openNodeInInspector: async (grip) => {
const onSelectInspector = this.toolbox.selectTool("inspector");
const onGripNodeToFront = this.toolbox.highlighterUtils.gripToNodeFront(grip);
const [
front,
inspector
] = await Promise.all([onGripNodeToFront, onSelectInspector]);
const onInspectorUpdated = inspector.once("inspector-updated");
const onNodeFrontSet = this.toolbox.selection
.setNodeFront(front, { reason: "console" });
return Promise.all([onNodeFrontSet, onInspectorUpdated]);
}
});
}
const app = App({
attachRefToHud,
serviceContainer,
hud,
onFirstMeaningfulPaint: resolve,
closeSplitConsole: this.closeSplitConsole.bind(this),
jstermCodeMirror: store.getState().prefs.jstermCodeMirror,
});
// Render the root Application component.
const provider = createElement(Provider, { store }, app);
this.body = ReactDOM.render(provider, this.parentNode);
});
},
dispatchMessageAdd: function(packet, waitForResponse) {
// Wait for the message to render to resolve with the DOM node.
// This is just for backwards compatibility with old tests, and should
// be removed once it's not needed anymore.
// Can only wait for response if the action contains a valid message.
let promise;
// Also, do not expect any update while the panel is in background.
if (waitForResponse && document.visibilityState === "visible") {
const timeStampToMatch = packet.message
? packet.message.timeStamp
: packet.timestamp;
promise = new Promise(resolve => {
this.hud.on("new-messages", function onThisMessage(messages) {
for (const m of messages) {
if (m.timeStamp === timeStampToMatch) {
resolve(m.node);
this.hud.off("new-messages", onThisMessage);
return;
}
}
}.bind(this));
});
} else {
promise = Promise.resolve();
}
this.batchedMessageAdd(packet);
return promise;
},
dispatchMessagesAdd: function(messages) {
this.batchedMessagesAdd(messages);
},
dispatchMessagesClear: function() {
// We might still have pending message additions and updates when the clear action is
// triggered, so we need to flush them to make sure we don't have unexpected behavior
// in the ConsoleOutput.
this.queuedMessageAdds = [];
this.queuedMessageUpdates = [];
this.queuedRequestUpdates = [];
store.dispatch(actions.messagesClear());
},
dispatchPrivateMessagesClear: function() {
// We might still have pending private message additions when the private messages
// clear action is triggered. We need to remove any private-window-issued packets from
// the queue so they won't appear in the output.
// For (network) message updates, we need to check both messages queue and the state
// since we can receive updates even if the message isn't rendered yet.
const messages = [...getAllMessagesById(store.getState()).values()];
this.queuedMessageUpdates = this.queuedMessageUpdates.filter(({networkInfo}) => {
const { actor } = networkInfo;
const queuedNetworkMessage = this.queuedMessageAdds.find(p => p.actor === actor);
if (queuedNetworkMessage && isPacketPrivate(queuedNetworkMessage)) {
return false;
}
const requestMessage = messages.find(message => actor === message.actor);
if (requestMessage && requestMessage.private === true) {
return false;
}
return true;
});
// For (network) requests updates, we can check only the state, since there must be a
// user interaction to get an update (i.e. the network message is displayed and thus
// in the state).
this.queuedRequestUpdates = this.queuedRequestUpdates.filter(({id}) => {
const requestMessage = getMessage(store.getState(), id);
if (requestMessage && requestMessage.private === true) {
return false;
}
return true;
});
// Finally we clear the messages queue. This needs to be done here since we use it to
// clean the other queues.
this.queuedMessageAdds = this.queuedMessageAdds.filter(p => !isPacketPrivate(p));
store.dispatch(actions.privateMessagesClear());
},
dispatchTimestampsToggle: function(enabled) {
store.dispatch(actions.timestampsToggle(enabled));
},
dispatchMessageUpdate: function(message, res) {
// network-message-updated will emit when all the update message arrives.
// Since we can't ensure the order of the network update, we check
// that networkInfo.updates has all we need.
// Note that 'requestPostData' is sent only for POST requests, so we need
// to count with that.
// 'fetchCacheDescriptor' will also cause a network update and increment
// the number of networkInfo.updates
const NUMBER_OF_NETWORK_UPDATE = 8;
let expectedLength = NUMBER_OF_NETWORK_UPDATE;
if (this.hud.proxy.webConsoleClient.traits.fetchCacheDescriptor
&& res.networkInfo.updates.includes("responseCache")) {
expectedLength++;
}
if (res.networkInfo.updates.includes("requestPostData")) {
expectedLength++;
}
if (res.networkInfo.updates.length === expectedLength) {
this.batchedMessageUpdates({ res, message });
}
},
dispatchRequestUpdate: function(id, data) {
this.batchedRequestUpdates({ id, data });
},
dispatchSidebarClose: function() {
store.dispatch(actions.sidebarClose());
},
dispatchSplitConsoleCloseButtonToggle: function() {
store.dispatch(actions.splitConsoleCloseButtonToggle(
this.toolbox && this.toolbox.currentToolId !== "webconsole"));
},
batchedMessageUpdates: function(info) {
this.queuedMessageUpdates.push(info);
this.setTimeoutIfNeeded();
},
batchedRequestUpdates: function(message) {
this.queuedRequestUpdates.push(message);
this.setTimeoutIfNeeded();
},
batchedMessageAdd: function(message) {
this.queuedMessageAdds.push(message);
this.setTimeoutIfNeeded();
},
batchedMessagesAdd: function(messages) {
this.queuedMessageAdds = this.queuedMessageAdds.concat(messages);
this.setTimeoutIfNeeded();
},
dispatchClearHistory: function() {
store.dispatch(actions.clearHistory());
},
/**
* Returns a Promise that resolves once any async dispatch is finally dispatched.
*/
waitAsyncDispatches: function() {
if (!this.throttledDispatchPromise) {
return Promise.resolve();
}
return this.throttledDispatchPromise;
},
setTimeoutIfNeeded: function() {
if (this.throttledDispatchPromise) {
return;
}
this.throttledDispatchPromise = new Promise(done => {
setTimeout(() => {
this.throttledDispatchPromise = null;
store.dispatch(actions.messagesAdd(this.queuedMessageAdds));
const length = this.queuedMessageAdds.length;
this.telemetry.addEventProperty(
"devtools.main", "enter", "webconsole", null, "message_count", length);
this.queuedMessageAdds = [];
if (this.queuedMessageUpdates.length > 0) {
this.queuedMessageUpdates.forEach(({ message, res }) => {
store.dispatch(actions.networkMessageUpdate(message, null, res));
this.hud.emit("network-message-updated", res);
});
this.queuedMessageUpdates = [];
}
if (this.queuedRequestUpdates.length > 0) {
this.queuedRequestUpdates.forEach(({ id, data}) => {
store.dispatch(actions.networkUpdateRequest(id, data));
});
this.queuedRequestUpdates = [];
// Fire an event indicating that all data fetched from
// the backend has been received. This is based on
// 'FirefoxDataProvider.isQueuePayloadReady', see more
// comments in that method.
// (netmonitor/src/connector/firefox-data-provider).
// This event might be utilized in tests to find the right
// time when to finish.
this.hud.emit("network-request-payload-ready");
}
done();
}, 50);
});
},
// Should be used for test purpose only.
getStore: function() {
return store;
},
// Called by pushing close button.
closeSplitConsole() {
this.toolbox.closeSplitConsole();
}
};
// Exports from this module
module.exports = WebConsoleOutputWrapper;