diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/webconsole/actions | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/webconsole/actions')
-rw-r--r-- | devtools/client/webconsole/actions/autocomplete.js | 376 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/filters.js | 59 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/history.js | 90 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/index.js | 21 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/input.js | 466 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/messages.js | 173 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/moz.build | 17 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/notifications.js | 48 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/object.js | 63 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/toolbox.js | 49 | ||||
-rw-r--r-- | devtools/client/webconsole/actions/ui.js | 247 |
11 files changed, 1609 insertions, 0 deletions
diff --git a/devtools/client/webconsole/actions/autocomplete.js b/devtools/client/webconsole/actions/autocomplete.js new file mode 100644 index 0000000000..e2d08351ec --- /dev/null +++ b/devtools/client/webconsole/actions/autocomplete.js @@ -0,0 +1,376 @@ +/* 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 { + AUTOCOMPLETE_CLEAR, + AUTOCOMPLETE_DATA_RECEIVE, + AUTOCOMPLETE_PENDING_REQUEST, + AUTOCOMPLETE_RETRIEVE_FROM_CACHE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const { + analyzeInputString, + shouldInputBeAutocompleted, +} = require("resource://devtools/shared/webconsole/analyze-input-string.js"); + +loader.lazyRequireGetter( + this, + "getSelectedTarget", + "resource://devtools/shared/commands/target/selectors/targets.js", + true +); + +/** + * Update the data used for the autocomplete popup in the console input (JsTerm). + * + * @param {Boolean} force: True to force a call to the server (as opposed to retrieve + * from the cache). + * @param {Array<String>} getterPath: Array representing the getter access (i.e. + * `a.b.c.d.` is described as ['a', 'b', 'c', 'd'] ). + * @param {Array<String>} expressionVars: Array of the variables defined in the expression. + */ +function autocompleteUpdate(force, getterPath, expressionVars) { + return async ({ dispatch, getState, webConsoleUI, hud }) => { + if (hud.inputHasSelection()) { + return dispatch(autocompleteClear()); + } + + const inputValue = hud.getInputValue(); + const mappedVars = hud.getMappedVariables() ?? {}; + const allVars = (expressionVars ?? []).concat(Object.keys(mappedVars)); + const frameActorId = await hud.getSelectedFrameActorID(); + + const cursor = webConsoleUI.getInputCursor(); + + const state = getState().autocomplete; + const { cache } = state; + if ( + !force && + (!inputValue || /^[a-zA-Z0-9_$]/.test(inputValue.substring(cursor))) + ) { + return dispatch(autocompleteClear()); + } + + const rawInput = inputValue.substring(0, cursor); + const retrieveFromCache = + !force && + cache && + cache.input && + rawInput.startsWith(cache.input) && + /[a-zA-Z0-9]$/.test(rawInput) && + frameActorId === cache.frameActorId; + + if (retrieveFromCache) { + return dispatch(autoCompleteDataRetrieveFromCache(rawInput)); + } + + const authorizedEvaluations = updateAuthorizedEvaluations( + state.authorizedEvaluations, + getterPath, + mappedVars + ); + + const { input, originalExpression } = await getMappedInput( + rawInput, + mappedVars, + hud + ); + + return dispatch( + autocompleteDataFetch({ + input, + frameActorId, + authorizedEvaluations, + force, + allVars, + mappedVars, + originalExpression, + }) + ); + }; +} + +/** + * Combine or replace authorizedEvaluations with the newly authorized getter path, if any. + * @param {Array<Array<String>>} authorizedEvaluations Existing authorized evaluations (may + * be updated in place) + * @param {Array<String>} getterPath The new getter path + * @param {{[String]: String}} mappedVars Map of original to generated variable names. + * @returns {Array<Array<String>>} The updated authorized evaluations (the original array, + * if it was updated in place) */ +function updateAuthorizedEvaluations( + authorizedEvaluations, + getterPath, + mappedVars +) { + if (!Array.isArray(authorizedEvaluations) || !authorizedEvaluations.length) { + authorizedEvaluations = []; + } + + if (Array.isArray(getterPath) && getterPath.length) { + // We need to check for any previous authorizations. For example, here if getterPath + // is ["a", "b", "c", "d"], we want to see if there was any other path that was + // authorized in a previous request. For that, we only add the previous + // authorizations if the last auth is contained in getterPath. (for the example, we + // would keep if it is [["a", "b"]], not if [["b"]] nor [["f", "g"]]) + const last = authorizedEvaluations[authorizedEvaluations.length - 1]; + + const generatedPath = mappedVars[getterPath[0]]?.split("."); + if (generatedPath) { + getterPath = generatedPath.concat(getterPath.slice(1)); + } + + const isMappedVariable = + generatedPath && getterPath.length === generatedPath.length; + const concat = !last || last.every((x, index) => x === getterPath[index]); + if (isMappedVariable) { + // If the path consists only of an original variable, add all the prefixes of its + // mapping. For example, for myVar => a.b.c, authorize a, a.b, and a.b.c. This + // ensures we'll only show a prompt for myVar once even if a.b and a.b.c are both + // unsafe getters. + authorizedEvaluations = generatedPath.map((_, i) => + generatedPath.slice(0, i + 1) + ); + } else if (concat) { + authorizedEvaluations.push(getterPath); + } else { + authorizedEvaluations = [getterPath]; + } + } + return authorizedEvaluations; +} + +/** + * Apply source mapping to the autocomplete input. + * @param {String} rawInput The input to map. + * @param {{[String]: String}} mappedVars Map of original to generated variable names. + * @param {WebConsole} hud A reference to the webconsole hud. + * @returns {String} The source-mapped expression to autocomplete. + */ +async function getMappedInput(rawInput, mappedVars, hud) { + if (!mappedVars || !Object.keys(mappedVars).length) { + return { input: rawInput, originalExpression: undefined }; + } + + const inputAnalysis = analyzeInputString(rawInput, 500); + if (!shouldInputBeAutocompleted(inputAnalysis)) { + return { input: rawInput, originalExpression: undefined }; + } + + const { + mainExpression: originalExpression, + isPropertyAccess, + isElementAccess, + lastStatement, + } = inputAnalysis; + + // If we're autocompleting a variable name, pass it through unchanged so that we + // show original variable names rather than generated ones. + // For example, if we have the mapping `myVariable` => `x`, show variables starting + // with myVariable rather than x. + if (!isPropertyAccess && !isElementAccess) { + return { input: lastStatement, originalExpression }; + } + + let generated = + (await hud.getMappedExpression(originalExpression))?.expression ?? + originalExpression; + // Strip off the semicolon if the expression was converted to a statement + const trailingSemicolon = /;\s*$/; + if ( + trailingSemicolon.test(generated) && + !trailingSemicolon.test(originalExpression) + ) { + generated = generated.slice(0, generated.lastIndexOf(";")); + } + + const suffix = lastStatement.slice(originalExpression.length); + return { input: generated + suffix, originalExpression }; +} + +/** + * Called when the autocompletion data should be cleared. + */ +function autocompleteClear() { + return { + type: AUTOCOMPLETE_CLEAR, + }; +} + +/** + * Called when the autocompletion data should be retrieved from the cache (i.e. + * client-side). + * + * @param {String} input: The input used to filter the cached data. + */ +function autoCompleteDataRetrieveFromCache(input) { + return { + type: AUTOCOMPLETE_RETRIEVE_FROM_CACHE, + input, + }; +} + +let currentRequestId = 0; +function generateRequestId() { + return currentRequestId++; +} + +/** + * Action that fetch autocompletion data from the server. + * + * @param {Object} Object of the following shape: + * - {String} input: the expression that we want to complete. + * - {String} frameActorId: The id of the frame we want to autocomplete in. + * - {Boolean} force: true if the user forced an autocompletion (with Ctrl+Space). + * - {Array} authorizedEvaluations: Array of the properties access which can be + * executed by the engine. + * Example: [["x", "myGetter"], ["x", "myGetter", "y", "glitter"]] + * to retrieve properties of `x.myGetter.` and `x.myGetter.y.glitter`. + */ +function autocompleteDataFetch({ + input, + frameActorId, + force, + authorizedEvaluations, + allVars, + mappedVars, + originalExpression, +}) { + return async ({ dispatch, commands, webConsoleUI, hud }) => { + // Retrieve the right WebConsole front that relates either to (by order of priority): + // - the currently selected target in the context selector + // (contextSelectedTargetFront), + // - the currently selected Node in the inspector (selectedNodeActor), + // - the currently selected frame in the debugger (when paused) (frameActor), + // - the currently selected target in the iframe dropdown + // (selectedTargetFront from the TargetCommand) + const selectedNodeActorId = webConsoleUI.getSelectedNodeActorID(); + + let targetFront = commands.targetCommand.selectedTargetFront; + // Note that getSelectedTargetFront will return null if we default to the top level target. + const contextSelectorTargetFront = getSelectedTarget( + hud.commands.targetCommand.store.getState() + ); + const selectedActorId = selectedNodeActorId || frameActorId; + if (contextSelectorTargetFront) { + targetFront = contextSelectorTargetFront; + } else if (selectedActorId) { + const selectedFront = commands.client.getFrontByID(selectedActorId); + if (selectedFront) { + targetFront = selectedFront.targetFront; + } + } + + const webconsoleFront = await targetFront.getFront("console"); + + const id = generateRequestId(); + dispatch({ type: AUTOCOMPLETE_PENDING_REQUEST, id }); + + webconsoleFront + .autocomplete( + input, + undefined, + frameActorId, + selectedNodeActorId, + authorizedEvaluations, + allVars + ) + .then(data => { + if (data.isUnsafeGetter && originalExpression !== undefined) { + data.getterPath = unmapGetterPath( + data.getterPath, + originalExpression, + mappedVars + ); + } + return dispatch( + autocompleteDataReceive({ + id, + input, + force, + frameActorId, + data, + authorizedEvaluations, + }) + ); + }) + .catch(e => { + console.error("failed autocomplete", e); + dispatch(autocompleteClear()); + }); + }; +} + +/** + * Replace generated variable names in an unsafe getter path with their original + * counterparts. + * @param {Array<String>} getterPath Array of properties leading up to and including the + * unsafe getter. + * @param {String} originalExpression The expression that was evaluated, before mapping. + * @param {{[String]: String}} mappedVars Map of original to generated variable names. + * @returns {Array<String>} An updated getter path containing original variables. + */ +function unmapGetterPath(getterPath, originalExpression, mappedVars) { + // We know that the original expression is a sequence of property accesses, that only + // the first part can be a mapped variable, and that the getter path must start with + // its generated path or be a prefix of it. + + // Suppose we have the expression `foo.bar`, which maps to `a.b.c.bar`. + // Get the first part of the expression ("foo") + const originalVariable = /^[^.[?]*/s.exec(originalExpression)[0].trim(); + const generatedVariable = mappedVars[originalVariable]; + if (generatedVariable) { + // Get number of properties in "a.b.c" + const generatedVariableParts = generatedVariable.split("."); + // Replace ["a", "b", "c"] with "foo" in the getter path. + // Note that this will also work if the getter path ends inside of the mapped + // variable, like ["a", "b"]. + return [ + originalVariable, + ...getterPath.slice(generatedVariableParts.length), + ]; + } + return getterPath; +} + +/** + * Called when we receive the autocompletion data from the server. + * + * @param {Object} Object of the following shape: + * - {Integer} id: The autocompletion request id. This will be used in the reducer + * to check that we update the state with the last request results. + * - {String} input: the expression that we want to complete. + * - {String} frameActorId: The id of the frame we want to autocomplete in. + * - {Boolean} force: true if the user forced an autocompletion (with Ctrl+Space). + * - {Object} data: The actual data returned from the server. + * - {Array} authorizedEvaluations: Array of the properties access which can be + * executed by the engine. + * Example: [["x", "myGetter"], ["x", "myGetter", "y", "glitter"]] + * to retrieve properties of `x.myGetter.` and `x.myGetter.y.glitter`. + */ +function autocompleteDataReceive({ + id, + input, + frameActorId, + force, + data, + authorizedEvaluations, +}) { + return { + type: AUTOCOMPLETE_DATA_RECEIVE, + id, + input, + force, + frameActorId, + data, + authorizedEvaluations, + }; +} + +module.exports = { + autocompleteClear, + autocompleteUpdate, +}; diff --git a/devtools/client/webconsole/actions/filters.js b/devtools/client/webconsole/actions/filters.js new file mode 100644 index 0000000000..108614ee9f --- /dev/null +++ b/devtools/client/webconsole/actions/filters.js @@ -0,0 +1,59 @@ +/* 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 { + getAllFilters, +} = require("resource://devtools/client/webconsole/selectors/filters.js"); + +const { + FILTER_TEXT_SET, + FILTER_TOGGLE, + FILTERS_CLEAR, + PREFS, + FILTERS, +} = require("resource://devtools/client/webconsole/constants.js"); + +function filterTextSet(text) { + return { + type: FILTER_TEXT_SET, + text, + }; +} + +function filterToggle(filter) { + return ({ dispatch, getState, prefsService }) => { + dispatch({ + type: FILTER_TOGGLE, + filter, + }); + const filterState = getAllFilters(getState()); + prefsService.setBoolPref( + PREFS.FILTER[filter.toUpperCase()], + filterState[filter] + ); + }; +} + +function filtersClear() { + return ({ dispatch, getState, prefsService }) => { + dispatch({ + type: FILTERS_CLEAR, + }); + + const filterState = getAllFilters(getState()); + for (const filter in filterState) { + if (filter !== FILTERS.TEXT) { + prefsService.clearUserPref(PREFS.FILTER[filter.toUpperCase()]); + } + } + }; +} + +module.exports = { + filterTextSet, + filterToggle, + filtersClear, +}; diff --git a/devtools/client/webconsole/actions/history.js b/devtools/client/webconsole/actions/history.js new file mode 100644 index 0000000000..3c55389020 --- /dev/null +++ b/devtools/client/webconsole/actions/history.js @@ -0,0 +1,90 @@ +/* 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 { + APPEND_TO_HISTORY, + CLEAR_HISTORY, + HISTORY_LOADED, + UPDATE_HISTORY_POSITION, + REVERSE_SEARCH_INPUT_CHANGE, + REVERSE_SEARCH_BACK, + REVERSE_SEARCH_NEXT, +} = require("resource://devtools/client/webconsole/constants.js"); + +/** + * Append a new value in the history of executed expressions, + * or overwrite the most recent entry. The most recent entry may + * contain the last edited input value that was not evaluated yet. + */ +function appendToHistory(expression) { + return { + type: APPEND_TO_HISTORY, + expression, + }; +} + +/** + * Clear the console history altogether. Note that this will not affect + * other consoles that are already opened (since they have their own copy), + * but it will reset the array for all newly-opened consoles. + */ +function clearHistory() { + return { + type: CLEAR_HISTORY, + }; +} + +/** + * Fired when the console history from previous Firefox sessions is loaded. + */ +function historyLoaded(entries) { + return { + type: HISTORY_LOADED, + entries, + }; +} + +/** + * Update place-holder position in the history list. + */ +function updateHistoryPosition(direction, expression) { + return { + type: UPDATE_HISTORY_POSITION, + direction, + expression, + }; +} + +function reverseSearchInputChange(value) { + return { + type: REVERSE_SEARCH_INPUT_CHANGE, + value, + }; +} + +function showReverseSearchNext({ access } = {}) { + return { + type: REVERSE_SEARCH_NEXT, + access, + }; +} + +function showReverseSearchBack({ access } = {}) { + return { + type: REVERSE_SEARCH_BACK, + access, + }; +} + +module.exports = { + appendToHistory, + clearHistory, + historyLoaded, + updateHistoryPosition, + reverseSearchInputChange, + showReverseSearchNext, + showReverseSearchBack, +}; diff --git a/devtools/client/webconsole/actions/index.js b/devtools/client/webconsole/actions/index.js new file mode 100644 index 0000000000..29aeb937a2 --- /dev/null +++ b/devtools/client/webconsole/actions/index.js @@ -0,0 +1,21 @@ +/* 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 actionModules = [ + require("resource://devtools/client/webconsole/actions/autocomplete.js"), + require("resource://devtools/client/webconsole/actions/filters.js"), + require("resource://devtools/client/webconsole/actions/input.js"), + require("resource://devtools/client/webconsole/actions/messages.js"), + require("resource://devtools/client/webconsole/actions/ui.js"), + require("resource://devtools/client/webconsole/actions/notifications.js"), + require("resource://devtools/client/webconsole/actions/object.js"), + require("resource://devtools/client/webconsole/actions/toolbox.js"), + require("resource://devtools/client/webconsole/actions/history.js"), +]; + +const actions = Object.assign({}, ...actionModules); + +module.exports = actions; diff --git a/devtools/client/webconsole/actions/input.js b/devtools/client/webconsole/actions/input.js new file mode 100644 index 0000000000..85b1867fc8 --- /dev/null +++ b/devtools/client/webconsole/actions/input.js @@ -0,0 +1,466 @@ +/* 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 { + Utils: WebConsoleUtils, +} = require("resource://devtools/client/webconsole/utils.js"); +const { + EVALUATE_EXPRESSION, + SET_TERMINAL_INPUT, + SET_TERMINAL_EAGER_RESULT, + EDITOR_PRETTY_PRINT, +} = require("resource://devtools/client/webconsole/constants.js"); +const { + getAllPrefs, +} = require("resource://devtools/client/webconsole/selectors/prefs.js"); +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); +const l10n = require("resource://devtools/client/webconsole/utils/l10n.js"); + +loader.lazyServiceGetter( + this, + "clipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); +loader.lazyRequireGetter( + this, + "messagesActions", + "resource://devtools/client/webconsole/actions/messages.js" +); +loader.lazyRequireGetter( + this, + "historyActions", + "resource://devtools/client/webconsole/actions/history.js" +); +loader.lazyRequireGetter( + this, + "ConsoleCommand", + "resource://devtools/client/webconsole/types.js", + true +); +loader.lazyRequireGetter( + this, + "netmonitorBlockingActions", + "resource://devtools/client/netmonitor/src/actions/request-blocking.js" +); + +loader.lazyRequireGetter( + this, + ["saveScreenshot", "captureAndSaveScreenshot"], + "resource://devtools/client/shared/screenshot.js", + true +); +loader.lazyRequireGetter( + this, + "createSimpleTableMessage", + "resource://devtools/client/webconsole/utils/messages.js", + true +); +loader.lazyRequireGetter( + this, + "getSelectedTarget", + "resource://devtools/shared/commands/target/selectors/targets.js", + true +); + +const HELP_URL = + "https://firefox-source-docs.mozilla.org/devtools-user/web_console/helpers/"; + +async function getMappedExpression(hud, expression) { + let mapResult; + try { + mapResult = await hud.getMappedExpression(expression); + } catch (e) { + console.warn("Error when calling getMappedExpression", e); + } + + let mapped = null; + if (mapResult) { + ({ expression, mapped } = mapResult); + } + return { expression, mapped }; +} + +function evaluateExpression(expression, from = "input") { + return async ({ dispatch, toolbox, webConsoleUI, hud, commands }) => { + if (!expression) { + expression = hud.getInputSelection() || hud.getInputValue(); + } + if (!expression) { + return null; + } + + // We use the messages action as it's doing additional transformation on the message. + const { messages } = dispatch( + messagesActions.messagesAdd([ + new ConsoleCommand({ + messageText: expression, + timeStamp: Date.now(), + }), + ]) + ); + const [consoleCommandMessage] = messages; + + dispatch({ + type: EVALUATE_EXPRESSION, + expression, + from, + }); + + WebConsoleUtils.usageCount++; + + let mapped; + ({ expression, mapped } = await getMappedExpression(hud, expression)); + + // Even if the evaluation fails, + // we still need to pass the error response to onExpressionEvaluated. + const onSettled = res => res; + + const response = await commands.scriptCommand + .execute(expression, { + frameActor: hud.getSelectedFrameActorID(), + selectedNodeActor: webConsoleUI.getSelectedNodeActorID(), + selectedTargetFront: getSelectedTarget( + webConsoleUI.hud.commands.targetCommand.store.getState() + ), + mapped, + }) + .then(onSettled, onSettled); + + const serverConsoleCommandTimestamp = response.startTime; + + // In case of remote debugging, it might happen that the debuggee page does not have + // the exact same clock time as the client. This could cause some ordering issues + // where the result message is displayed *before* the expression that lead to it. + if ( + serverConsoleCommandTimestamp && + consoleCommandMessage.timeStamp > serverConsoleCommandTimestamp + ) { + // If we're in such case, we remove the original command message, and add it again, + // with the timestamp coming from the server. + dispatch(messagesActions.messageRemove(consoleCommandMessage.id)); + dispatch( + messagesActions.messagesAdd([ + new ConsoleCommand({ + messageText: expression, + timeStamp: serverConsoleCommandTimestamp, + }), + ]) + ); + } + + return dispatch(onExpressionEvaluated(response)); + }; +} + +/** + * The JavaScript evaluation response handler. + * + * @private + * @param {Object} response + * The message received from the server. + */ +function onExpressionEvaluated(response) { + return async ({ dispatch, webConsoleUI }) => { + if (response.error) { + console.error(`Evaluation error`, response.error, ": ", response.message); + return; + } + + // If the evaluation was a top-level await expression that was rejected, there will + // be an uncaught exception reported, so we don't need to do anything. + if (response.topLevelAwaitRejected === true) { + return; + } + + if (!response.helperResult) { + webConsoleUI.wrapper.dispatchMessageAdd(response); + return; + } + + await dispatch(handleHelperResult(response)); + }; +} + +function handleHelperResult(response) { + // eslint-disable-next-line complexity + return async ({ dispatch, hud, toolbox, webConsoleUI, getState }) => { + const { result, helperResult } = response; + const helperHasRawOutput = !!helperResult?.rawOutput; + + if (helperResult?.type) { + switch (helperResult.type) { + case "clearOutput": + dispatch(messagesActions.messagesClear()); + break; + case "clearHistory": + dispatch(historyActions.clearHistory()); + break; + case "historyOutput": + const history = getState().history.entries || []; + const columns = new Map([ + ["_index", "(index)"], + ["expression", "Expressions"], + ]); + dispatch( + messagesActions.messagesAdd([ + { + ...createSimpleTableMessage( + columns, + history.map((expression, index) => { + return { _index: index, expression }; + }) + ), + }, + ]) + ); + break; + case "inspectObject": { + const objectActor = helperResult.object; + if (hud.toolbox && !helperResult.forceExpandInConsole) { + hud.toolbox.inspectObjectActor(objectActor); + } else { + webConsoleUI.inspectObjectActor(objectActor); + } + break; + } + case "help": + hud.openLink(HELP_URL); + break; + case "copyValueToClipboard": + clipboardHelper.copyString(helperResult.value); + dispatch( + messagesActions.messagesAdd([ + { + resourceType: ResourceCommand.TYPES.PLATFORM_MESSAGE, + message: l10n.getStr( + "webconsole.message.commands.copyValueToClipboard" + ), + }, + ]) + ); + break; + case "screenshotOutput": + const { args, value } = helperResult; + const targetFront = + getSelectedTarget(hud.commands.targetCommand.store.getState()) || + hud.currentTarget; + let screenshotMessages; + + // @backward-compat { version 87 } The screenshot-content actor isn't available + // in older server. + // With an old server, the console actor captures the screenshot when handling + // the command, and send it to the client which only needs to save it to a file. + // With a new server, the server simply acknowledges the command, + // and the client will drive the whole screenshot process (capture and save). + if (targetFront.hasActor("screenshotContent")) { + screenshotMessages = await captureAndSaveScreenshot( + targetFront, + webConsoleUI.getPanelWindow(), + args + ); + } else { + screenshotMessages = await saveScreenshot( + webConsoleUI.getPanelWindow(), + args, + value + ); + } + + if (screenshotMessages && screenshotMessages.length) { + dispatch( + messagesActions.messagesAdd( + screenshotMessages.map(message => ({ + message: { + level: message.level || "log", + arguments: [message.text], + chromeContext: true, + }, + resourceType: ResourceCommand.TYPES.CONSOLE_MESSAGE, + })) + ) + ); + } + break; + case "blockURL": + const blockURL = helperResult.args.url; + // The console actor isn't able to block the request as the console actor runs in the content + // process, while the request has to be blocked from the parent process. + // Then, calling the Netmonitor action will only update the visual state of the Netmonitor, + // but we also have to block the request via the NetworkParentActor. + await hud.commands.networkCommand.blockRequestForUrl(blockURL); + toolbox + .getPanel("netmonitor") + ?.panelWin.store.dispatch( + netmonitorBlockingActions.addBlockedUrl(blockURL) + ); + + dispatch( + messagesActions.messagesAdd([ + { + resourceType: ResourceCommand.TYPES.PLATFORM_MESSAGE, + message: l10n.getFormatStr( + "webconsole.message.commands.blockedURL", + [blockURL] + ), + }, + ]) + ); + break; + case "unblockURL": + const unblockURL = helperResult.args.url; + await hud.commands.networkCommand.unblockRequestForUrl(unblockURL); + toolbox + .getPanel("netmonitor") + ?.panelWin.store.dispatch( + netmonitorBlockingActions.removeBlockedUrl(unblockURL) + ); + + dispatch( + messagesActions.messagesAdd([ + { + resourceType: ResourceCommand.TYPES.PLATFORM_MESSAGE, + message: l10n.getFormatStr( + "webconsole.message.commands.unblockedURL", + [unblockURL] + ), + }, + ]) + ); + // early return as we already dispatched necessary messages. + return; + } + } + + const hasErrorMessage = + response.exceptionMessage || + (helperResult && helperResult.type === "error"); + + // Hide undefined results coming from helper functions. + const hasUndefinedResult = + result && typeof result == "object" && result.type == "undefined"; + + if (hasErrorMessage || helperHasRawOutput || !hasUndefinedResult) { + dispatch(messagesActions.messagesAdd([response])); + } + }; +} + +function focusInput() { + return ({ hud }) => { + return hud.focusInput(); + }; +} + +function setInputValue(value) { + return ({ hud }) => { + return hud.setInputValue(value); + }; +} + +/** + * Request an eager evaluation from the server. + * + * @param {String} expression: The expression to evaluate. + * @param {Boolean} force: When true, will request an eager evaluation again, even if + * the expression is the same one than the one that was used in + * the previous evaluation. + */ +function terminalInputChanged(expression, force = false) { + return async ({ dispatch, webConsoleUI, hud, commands, getState }) => { + const prefs = getAllPrefs(getState()); + if (!prefs.eagerEvaluation) { + return null; + } + + const { terminalInput = "" } = getState().history; + + // Only re-evaluate if the expression did change. + if ( + (!terminalInput && !expression) || + (typeof terminalInput === "string" && + typeof expression === "string" && + expression.trim() === terminalInput.trim() && + !force) + ) { + return null; + } + + dispatch({ + type: SET_TERMINAL_INPUT, + expression: expression.trim(), + }); + + // There's no need to evaluate an empty string. + if (!expression || !expression.trim()) { + return dispatch({ + type: SET_TERMINAL_EAGER_RESULT, + expression, + result: null, + }); + } + + let mapped; + ({ expression, mapped } = await getMappedExpression(hud, expression)); + + // We don't want to evaluate top-level await expressions (see Bug 1786805) + if (mapped?.await) { + return dispatch({ + type: SET_TERMINAL_EAGER_RESULT, + expression, + result: null, + }); + } + + const response = await commands.scriptCommand.execute(expression, { + frameActor: hud.getSelectedFrameActorID(), + selectedNodeActor: webConsoleUI.getSelectedNodeActorID(), + selectedTargetFront: getSelectedTarget( + hud.commands.targetCommand.store.getState() + ), + mapped, + eager: true, + }); + + return dispatch({ + type: SET_TERMINAL_EAGER_RESULT, + result: getEagerEvaluationResult(response), + }); + }; +} + +/** + * Refresh the current eager evaluation by requesting a new eager evaluation. + */ +function updateInstantEvaluationResultForCurrentExpression() { + return ({ getState, dispatch }) => + dispatch(terminalInputChanged(getState().history.terminalInput, true)); +} + +function getEagerEvaluationResult(response) { + const result = response.exception || response.result; + // Don't show syntax errors results to the user. + if (result?.isSyntaxError || (result && result.type == "undefined")) { + return null; + } + + return result; +} + +function prettyPrintEditor() { + return { + type: EDITOR_PRETTY_PRINT, + }; +} + +module.exports = { + evaluateExpression, + focusInput, + setInputValue, + terminalInputChanged, + updateInstantEvaluationResultForCurrentExpression, + prettyPrintEditor, +}; diff --git a/devtools/client/webconsole/actions/messages.js b/devtools/client/webconsole/actions/messages.js new file mode 100644 index 0000000000..1da76b4475 --- /dev/null +++ b/devtools/client/webconsole/actions/messages.js @@ -0,0 +1,173 @@ +/* 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 { + prepareMessage, + getNaturalOrder, +} = require("resource://devtools/client/webconsole/utils/messages.js"); +const { + IdGenerator, +} = require("resource://devtools/client/webconsole/utils/id-generator.js"); +const { + batchActions, +} = require("resource://devtools/client/shared/redux/middleware/debounce.js"); + +const { + CSS_MESSAGE_ADD_MATCHING_ELEMENTS, + MESSAGE_CLOSE, + MESSAGE_OPEN, + MESSAGE_REMOVE, + MESSAGE_TYPE, + MESSAGES_ADD, + MESSAGES_CLEAR, + MESSAGES_DISABLE, + NETWORK_MESSAGES_UPDATE, + NETWORK_UPDATES_REQUEST, + PRIVATE_MESSAGES_CLEAR, + TARGET_MESSAGES_REMOVE, +} = require("resource://devtools/client/webconsole/constants.js"); + +const defaultIdGenerator = new IdGenerator(); + +function messagesAdd(packets, idGenerator = null) { + if (idGenerator == null) { + idGenerator = defaultIdGenerator; + } + const messages = packets.map(packet => prepareMessage(packet, idGenerator)); + // Sort the messages by their timestamps. + messages.sort(getNaturalOrder); + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === MESSAGE_TYPE.CLEAR) { + return batchActions([ + messagesClear(), + { + type: MESSAGES_ADD, + messages: messages.slice(i), + }, + ]); + } + } + + // When this is used for non-cached messages then handle clear message and + // split up into batches + return { + type: MESSAGES_ADD, + messages, + }; +} + +function messagesClear() { + return { + type: MESSAGES_CLEAR, + }; +} + +function messagesDisable(ids) { + return { + type: MESSAGES_DISABLE, + ids, + }; +} + +function privateMessagesClear() { + return { + type: PRIVATE_MESSAGES_CLEAR, + }; +} + +function targetMessagesRemove(targetFront) { + return { + type: TARGET_MESSAGES_REMOVE, + targetFront, + }; +} + +function messageOpen(id) { + return { + type: MESSAGE_OPEN, + id, + }; +} + +function messageClose(id) { + return { + type: MESSAGE_CLOSE, + id, + }; +} + +/** + * Make a query on the server to get a list of DOM elements matching the given + * CSS selectors and store the information in the state. + * + * @param {Message} message + * The CSSWarning message + */ +function messageGetMatchingElements(message) { + return async ({ dispatch, commands }) => { + try { + // We need to do the querySelectorAll using the target the message is coming from, + // as well as with the window the warning message was emitted from. + const selectedTargetFront = message?.targetFront; + + const response = await commands.scriptCommand.execute( + `document.querySelectorAll('${message.cssSelectors}')`, + { + selectedTargetFront, + innerWindowID: message.innerWindowID, + } + ); + dispatch({ + type: CSS_MESSAGE_ADD_MATCHING_ELEMENTS, + id: message.id, + elements: response.result, + }); + } catch (err) { + console.error(err); + } + }; +} + +function messageRemove(id) { + return { + type: MESSAGE_REMOVE, + id, + }; +} + +function networkMessageUpdates(packets, idGenerator = null) { + if (idGenerator == null) { + idGenerator = defaultIdGenerator; + } + + const messages = packets.map(packet => prepareMessage(packet, idGenerator)); + + return { + type: NETWORK_MESSAGES_UPDATE, + messages, + }; +} + +function networkUpdateRequests(updates) { + return { + type: NETWORK_UPDATES_REQUEST, + updates, + }; +} + +module.exports = { + messagesAdd, + messagesClear, + messagesDisable, + messageOpen, + messageClose, + messageRemove, + messageGetMatchingElements, + networkMessageUpdates, + networkUpdateRequests, + privateMessagesClear, + targetMessagesRemove, +}; diff --git a/devtools/client/webconsole/actions/moz.build b/devtools/client/webconsole/actions/moz.build new file mode 100644 index 0000000000..5b06311064 --- /dev/null +++ b/devtools/client/webconsole/actions/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "autocomplete.js", + "filters.js", + "history.js", + "index.js", + "input.js", + "messages.js", + "notifications.js", + "object.js", + "toolbox.js", + "ui.js", +) diff --git a/devtools/client/webconsole/actions/notifications.js b/devtools/client/webconsole/actions/notifications.js new file mode 100644 index 0000000000..2ffe96985b --- /dev/null +++ b/devtools/client/webconsole/actions/notifications.js @@ -0,0 +1,48 @@ +/* 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 { + APPEND_NOTIFICATION, + REMOVE_NOTIFICATION, +} = require("resource://devtools/client/webconsole/constants.js"); + +/** + * Append a notification into JSTerm notification list. + */ +function appendNotification( + label, + value, + image, + priority, + buttons = [], + eventCallback +) { + return { + type: APPEND_NOTIFICATION, + label, + value, + image, + priority, + buttons, + eventCallback, + }; +} + +/** + * Remove notification with specified value from JSTerm + * notification list. + */ +function removeNotification(value) { + return { + type: REMOVE_NOTIFICATION, + value, + }; +} + +module.exports = { + appendNotification, + removeNotification, +}; diff --git a/devtools/client/webconsole/actions/object.js b/devtools/client/webconsole/actions/object.js new file mode 100644 index 0000000000..c2b255bd4d --- /dev/null +++ b/devtools/client/webconsole/actions/object.js @@ -0,0 +1,63 @@ +/* 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"; + +loader.lazyServiceGetter( + this, + "clipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +function storeAsGlobal(actor) { + return async ({ commands, hud }) => { + const evalString = `{ let i = 0; + while (this.hasOwnProperty("temp" + i) && i < 1000) { + i++; + } + this["temp" + i] = _self; + "temp" + i; + }`; + + const res = await commands.scriptCommand.execute(evalString, { + selectedObjectActor: actor, + }); + + // Select the adhoc target in the console. + if (hud.toolbox) { + const objectFront = commands.client.getFrontByID(actor); + if (objectFront) { + const targetActorID = objectFront.targetFront?.actorID; + if (targetActorID) { + hud.toolbox.selectTarget(targetActorID); + } + } + } + + hud.focusInput(); + hud.setInputValue(res.result); + }; +} + +function copyMessageObject(actor, variableText) { + return async ({ commands }) => { + if (actor) { + // The Debugger.Object of the OA will be bound to |_self| during evaluation. + // See server/actors/webconsole/eval-with-debugger.js `evalWithDebugger`. + const res = await commands.scriptCommand.execute("copy(_self)", { + selectedObjectActor: actor, + }); + + clipboardHelper.copyString(res.helperResult.value); + } else { + clipboardHelper.copyString(variableText); + } + }; +} + +module.exports = { + storeAsGlobal, + copyMessageObject, +}; diff --git a/devtools/client/webconsole/actions/toolbox.js b/devtools/client/webconsole/actions/toolbox.js new file mode 100644 index 0000000000..65739f2ca5 --- /dev/null +++ b/devtools/client/webconsole/actions/toolbox.js @@ -0,0 +1,49 @@ +/* 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"; + +function openNetworkPanel(messageId) { + return ({ hud }) => { + hud.openNetworkPanel(messageId); + }; +} + +function resendNetworkRequest(messageId) { + return ({ hud }) => { + hud.resendNetworkRequest(messageId); + }; +} + +function highlightDomElement(grip) { + return ({ hud }) => { + const highlighter = hud.getHighlighter(); + if (highlighter) { + highlighter.highlight(grip); + } + }; +} + +function unHighlightDomElement(grip) { + return ({ hud }) => { + const highlighter = hud.getHighlighter(); + if (highlighter) { + highlighter.unhighlight(grip); + } + }; +} + +function openNodeInInspector(contentDomReference) { + return ({ hud }) => { + hud.openNodeInInspector({ contentDomReference }); + }; +} + +module.exports = { + highlightDomElement, + unHighlightDomElement, + openNetworkPanel, + resendNetworkRequest, + openNodeInInspector, +}; diff --git a/devtools/client/webconsole/actions/ui.js b/devtools/client/webconsole/actions/ui.js new file mode 100644 index 0000000000..a8ffaee0f3 --- /dev/null +++ b/devtools/client/webconsole/actions/ui.js @@ -0,0 +1,247 @@ +/* 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 { + getAllPrefs, +} = require("resource://devtools/client/webconsole/selectors/prefs.js"); +const { + getAllUi, +} = require("resource://devtools/client/webconsole/selectors/ui.js"); +const { + getMessage, +} = require("resource://devtools/client/webconsole/selectors/messages.js"); + +const { + INITIALIZE, + PERSIST_TOGGLE, + PREFS, + REVERSE_SEARCH_INPUT_TOGGLE, + SELECT_NETWORK_MESSAGE_TAB, + SHOW_OBJECT_IN_SIDEBAR, + SIDEBAR_CLOSE, + SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE, + TIMESTAMPS_TOGGLE, + WARNING_GROUPS_TOGGLE, + FILTERBAR_DISPLAY_MODE_SET, + EDITOR_TOGGLE, + EDITOR_SET_WIDTH, + EDITOR_ONBOARDING_DISMISS, + EAGER_EVALUATION_TOGGLE, + AUTOCOMPLETE_TOGGLE, + ENABLE_NETWORK_MONITORING, +} = require("resource://devtools/client/webconsole/constants.js"); + +function openLink(url, e) { + return ({ hud }) => { + return hud.openLink(url, e); + }; +} + +function persistToggle() { + return ({ dispatch, getState, prefsService }) => { + dispatch({ + type: PERSIST_TOGGLE, + }); + const uiState = getAllUi(getState()); + prefsService.setBoolPref(PREFS.UI.PERSIST, uiState.persistLogs); + }; +} + +function networkMonitoringToggle() { + return ({ dispatch, getState, prefsService, webConsoleUI }) => { + dispatch({ type: ENABLE_NETWORK_MONITORING }); + const uiState = getAllUi(getState()); + + prefsService.setBoolPref( + PREFS.UI.ENABLE_NETWORK_MONITORING, + uiState.enableNetworkMonitoring + ); + + if (uiState.enableNetworkMonitoring) { + webConsoleUI.startWatchingNetworkResources(); + } else { + webConsoleUI.stopWatchingNetworkResources(); + } + }; +} + +function timestampsToggle() { + return ({ dispatch, getState, prefsService }) => { + dispatch({ + type: TIMESTAMPS_TOGGLE, + }); + const uiState = getAllUi(getState()); + prefsService.setBoolPref( + PREFS.UI.MESSAGE_TIMESTAMP, + uiState.timestampsVisible + ); + }; +} + +function autocompleteToggle() { + return ({ dispatch, getState, prefsService }) => { + dispatch({ + type: AUTOCOMPLETE_TOGGLE, + }); + const prefsState = getAllPrefs(getState()); + prefsService.setBoolPref( + PREFS.FEATURES.AUTOCOMPLETE, + prefsState.autocomplete + ); + }; +} + +function warningGroupsToggle() { + return ({ dispatch, getState, prefsService }) => { + dispatch({ + type: WARNING_GROUPS_TOGGLE, + }); + const prefsState = getAllPrefs(getState()); + prefsService.setBoolPref( + PREFS.FEATURES.GROUP_WARNINGS, + prefsState.groupWarnings + ); + }; +} + +function eagerEvaluationToggle() { + return ({ dispatch, getState, prefsService }) => { + dispatch({ + type: EAGER_EVALUATION_TOGGLE, + }); + const prefsState = getAllPrefs(getState()); + prefsService.setBoolPref( + PREFS.FEATURES.EAGER_EVALUATION, + prefsState.eagerEvaluation + ); + }; +} + +function selectNetworkMessageTab(id) { + return { + type: SELECT_NETWORK_MESSAGE_TAB, + id, + }; +} + +function initialize() { + return { + type: INITIALIZE, + }; +} + +function sidebarClose() { + return { + type: SIDEBAR_CLOSE, + }; +} + +function splitConsoleCloseButtonToggle(shouldDisplayButton) { + return { + type: SPLIT_CONSOLE_CLOSE_BUTTON_TOGGLE, + shouldDisplayButton, + }; +} + +function editorToggle() { + return ({ dispatch, getState, prefsService }) => { + dispatch({ + type: EDITOR_TOGGLE, + }); + const uiState = getAllUi(getState()); + prefsService.setBoolPref(PREFS.UI.EDITOR, uiState.editor); + }; +} + +function editorOnboardingDismiss() { + return ({ dispatch, prefsService }) => { + dispatch({ + type: EDITOR_ONBOARDING_DISMISS, + }); + prefsService.setBoolPref(PREFS.UI.EDITOR_ONBOARDING, false); + }; +} + +function setEditorWidth(width) { + return ({ dispatch, prefsService }) => { + dispatch({ + type: EDITOR_SET_WIDTH, + width, + }); + prefsService.setIntPref(PREFS.UI.EDITOR_WIDTH, width); + }; +} + +/** + * Dispatches a SHOW_OBJECT_IN_SIDEBAR action, with a grip property corresponding to the + * {actor} parameter in the {messageId} message. + * + * @param {String} actorID: Actor id of the object we want to place in the sidebar. + * @param {String} messageId: id of the message containing the {actor} parameter. + */ +function showMessageObjectInSidebar(actorID, messageId) { + return ({ dispatch, getState }) => { + const { parameters } = getMessage(getState(), messageId); + if (Array.isArray(parameters)) { + for (const parameter of parameters) { + if (parameter && parameter.actorID === actorID) { + dispatch(showObjectInSidebar(parameter)); + return; + } + } + } + }; +} + +function showObjectInSidebar(front) { + return { + type: SHOW_OBJECT_IN_SIDEBAR, + front, + }; +} + +function reverseSearchInputToggle({ initialValue, access } = {}) { + return { + type: REVERSE_SEARCH_INPUT_TOGGLE, + initialValue, + access, + }; +} + +function filterBarDisplayModeSet(displayMode) { + return { + type: FILTERBAR_DISPLAY_MODE_SET, + displayMode, + }; +} + +function openSidebar(messageId, rootActorId) { + return ({ dispatch }) => { + dispatch(showMessageObjectInSidebar(rootActorId, messageId)); + }; +} + +module.exports = { + eagerEvaluationToggle, + editorOnboardingDismiss, + editorToggle, + filterBarDisplayModeSet, + initialize, + persistToggle, + reverseSearchInputToggle, + selectNetworkMessageTab, + setEditorWidth, + showMessageObjectInSidebar, + showObjectInSidebar, + sidebarClose, + splitConsoleCloseButtonToggle, + timestampsToggle, + networkMonitoringToggle, + warningGroupsToggle, + openLink, + openSidebar, + autocompleteToggle, +}; |