diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/debugger/src/actions | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/actions')
90 files changed, 10382 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/actions/README.md b/devtools/client/debugger/src/actions/README.md new file mode 100644 index 0000000000..d919247838 --- /dev/null +++ b/devtools/client/debugger/src/actions/README.md @@ -0,0 +1,26 @@ +## Actions + +### Best Practices + +#### Scheduling Async Actions + +There are several use-cases with async actions that involve scheduling: + +* we do one action and cancel subsequent actions +* we do one action and subsequent calls wait on the initial call +* we start an action and show a loading state + +If you want to wait on subsequent calls you need to store action promises. +[ex][req] + +If you just want to cancel subsequent calls, you can keep track of a pending +state in the store. [ex][state] + +The advantage of adding the pending state to the store is that we can use that +in the UI: + +* disable/hide the pretty print button +* show a progress ui + +[req]: https://github.com/firefox-devtools/debugger/blob/master/src/actions/sources/loadSourceText.js +[state]: https://github.com/firefox-devtools/debugger/blob/master/src/reducers/sources.js diff --git a/devtools/client/debugger/src/actions/ast/index.js b/devtools/client/debugger/src/actions/ast/index.js new file mode 100644 index 0000000000..ec2c1ae84c --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/index.js @@ -0,0 +1,5 @@ +/* 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/>. */ + +export { setInScopeLines } from "./setInScopeLines"; diff --git a/devtools/client/debugger/src/actions/ast/moz.build b/devtools/client/debugger/src/actions/ast/moz.build new file mode 100644 index 0000000000..5b0152d2ad --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/moz.build @@ -0,0 +1,11 @@ +# 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/. + +DIRS += [] + +CompiledModules( + "index.js", + "setInScopeLines.js", +) diff --git a/devtools/client/debugger/src/actions/ast/setInScopeLines.js b/devtools/client/debugger/src/actions/ast/setInScopeLines.js new file mode 100644 index 0000000000..72bd33b59f --- /dev/null +++ b/devtools/client/debugger/src/actions/ast/setInScopeLines.js @@ -0,0 +1,98 @@ +/* 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/>. */ + +import { + hasInScopeLines, + getSourceTextContent, + getVisibleSelectedFrame, +} from "../../selectors/index"; + +import { getSourceLineCount } from "../../utils/source"; + +import { isFulfilled } from "../../utils/async-value"; + +function getOutOfScopeLines(outOfScopeLocations) { + if (!outOfScopeLocations) { + return null; + } + + const uniqueLines = new Set(); + for (const location of outOfScopeLocations) { + for (let i = location.start.line; i < location.end.line; i++) { + uniqueLines.add(i); + } + } + + return uniqueLines; +} + +async function getInScopeLines( + location, + sourceTextContent, + { dispatch, getState, parserWorker } +) { + let locations = null; + if (location.line && parserWorker.isLocationSupported(location)) { + locations = await parserWorker.findOutOfScopeLocations(location); + } + + const linesOutOfScope = getOutOfScopeLines(locations); + const sourceNumLines = + !sourceTextContent || !isFulfilled(sourceTextContent) + ? 0 + : getSourceLineCount(sourceTextContent.value); + + const noLinesOutOfScope = + linesOutOfScope == null || linesOutOfScope.size == 0; + + // This operation can be very costly for large files so we sacrifice a bit of readability + // for performance sake. + // We initialize an array with a fixed size and we'll directly assign value for lines + // that are not out of scope. This is much faster than having an empty array and pushing + // into it. + const sourceLines = new Array(sourceNumLines); + for (let i = 0; i < sourceNumLines; i++) { + const line = i + 1; + if (noLinesOutOfScope || !linesOutOfScope.has(line)) { + sourceLines[i] = line; + } + } + + // Finally we need to remove any undefined values, i.e. the ones that were matching + // out of scope lines. + return sourceLines.filter(i => i != undefined); +} + +export function setInScopeLines() { + return async thunkArgs => { + const { getState, dispatch } = thunkArgs; + const visibleFrame = getVisibleSelectedFrame(getState()); + + if (!visibleFrame) { + return; + } + + const { location } = visibleFrame; + const sourceTextContent = getSourceTextContent(getState(), location); + + // Ignore if in scope lines have already be computed, or if the selected location + // doesn't have its content already fully fetched. + // The ParserWorker will only have the source text content once the source text content is fulfilled. + if ( + hasInScopeLines(getState(), location) || + !sourceTextContent || + !isFulfilled(sourceTextContent) + ) { + return; + } + + const lines = await getInScopeLines(location, sourceTextContent, thunkArgs); + + dispatch({ + type: "IN_SCOPE_LINES", + location, + lines, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js new file mode 100644 index 0000000000..3729fd2741 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js @@ -0,0 +1,331 @@ +/* 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/>. */ + +import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/index"; + +import { + getSource, + getSourceFromId, + getBreakpointPositionsForSource, + getSourceActorsForSource, +} from "../../selectors/index"; + +import { makeBreakpointId } from "../../utils/breakpoint/index"; +import { memoizeableAction } from "../../utils/memoizableAction"; +import { fulfilled } from "../../utils/async-value"; +import { + sourceMapToDebuggerLocation, + createLocation, +} from "../../utils/location"; +import { validateSource } from "../../utils/context"; + +/** + * Helper function which consumes breakpoints positions sent by the server + * and map them to location objects. + * During this process, the SourceMapLoader will be queried to map the positions from generated to original locations. + * + * @param {Object} breakpointPositions + * The positions to map related to the generated source: + * { + * 1: [ 2, 6 ], // Line 1 is breakable on column 2 and 6 + * 2: [ 2 ], // Line 2 is only breakable on column 2 + * } + * @param {Object} generatedSource + * @param {Object} location + * The current location we are computing breakable positions. + * @param {Object} thunk arguments + * @return {Object} + * The mapped breakable locations in the original source: + * { + * 1: [ { source, line: 1, column: 2} , { source, line: 1, column 6 } ], // Line 1 is not mapped as location are same as breakpointPositions. + * 10: [ { source, line: 10, column: 28 } ], // Line 2 is mapped and locations and line key refers to the original source positions. + * } + */ +async function mapToLocations( + breakpointPositions, + generatedSource, + mappedLocation, + { getState, sourceMapLoader } +) { + // Map breakable positions from generated to original locations. + let mappedBreakpointPositions = await sourceMapLoader.getOriginalLocations( + breakpointPositions, + generatedSource.id + ); + // The Source Map Loader will return null when there is no source map for that generated source. + // Consider the map as unrelated to source map and process the source actor positions as-is. + if (!mappedBreakpointPositions) { + mappedBreakpointPositions = breakpointPositions; + } + + const positions = {}; + + // Ensure that we have an entry for the line fetched + if (typeof mappedLocation.line === "number") { + positions[mappedLocation.line] = []; + } + + const handledBreakpointIds = new Set(); + const isOriginal = mappedLocation.source.isOriginal; + const originalSourceId = mappedLocation.source.id; + + for (let line in mappedBreakpointPositions) { + // createLocation expects a number and not a string. + line = parseInt(line, 10); + for (const columnOrSourceMapLocation of mappedBreakpointPositions[line]) { + let location, generatedLocation; + + // When processing a source unrelated to source map, `mappedBreakpointPositions` will be equal to `breakpointPositions`. + // and columnOrSourceMapLocation will always be a number. + // But it will also be a number if we process a source mapped file and SourceMapLoader didn't find any valid mapping + // for the current position (line and column). + // When this happen to be a number it means it isn't mapped and columnOrSourceMapLocation refers to the column index. + if (typeof columnOrSourceMapLocation == "number") { + // If columnOrSourceMapLocation is a number, it means that this location doesn't mapped to an original source. + // So if we are currently computation positions for an original source, we can skip this breakable positions. + if (isOriginal) { + continue; + } + location = generatedLocation = createLocation({ + line, + column: columnOrSourceMapLocation, + source: generatedSource, + }); + } else { + // Otherwise, for sources which are mapped. `columnOrSourceMapLocation` will be a SourceMapLoader location object. + // This location object will refer to the location where the current column (columnOrSourceMapLocation.generatedColumn) + // mapped in the original file. + + // When computing positions for an original source, ignore the location if that mapped to another original source. + if ( + isOriginal && + columnOrSourceMapLocation.sourceId != originalSourceId + ) { + continue; + } + + location = sourceMapToDebuggerLocation( + getState(), + columnOrSourceMapLocation + ); + + // Merge positions that refer to duplicated positions. + // Some sourcemaped positions might refer to the exact same source/line/column triple. + const breakpointId = makeBreakpointId(location); + if (handledBreakpointIds.has(breakpointId)) { + continue; + } + handledBreakpointIds.add(breakpointId); + + generatedLocation = createLocation({ + line, + column: columnOrSourceMapLocation.generatedColumn, + source: generatedSource, + }); + } + + // The positions stored in redux will be keyed by original source's line (if we are + // computing the original source positions), or the generated source line. + // Note that when we compute the bundle positions, location may refer to the original source, + // but we still want to use the generated location as key. + const keyLocation = isOriginal ? location : generatedLocation; + const keyLine = keyLocation.line; + if (!positions[keyLine]) { + positions[keyLine] = []; + } + positions[keyLine].push({ location, generatedLocation }); + } + } + + return positions; +} + +async function _setBreakpointPositions(location, thunkArgs) { + const { client, dispatch, getState, sourceMapLoader } = thunkArgs; + const results = {}; + let generatedSource = location.source; + if (location.source.isOriginal) { + const ranges = await sourceMapLoader.getGeneratedRangesForOriginal( + location.source.id, + true + ); + const generatedSourceId = originalToGeneratedId(location.source.id); + generatedSource = getSourceFromId(getState(), generatedSourceId); + + // Note: While looping here may not look ideal, in the vast majority of + // cases, the number of ranges here should be very small, and is quite + // likely to only be a single range. + for (const range of ranges) { + // Wrap infinite end positions to the next line to keep things simple + // and because we know we don't care about the end-line whitespace + // in this case. + if (range.end.column === Infinity) { + range.end = { + line: range.end.line + 1, + column: 0, + }; + } + + // Retrieve the positions for all the source actors for the related generated source. + // There might be many if it is loaded many times. + // We limit the retrieval of positions within the given range, so that we don't + // retrieve the whole bundle positions. + const allActorsPositions = await Promise.all( + getSourceActorsForSource(getState(), generatedSourceId).map(actor => + client.getSourceActorBreakpointPositions(actor, range) + ) + ); + + // `allActorsPositions` looks like this: + // [ + // { // Positions for the first source actor + // 1: [ 2, 6 ], // Line 1 is breakable on column 2 and 6 + // 2: [ 2 ], // Line 2 is only breakable on column 2 + // }, + // {...} // Positions for another source actor + // ] + for (const actorPositions of allActorsPositions) { + for (const rangeLine in actorPositions) { + const columns = actorPositions[rangeLine]; + + // Merge all actors's breakable columns and avoid duplication of columns reported as breakable + const existing = results[rangeLine]; + if (existing) { + for (const column of columns) { + if (!existing.includes(column)) { + existing.push(column); + } + } + } else { + results[rangeLine] = columns; + } + } + } + } + } else { + const { line } = location; + if (typeof line !== "number") { + throw new Error("Line is required for generated sources"); + } + + // We only retrieve the positions for the given requested line, that, for each source actor. + // There might be many source actor, if it is loaded many times. + // Or if this is an html page, with many inline scripts. + const allActorsBreakableColumns = await Promise.all( + getSourceActorsForSource(getState(), location.source.id).map( + async actor => { + const positions = await client.getSourceActorBreakpointPositions( + actor, + { + // Only retrieve positions for the given line + start: { line, column: 0 }, + end: { line: line + 1, column: 0 }, + } + ); + return positions[line] || []; + } + ) + ); + + for (const columns of allActorsBreakableColumns) { + // Merge all actors's breakable columns and avoid duplication of columns reported as breakable + const existing = results[line]; + if (existing) { + for (const column of columns) { + if (!existing.includes(column)) { + existing.push(column); + } + } + } else { + results[line] = columns; + } + } + } + + const positions = await mapToLocations( + results, + generatedSource, + location, + thunkArgs + ); + // `mapToLocations` may compute for a little while asynchronously, + // ensure that the location is still valid before continuing. + validateSource(getState(), location.source); + + dispatch({ + type: "ADD_BREAKPOINT_POSITIONS", + source: location.source, + positions, + }); +} + +function generatedSourceActorKey(state, source) { + const generatedSource = getSource( + state, + source.isOriginal ? originalToGeneratedId(source.id) : source.id + ); + const actors = generatedSource + ? getSourceActorsForSource(state, generatedSource.id).map( + ({ actor }) => actor + ) + : []; + return [source.id, ...actors].join(":"); +} + +/** + * This method will force retrieving the breakable positions for a given source, on a given line. + * If this data has already been computed, it will returned the cached data. + * + * For original sources, this will query the SourceMap worker. + * For generated sources, this will query the DevTools server and the related source actors. + * + * @param Object options + * Dictionary object with many arguments: + * @param String options.sourceId + * The source we want to fetch breakable positions + * @param Number options.line + * The line we want to know which columns are breakable. + * (note that this seems to be optional for original sources) + * @return Array<Object> + * The list of all breakable positions, each object of this array will be like this: + * { + * line: Number + * column: Number + * source: Source object + * } + */ +export const setBreakpointPositions = memoizeableAction( + "setBreakpointPositions", + { + getValue: (location, { getState }) => { + const positions = getBreakpointPositionsForSource( + getState(), + location.source.id + ); + if (!positions) { + return null; + } + + if ( + !location.source.isOriginal && + location.line && + !positions[location.line] + ) { + // We always return the full position dataset, but if a given line is + // not available, we treat the whole set as loading. + return null; + } + + return fulfilled(positions); + }, + createKey(location, { getState }) { + const key = generatedSourceActorKey(getState(), location.source); + return !location.source.isOriginal && location.line + ? `${key}-${location.line}` + : key; + }, + action: async (location, thunkArgs) => + _setBreakpointPositions(location, thunkArgs), + } +); diff --git a/devtools/client/debugger/src/actions/breakpoints/index.js b/devtools/client/debugger/src/actions/breakpoints/index.js new file mode 100644 index 0000000000..2125ec9ec7 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/index.js @@ -0,0 +1,395 @@ +/* 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/>. */ + +/** + * Redux actions for breakpoints + * @module actions/breakpoints + */ + +import { PROMISE } from "../utils/middleware/promise"; +import { asyncStore } from "../../utils/prefs"; +import { createLocation } from "../../utils/location"; +import { + getBreakpointsList, + getXHRBreakpoints, + getSelectedSource, + getBreakpointAtLocation, + getBreakpointsForSource, + getBreakpointsAtLine, +} from "../../selectors/index"; +import { createXHRBreakpoint } from "../../utils/breakpoint/index"; +import { + addBreakpoint, + removeBreakpoint, + enableBreakpoint, + disableBreakpoint, +} from "./modify"; +import { getOriginalLocation } from "../../utils/source-maps"; + +export * from "./breakpointPositions"; +export * from "./modify"; +export * from "./syncBreakpoint"; + +export function addHiddenBreakpoint(location) { + return ({ dispatch }) => { + return dispatch(addBreakpoint(location, { hidden: true })); + }; +} + +/** + * Disable all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function disableBreakpointsInSource(source) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsForSource(getState(), source); + for (const breakpoint of breakpoints) { + if (!breakpoint.disabled) { + dispatch(disableBreakpoint(breakpoint)); + } + } + }; +} + +/** + * Enable all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function enableBreakpointsInSource(source) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsForSource(getState(), source); + for (const breakpoint of breakpoints) { + if (breakpoint.disabled) { + dispatch(enableBreakpoint(breakpoint)); + } + } + }; +} + +/** + * Toggle All Breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function toggleAllBreakpoints(shouldDisableBreakpoints) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsList(getState()); + + for (const breakpoint of breakpoints) { + if (shouldDisableBreakpoints) { + dispatch(disableBreakpoint(breakpoint)); + } else { + dispatch(enableBreakpoint(breakpoint)); + } + } + }; +} + +/** + * Toggle Breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function toggleBreakpoints(shouldDisableBreakpoints, breakpoints) { + return async ({ dispatch }) => { + const promises = breakpoints.map(breakpoint => + shouldDisableBreakpoints + ? dispatch(disableBreakpoint(breakpoint)) + : dispatch(enableBreakpoint(breakpoint)) + ); + + await Promise.all(promises); + }; +} + +export function toggleBreakpointsAtLine(shouldDisableBreakpoints, line) { + return async ({ dispatch, getState }) => { + const breakpoints = getBreakpointsAtLine(getState(), line); + return dispatch(toggleBreakpoints(shouldDisableBreakpoints, breakpoints)); + }; +} + +/** + * Removes all breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function removeAllBreakpoints() { + return async ({ dispatch, getState }) => { + const breakpointList = getBreakpointsList(getState()); + await Promise.all(breakpointList.map(bp => dispatch(removeBreakpoint(bp)))); + dispatch({ type: "CLEAR_BREAKPOINTS" }); + }; +} + +/** + * Removes breakpoints + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpoints(breakpoints) { + return async ({ dispatch }) => { + return Promise.all(breakpoints.map(bp => dispatch(removeBreakpoint(bp)))); + }; +} + +/** + * Removes all breakpoints in a source + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpointsInSource(source) { + return async ({ dispatch, getState, client }) => { + const breakpoints = getBreakpointsForSource(getState(), source); + for (const breakpoint of breakpoints) { + dispatch(removeBreakpoint(breakpoint)); + } + }; +} + +/** + * Update the original location information of breakpoints. + +/* + * Update breakpoints for a source that just got pretty printed. + * This method maps the breakpoints currently set only against the + * non-pretty-printed (generated) source to the related pretty-printed + * (original) source by querying the SourceMap service. + * + * @param {String} source - the generated source + */ +export function updateBreakpointsForNewPrettyPrintedSource(source) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + if (source.isOriginal) { + console.error("Can't update breakpoints on original sources"); + return; + } + const breakpoints = getBreakpointsForSource(getState(), source); + // Remap the breakpoints with the original location information from + // the pretty-printed source. + const newBreakpoints = await Promise.all( + breakpoints.map(async breakpoint => { + const location = await getOriginalLocation( + breakpoint.generatedLocation, + thunkArgs + ); + return { ...breakpoint, location }; + }) + ); + + // Normally old breakpoints will be clobbered if we re-add them, but when + // remapping we have changed the source maps and the old breakpoints will + // have different locations than the new ones. Manually remove the + // old breakpoints before adding the new ones. + for (const bp of breakpoints) { + dispatch(removeBreakpoint(bp)); + } + + for (const bp of newBreakpoints) { + await dispatch(addBreakpoint(bp.location, bp.options, bp.disabled)); + } + }; +} + +export function toggleBreakpointAtLine(line) { + return ({ dispatch, getState }) => { + const state = getState(); + const selectedSource = getSelectedSource(state); + + if (!selectedSource) { + return null; + } + + const bp = getBreakpointAtLocation(state, { line, column: undefined }); + if (bp) { + return dispatch(removeBreakpoint(bp)); + } + return dispatch( + addBreakpoint( + createLocation({ + source: selectedSource, + line, + }) + ) + ); + }; +} + +export function addBreakpointAtLine(line, shouldLog = false, disabled = false) { + return ({ dispatch, getState }) => { + const state = getState(); + const source = getSelectedSource(state); + + if (!source) { + return null; + } + const breakpointLocation = createLocation({ + source, + column: undefined, + line, + }); + + const options = {}; + if (shouldLog) { + options.logValue = "displayName"; + } + + return dispatch(addBreakpoint(breakpointLocation, options, disabled)); + }; +} + +export function removeBreakpointsAtLine(source, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource(getState(), source, line); + return dispatch(removeBreakpoints(breakpointsAtLine)); + }; +} + +export function disableBreakpointsAtLine(source, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource(getState(), source, line); + return dispatch(toggleBreakpoints(true, breakpointsAtLine)); + }; +} + +export function enableBreakpointsAtLine(source, line) { + return ({ dispatch, getState }) => { + const breakpointsAtLine = getBreakpointsForSource(getState(), source, line); + return dispatch(toggleBreakpoints(false, breakpointsAtLine)); + }; +} + +export function toggleDisabledBreakpoint(breakpoint) { + return ({ dispatch, getState }) => { + if (!breakpoint.disabled) { + return dispatch(disableBreakpoint(breakpoint)); + } + return dispatch(enableBreakpoint(breakpoint)); + }; +} + +export function enableXHRBreakpoint(index, bp) { + return ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = bp || xhrBreakpoints[index]; + const enabledBreakpoint = { + ...breakpoint, + disabled: false, + }; + + return dispatch({ + type: "ENABLE_XHR_BREAKPOINT", + breakpoint: enabledBreakpoint, + index, + [PROMISE]: client.setXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} + +export function disableXHRBreakpoint(index, bp) { + return ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = bp || xhrBreakpoints[index]; + const disabledBreakpoint = { + ...breakpoint, + disabled: true, + }; + + return dispatch({ + type: "DISABLE_XHR_BREAKPOINT", + breakpoint: disabledBreakpoint, + index, + [PROMISE]: client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} + +export function updateXHRBreakpoint(index, path, method) { + return ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = xhrBreakpoints[index]; + + const updatedBreakpoint = { + ...breakpoint, + path, + method, + text: L10N.getFormatStr("xhrBreakpoints.item.label", path), + }; + + return dispatch({ + type: "UPDATE_XHR_BREAKPOINT", + breakpoint: updatedBreakpoint, + index, + [PROMISE]: Promise.all([ + client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + client.setXHRBreakpoint(path, method), + ]), + }); + }; +} +export function togglePauseOnAny() { + return ({ dispatch, getState }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const index = xhrBreakpoints.findIndex(({ path }) => path.length === 0); + if (index < 0) { + return dispatch(setXHRBreakpoint("", "ANY")); + } + + const bp = xhrBreakpoints[index]; + if (bp.disabled) { + return dispatch(enableXHRBreakpoint(index, bp)); + } + + return dispatch(disableXHRBreakpoint(index, bp)); + }; +} + +export function setXHRBreakpoint(path, method) { + return ({ dispatch, getState, client }) => { + const breakpoint = createXHRBreakpoint(path, method); + + return dispatch({ + type: "SET_XHR_BREAKPOINT", + breakpoint, + [PROMISE]: client.setXHRBreakpoint(path, method), + }); + }; +} + +export function removeAllXHRBreakpoints() { + return async ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const promises = xhrBreakpoints.map(breakpoint => + client.removeXHRBreakpoint(breakpoint.path, breakpoint.method) + ); + await dispatch({ + type: "CLEAR_XHR_BREAKPOINTS", + [PROMISE]: Promise.all(promises), + }); + asyncStore.xhrBreakpoints = []; + }; +} + +export function removeXHRBreakpoint(index) { + return ({ dispatch, getState, client }) => { + const xhrBreakpoints = getXHRBreakpoints(getState()); + const breakpoint = xhrBreakpoints[index]; + return dispatch({ + type: "REMOVE_XHR_BREAKPOINT", + breakpoint, + index, + [PROMISE]: client.removeXHRBreakpoint(breakpoint.path, breakpoint.method), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/modify.js b/devtools/client/debugger/src/actions/breakpoints/modify.js new file mode 100644 index 0000000000..b083baf7ea --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/modify.js @@ -0,0 +1,368 @@ +/* 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/>. */ + +import { createBreakpoint } from "../../client/firefox/create"; +import { + makeBreakpointServerLocation, + makeBreakpointId, +} from "../../utils/breakpoint/index"; +import { + getBreakpoint, + getBreakpointPositionsForLocation, + getFirstBreakpointPosition, + getSettledSourceTextContent, + getBreakpointsList, + getPendingBreakpointList, + isMapScopesEnabled, + getBlackBoxRanges, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors/index"; + +import { setBreakpointPositions } from "./breakpointPositions"; +import { setSkipPausing } from "../pause/skipPausing"; + +import { PROMISE } from "../utils/middleware/promise"; +import { recordEvent } from "../../utils/telemetry"; +import { comparePosition } from "../../utils/location"; +import { getTextAtPosition, isLineBlackboxed } from "../../utils/source"; +import { getMappedScopesForLocation } from "../pause/mapScopes"; +import { validateBreakpoint } from "../../utils/context"; + +// This file has the primitive operations used to modify individual breakpoints +// and keep them in sync with the breakpoints installed on server threads. These +// are collected here to make it easier to preserve the following invariant: +// +// Breakpoints are included in reducer state if they are disabled or requests +// have been dispatched to set them in all server threads. +// +// To maintain this property, updates to the reducer and installed breakpoints +// must happen with no intervening await. Using await allows other operations to +// modify the breakpoint state in the interim and potentially cause breakpoint +// state to go out of sync. +// +// The reducer is optimistically updated when users set or remove a breakpoint, +// but it might take a little while before the breakpoints have been set or +// removed in each thread. Once all outstanding requests sent to a thread have +// been processed, the reducer and server threads will be in sync. +// +// There is another exception to the above invariant when first connecting to +// the server: breakpoints have been installed on all generated locations in the +// pending breakpoints, but no breakpoints have been added to the reducer. When +// a matching source appears, either the server breakpoint will be removed or a +// breakpoint will be added to the reducer, to restore the above invariant. +// See syncBreakpoint.js for more. + +async function clientSetBreakpoint(client, { getState, dispatch }, breakpoint) { + const breakpointServerLocation = makeBreakpointServerLocation( + getState(), + breakpoint.generatedLocation + ); + const shouldMapBreakpointExpressions = + isMapScopesEnabled(getState()) && + breakpoint.location.source.isOriginal && + (breakpoint.options.logValue || breakpoint.options.condition); + + if (shouldMapBreakpointExpressions) { + breakpoint = await dispatch(updateBreakpointSourceMapping(breakpoint)); + } + return client.setBreakpoint(breakpointServerLocation, breakpoint.options); +} + +function clientRemoveBreakpoint(client, state, generatedLocation) { + const breakpointServerLocation = makeBreakpointServerLocation( + state, + generatedLocation + ); + return client.removeBreakpoint(breakpointServerLocation); +} + +export function enableBreakpoint(initialBreakpoint) { + return thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + const state = getState(); + const breakpoint = getBreakpoint(state, initialBreakpoint.location); + const blackboxedRanges = getBlackBoxRanges(state); + const isSourceOnIgnoreList = + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, breakpoint.location.source); + if ( + !breakpoint || + !breakpoint.disabled || + isLineBlackboxed( + blackboxedRanges[breakpoint.location.source.url], + breakpoint.location.line, + isSourceOnIgnoreList + ) + ) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + breakpoint: createBreakpoint({ ...breakpoint, disabled: false }), + [PROMISE]: clientSetBreakpoint(client, thunkArgs, breakpoint), + }); + }; +} + +export function addBreakpoint( + initialLocation, + options = {}, + disabled, + shouldCancel = () => false +) { + return async thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + recordEvent("add_breakpoint"); + + await dispatch(setBreakpointPositions(initialLocation)); + + const position = initialLocation.column + ? getBreakpointPositionsForLocation(getState(), initialLocation) + : getFirstBreakpointPosition(getState(), initialLocation); + + // No position is found if the `initialLocation` is on a non-breakable line or + // the line no longer exists. + if (!position) { + return null; + } + + const { location, generatedLocation } = position; + + if (!location.source || !generatedLocation.source) { + return null; + } + + const originalContent = getSettledSourceTextContent(getState(), location); + const originalText = getTextAtPosition( + location.source.id, + originalContent, + location + ); + + const content = getSettledSourceTextContent(getState(), generatedLocation); + const text = getTextAtPosition( + generatedLocation.source.id, + content, + generatedLocation + ); + + const id = makeBreakpointId(location); + const breakpoint = createBreakpoint({ + id, + disabled, + options, + location, + generatedLocation, + text, + originalText, + }); + + if (shouldCancel()) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + breakpoint, + // If we just clobbered an enabled breakpoint with a disabled one, we need + // to remove any installed breakpoint in the server. + [PROMISE]: disabled + ? clientRemoveBreakpoint(client, getState(), generatedLocation) + : clientSetBreakpoint(client, thunkArgs, breakpoint), + }); + }; +} + +/** + * Remove a single breakpoint + * + * @memberof actions/breakpoints + * @static + */ +export function removeBreakpoint(initialBreakpoint) { + return ({ dispatch, getState, client }) => { + recordEvent("remove_breakpoint"); + + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "REMOVE_BREAKPOINT", + breakpoint, + // If the breakpoint is disabled then it is not installed in the server. + [PROMISE]: breakpoint.disabled + ? Promise.resolve() + : clientRemoveBreakpoint( + client, + getState(), + breakpoint.generatedLocation + ), + }); + }; +} + +/** + * Remove all installed, pending, and client breakpoints associated with a + * target generated location. + * + * @param {Object} target + * Location object where to remove breakpoints. + */ +export function removeBreakpointAtGeneratedLocation(target) { + return ({ dispatch, getState, client }) => { + // remove breakpoint from the server + const onBreakpointRemoved = clientRemoveBreakpoint( + client, + getState(), + target + ); + // Remove any breakpoints matching the generated location. + const breakpoints = getBreakpointsList(getState()); + for (const breakpoint of breakpoints) { + const { generatedLocation } = breakpoint; + if ( + generatedLocation.source.id == target.source.id && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_BREAKPOINT", + breakpoint, + [PROMISE]: onBreakpointRemoved, + }); + } + } + + // Remove any remaining pending breakpoints matching the generated location. + const pending = getPendingBreakpointList(getState()); + for (const pendingBreakpoint of pending) { + const { generatedLocation } = pendingBreakpoint; + if ( + generatedLocation.sourceUrl == target.source.url && + comparePosition(generatedLocation, target) + ) { + dispatch({ + type: "REMOVE_PENDING_BREAKPOINT", + pendingBreakpoint, + }); + } + } + return onBreakpointRemoved; + }; +} + +/** + * Disable a single breakpoint + * + * @memberof actions/breakpoints + * @static + */ +export function disableBreakpoint(initialBreakpoint) { + return ({ dispatch, getState, client }) => { + const breakpoint = getBreakpoint(getState(), initialBreakpoint.location); + if (!breakpoint || breakpoint.disabled) { + return null; + } + + dispatch(setSkipPausing(false)); + return dispatch({ + type: "SET_BREAKPOINT", + breakpoint: createBreakpoint({ ...breakpoint, disabled: true }), + [PROMISE]: clientRemoveBreakpoint( + client, + getState(), + breakpoint.generatedLocation + ), + }); + }; +} + +/** + * Update the options of a breakpoint. + * + * @throws {Error} "not implemented" + * @memberof actions/breakpoints + * @static + * @param {SourceLocation} location + * @see DebuggerController.Breakpoints.addBreakpoint + * @param {Object} options + * Any options to set on the breakpoint + */ +export function setBreakpointOptions(location, options = {}) { + return thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + let breakpoint = getBreakpoint(getState(), location); + if (!breakpoint) { + return dispatch(addBreakpoint(location, options)); + } + + // Note: setting a breakpoint's options implicitly enables it. + breakpoint = createBreakpoint({ ...breakpoint, disabled: false, options }); + + return dispatch({ + type: "SET_BREAKPOINT", + breakpoint, + [PROMISE]: clientSetBreakpoint(client, thunkArgs, breakpoint), + }); + }; +} + +async function updateExpression(parserWorker, mappings, originalExpression) { + const mapped = await parserWorker.mapExpression( + originalExpression, + mappings, + [], + false, + false + ); + if (!mapped) { + return originalExpression; + } + if (!originalExpression.trimEnd().endsWith(";")) { + return mapped.expression.replace(/;$/, ""); + } + return mapped.expression; +} + +function updateBreakpointSourceMapping(breakpoint) { + return async ({ getState, dispatch, parserWorker }) => { + const options = { ...breakpoint.options }; + + const mappedScopes = await dispatch( + getMappedScopesForLocation(breakpoint.location) + ); + if (!mappedScopes) { + return breakpoint; + } + const { mappings } = mappedScopes; + + if (options.condition) { + options.condition = await updateExpression( + parserWorker, + mappings, + options.condition + ); + } + if (options.logValue) { + options.logValue = await updateExpression( + parserWorker, + mappings, + options.logValue + ); + } + + // As we waited for lots of asynchronous operations, + // verify that the breakpoint is still valid before + // trying to set/update it on the server. + validateBreakpoint(getState(), breakpoint); + + return { ...breakpoint, options }; + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/moz.build b/devtools/client/debugger/src/actions/breakpoints/moz.build new file mode 100644 index 0000000000..65910c4ef2 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/moz.build @@ -0,0 +1,13 @@ +# 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/. + +DIRS += [] + +CompiledModules( + "breakpointPositions.js", + "index.js", + "modify.js", + "syncBreakpoint.js", +) diff --git a/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js new file mode 100644 index 0000000000..b24912de58 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { setBreakpointPositions } from "./breakpointPositions"; +import { + findPosition, + makeBreakpointServerLocation, +} from "../../utils/breakpoint/index"; + +import { comparePosition, createLocation } from "../../utils/location"; + +import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/index"; +import { getSource } from "../../selectors/index"; +import { addBreakpoint, removeBreakpointAtGeneratedLocation } from "./modify"; + +async function findBreakpointPosition({ getState, dispatch }, location) { + const positions = await dispatch(setBreakpointPositions(location)); + + const position = findPosition(positions, location); + return position; +} + +// Breakpoint syncing occurs when a source is found that matches either the +// original or generated URL of a pending breakpoint. A new breakpoint is +// constructed that might have a different original and/or generated location, +// if the original source has changed since the pending breakpoint was created. +// There are a couple subtle aspects to syncing: +// +// - We handle both the original and generated source because there is no +// guarantee that seeing the generated source means we will also see the +// original source. When connecting, a breakpoint will be installed in the +// client for the generated location in the pending breakpoint, and we need +// to make sure that either a breakpoint is added to the reducer or that this +// client breakpoint is deleted. +// +// - If we see both the original and generated sources and the source mapping +// has changed, we need to make sure that only a single breakpoint is added +// to the reducer for the new location corresponding to the original location +// in the pending breakpoint. +export function syncPendingBreakpoint(source, pendingBreakpoint) { + return async thunkArgs => { + const { getState, client, dispatch } = thunkArgs; + + const generatedSourceId = source.isOriginal + ? originalToGeneratedId(source.id) + : source.id; + + const generatedSource = getSource(getState(), generatedSourceId); + + if (!source || !generatedSource) { + return null; + } + + // /!\ Pending breakpoint locations come only with sourceUrl, line and column attributes. + // We have to map it to a specific source object and avoid trying to query its non-existent 'source' attribute. + const { location, generatedLocation } = pendingBreakpoint; + const isPendingBreakpointWithSourceMap = + location.sourceUrl != generatedLocation.sourceUrl; + const sourceGeneratedLocation = createLocation({ + ...generatedLocation, + source: generatedSource, + }); + + if (source == generatedSource && isPendingBreakpointWithSourceMap) { + // We are handling the generated source and the pending breakpoint has a + // source mapping. Supply a cancellation callback that will abort the + // breakpoint if the original source was synced to a different location, + // in which case the client breakpoint has been removed. + const breakpointServerLocation = makeBreakpointServerLocation( + getState(), + sourceGeneratedLocation + ); + return dispatch( + addBreakpoint( + sourceGeneratedLocation, + pendingBreakpoint.options, + pendingBreakpoint.disabled, + () => !client.hasBreakpoint(breakpointServerLocation) + ) + ); + } + + const originalLocation = createLocation({ + ...location, + source, + }); + + const newPosition = await findBreakpointPosition( + thunkArgs, + originalLocation + ); + + const newGeneratedLocation = newPosition?.generatedLocation; + if (!newGeneratedLocation) { + // We couldn't find a new mapping for the breakpoint. If there is a source + // mapping, remove any breakpoints for the generated location, as if the + // breakpoint moved. If the old generated location still maps to an + // original location then we don't want to add a breakpoint for it. + if (isPendingBreakpointWithSourceMap) { + dispatch(removeBreakpointAtGeneratedLocation(sourceGeneratedLocation)); + } + return null; + } + + const isSameLocation = comparePosition( + generatedLocation, + newGeneratedLocation + ); + + // If the new generated location has changed from that in the pending + // breakpoint, remove any breakpoint associated with the old generated + // location. + if (!isSameLocation) { + dispatch(removeBreakpointAtGeneratedLocation(sourceGeneratedLocation)); + } + + return dispatch( + addBreakpoint( + newGeneratedLocation, + pendingBreakpoint.options, + pendingBreakpoint.disabled + ) + ); + }; +} diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap new file mode 100644 index 0000000000..e6abad25d5 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`breakpoints should add a breakpoint 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 1, + "line": 2, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + "sourceActor": null, + "sourceActorId": undefined, + }, + "id": "a:2:1", + "location": Object { + "column": 1, + "line": 2, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + "sourceActor": null, + "sourceActorId": undefined, + }, + "options": Object {}, + "originalText": "return a", + "text": "return a", + "thread": undefined, + }, + ], + "filename": "a", + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + }, +] +`; + +exports[`breakpoints should not show a breakpoint that does not have text 1`] = `Array []`; + +exports[`breakpoints should show a disabled breakpoint that does not have text 1`] = ` +Array [ + Object { + "breakpoints": Array [ + Object { + "disabled": true, + "generatedLocation": Object { + "column": 1, + "line": 5, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + "sourceActor": null, + "sourceActorId": undefined, + }, + "id": "a:5:1", + "location": Object { + "column": 1, + "line": 5, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + "sourceActor": null, + "sourceActorId": undefined, + }, + "options": Object {}, + "originalText": "", + "text": "", + "thread": undefined, + }, + ], + "filename": "a", + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "a", + "group": "localhost:8000", + "path": "/examples/a", + "search": "", + }, + "extensionName": null, + "id": "a", + "isExtension": false, + "isHTML": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "url": "http://localhost:8000/examples/a", + }, + }, +] +`; diff --git a/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js new file mode 100644 index 0000000000..8096379429 --- /dev/null +++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js @@ -0,0 +1,476 @@ +/* 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/>. */ + +import { + createStore, + selectors, + actions, + makeSource, + getTelemetryEvents, +} from "../../../utils/test-head"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; +import { createLocation } from "../../../utils/location"; + +jest.mock("../../../utils/prefs", () => ({ + prefs: { + expressions: [], + }, + asyncStore: { + pendingBreakpoints: {}, + }, + features: { + inlinePreview: true, + }, +})); + +function mockClient(positionsResponse = {}) { + return { + ...mockCommandClient, + setSkipPausing: jest.fn(), + getSourceActorBreakpointPositions: async () => positionsResponse, + getSourceActorBreakableLines: async () => [], + }; +} + +describe("breakpoints", () => { + it("should add a breakpoint", async () => { + const { dispatch, getState } = createStore(mockClient({ 2: [1] })); + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + source.url = "http://localhost:8000/examples/a"; + const loc1 = createLocation({ + source, + line: 2, + column: 1, + }); + await dispatch( + actions.selectLocation( + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(loc1)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(getTelemetryEvents("add_breakpoint")).toHaveLength(1); + + const bpSources = selectors.getBreakpointSources(getState()); + expect(bpSources).toMatchSnapshot(); + }); + + it("should not show a breakpoint that does not have text", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1] })); + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + source.url = "http://localhost:8000/examples/a"; + const loc1 = createLocation({ + source, + line: 5, + column: 1, + }); + await dispatch( + actions.selectLocation( + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(loc1)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(selectors.getBreakpointSources(getState())).toMatchSnapshot(); + }); + + it("should show a disabled breakpoint that does not have text", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1] })); + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + source.url = "http://localhost:8000/examples/a"; + const loc1 = createLocation({ + source, + line: 5, + column: 1, + }); + await dispatch( + actions.selectLocation( + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(loc1)); + const breakpoint = selectors.getBreakpoint(getState(), loc1); + if (!breakpoint) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(breakpoint)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + expect(selectors.getBreakpointSources(getState())).toMatchSnapshot(); + }); + + it("should not re-add a breakpoint", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1] })); + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + source.url = "http://localhost:8000/examples/a"; + const loc1 = createLocation({ + source, + line: 5, + column: 1, + }); + await dispatch( + actions.selectLocation( + createLocation({ + source, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(loc1)); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.location).toEqual(loc1); + + await dispatch(actions.addBreakpoint(loc1)); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("should remove a breakpoint", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1], 6: [2] })); + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + aSource.url = "http://localhost:8000/examples/a"; + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + bSource.url = "http://localhost:8000/examples/b"; + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + }); + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + + await dispatch(actions.loadGeneratedSourceText(bSourceActor)); + + await dispatch( + actions.selectLocation( + createLocation({ + source: aSource, + line: 1, + column: 1, + }) + ) + ); + + await dispatch(actions.addBreakpoint(loc1)); + await dispatch(actions.addBreakpoint(loc2)); + + const bp = selectors.getBreakpoint(getState(), loc1); + if (!bp) { + throw new Error("no bp"); + } + await dispatch(actions.removeBreakpoint(bp)); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("should disable a breakpoint", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1], 6: [2] })); + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + aSource.url = "http://localhost:8000/examples/a"; + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch(actions.loadGeneratedSourceText(aSourceActor)); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + bSource.url = "http://localhost:8000/examples/b"; + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + await dispatch(actions.loadGeneratedSourceText(bSourceActor)); + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + }); + await dispatch(actions.addBreakpoint(loc1)); + await dispatch(actions.addBreakpoint(loc2)); + + const breakpoint = selectors.getBreakpoint(getState(), loc1); + if (!breakpoint) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(breakpoint)); + + const bp = selectors.getBreakpoint(getState(), loc1); + expect(bp && bp.disabled).toBe(true); + }); + + it("should enable breakpoint", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1], 6: [2] })); + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + aSource.url = "http://localhost:8000/examples/a"; + const loc = createLocation({ + source: aSource, + line: 5, + column: 1, + }); + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch(actions.loadGeneratedSourceText(aSourceActor)); + + await dispatch(actions.addBreakpoint(loc)); + let bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(bp)); + + bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + expect(bp && bp.disabled).toBe(true); + + await dispatch(actions.enableBreakpoint(bp)); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && !bp.disabled).toBe(true); + }); + + it("should toggle all the breakpoints", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1], 6: [2] })); + + const aSource = await dispatch(actions.newGeneratedSource(makeSource("a"))); + aSource.url = "http://localhost:8000/examples/a"; + const aSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + aSource.id + ); + await dispatch(actions.loadGeneratedSourceText(aSourceActor)); + + const bSource = await dispatch(actions.newGeneratedSource(makeSource("b"))); + bSource.url = "http://localhost:8000/examples/b"; + const bSourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + bSource.id + ); + await dispatch(actions.loadGeneratedSourceText(bSourceActor)); + + const loc1 = createLocation({ + source: aSource, + line: 5, + column: 1, + }); + + const loc2 = createLocation({ + source: bSource, + line: 6, + column: 2, + }); + + await dispatch(actions.addBreakpoint(loc1)); + await dispatch(actions.addBreakpoint(loc2)); + + await dispatch(actions.toggleAllBreakpoints(true)); + + let bp1 = selectors.getBreakpoint(getState(), loc1); + let bp2 = selectors.getBreakpoint(getState(), loc2); + + expect(bp1 && bp1.disabled).toBe(true); + expect(bp2 && bp2.disabled).toBe(true); + + await dispatch(actions.toggleAllBreakpoints(false)); + + bp1 = selectors.getBreakpoint(getState(), loc1); + bp2 = selectors.getBreakpoint(getState(), loc2); + expect(bp1 && bp1.disabled).toBe(false); + expect(bp2 && bp2.disabled).toBe(false); + }); + + it("should toggle a breakpoint at a location", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const loc = createLocation({ source, line: 5, column: 1 }); + const getBp = () => selectors.getBreakpoint(getState(), loc); + await dispatch(actions.selectLocation(loc)); + + await dispatch(actions.toggleBreakpointAtLine(5)); + const bp = getBp(); + expect(bp && !bp.disabled).toBe(true); + + await dispatch(actions.toggleBreakpointAtLine(5)); + expect(getBp()).toBe(undefined); + }); + + it("should disable/enable a breakpoint at a location", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const location = createLocation({ source, line: 5, column: 1 }); + const getBp = () => selectors.getBreakpoint(getState(), location); + await dispatch(actions.selectLocation(createLocation({ source, line: 1 }))); + + await dispatch(actions.toggleBreakpointAtLine(5)); + let bp = getBp(); + expect(bp && !bp.disabled).toBe(true); + bp = getBp(); + if (!bp) { + throw new Error("no bp"); + } + await dispatch(actions.toggleDisabledBreakpoint(bp)); + bp = getBp(); + expect(bp && bp.disabled).toBe(true); + }); + + it("should set the breakpoint condition", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + source.url = "http://localhost:8000/examples/a"; + const loc = createLocation({ + source, + line: 5, + column: 1, + }); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + await dispatch(actions.addBreakpoint(loc)); + + let bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe(undefined); + + await dispatch( + actions.setBreakpointOptions(loc, { + condition: "const foo = 0", + getTextForLine: () => {}, + }) + ); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe("const foo = 0"); + }); + + it("should set the condition and enable a breakpoint", async () => { + const { dispatch, getState } = createStore(mockClient({ 5: [1] })); + + const source = await dispatch(actions.newGeneratedSource(makeSource("a"))); + source.url = "http://localhost:8000/examples/a"; + const loc = createLocation({ + source, + line: 5, + column: 1, + }); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + await dispatch(actions.addBreakpoint(loc)); + let bp = selectors.getBreakpoint(getState(), loc); + if (!bp) { + throw new Error("no breakpoint"); + } + + await dispatch(actions.disableBreakpoint(bp)); + + bp = selectors.getBreakpoint(getState(), loc); + expect(bp && bp.options.condition).toBe(undefined); + + await dispatch( + actions.setBreakpointOptions(loc, { + condition: "const foo = 0", + getTextForLine: () => {}, + }) + ); + const newBreakpoint = selectors.getBreakpoint(getState(), loc); + expect(newBreakpoint && !newBreakpoint.disabled).toBe(true); + expect(newBreakpoint && newBreakpoint.options.condition).toBe( + "const foo = 0" + ); + }); + + it("should remove the pretty-printed breakpoint that was added", async () => { + const { dispatch, getState } = createStore(mockClient({ 1: [0] })); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("a.js")) + ); + source.url = "http://localhost:8000/examples/a"; + const loc = createLocation({ + source, + line: 1, + column: 0, + }); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + await dispatch(actions.addBreakpoint(loc)); + await dispatch(actions.prettyPrintAndSelectSource("a.js")); + + const breakpoint = selectors.getBreakpointsList(getState())[0]; + + await dispatch(actions.removeBreakpoint(breakpoint)); + + const breakpointList = selectors.getPendingBreakpointList(getState()); + expect(breakpointList.length).toBe(0); + }); +}); diff --git a/devtools/client/debugger/src/actions/context-menus/breakpoint-heading.js b/devtools/client/debugger/src/actions/context-menus/breakpoint-heading.js new file mode 100644 index 0000000000..bded531cfe --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/breakpoint-heading.js @@ -0,0 +1,78 @@ +/* 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/>. */ + +import { buildMenu, showMenu } from "../../context-menu/menu"; + +import { getBreakpointsForSource } from "../../selectors/index"; + +import { + disableBreakpointsInSource, + enableBreakpointsInSource, + removeBreakpointsInSource, +} from "../../actions/breakpoints/index"; + +export function showBreakpointHeadingContextMenu(event, source) { + return async ({ dispatch, getState }) => { + const state = getState(); + const breakpointsForSource = getBreakpointsForSource(state, source); + + const enableInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.enableInSource.label" + ); + const disableInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.disableInSource.label" + ); + const removeInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.removeInSource.label" + ); + const enableInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.enableInSource.accesskey" + ); + const disableInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.disableInSource.accesskey" + ); + const removeInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.removeInSource.accesskey" + ); + + const disableInSourceItem = { + id: "node-menu-disable-in-source", + label: disableInSourceLabel, + accesskey: disableInSourceKey, + disabled: false, + click: () => dispatch(disableBreakpointsInSource(source)), + }; + + const enableInSourceItem = { + id: "node-menu-enable-in-source", + label: enableInSourceLabel, + accesskey: enableInSourceKey, + disabled: false, + click: () => dispatch(enableBreakpointsInSource(source)), + }; + + const removeInSourceItem = { + id: "node-menu-enable-in-source", + label: removeInSourceLabel, + accesskey: removeInSourceKey, + disabled: false, + click: () => dispatch(removeBreakpointsInSource(source)), + }; + + const hideDisableInSourceItem = breakpointsForSource.every( + breakpoint => breakpoint.disabled + ); + const hideEnableInSourceItem = breakpointsForSource.every( + breakpoint => !breakpoint.disabled + ); + + const items = [ + { item: disableInSourceItem, hidden: () => hideDisableInSourceItem }, + { item: enableInSourceItem, hidden: () => hideEnableInSourceItem }, + { item: removeInSourceItem, hidden: () => false }, + ]; + + showMenu(event, buildMenu(items)); + }; +} diff --git a/devtools/client/debugger/src/actions/context-menus/breakpoint.js b/devtools/client/debugger/src/actions/context-menus/breakpoint.js new file mode 100644 index 0000000000..d70254130c --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/breakpoint.js @@ -0,0 +1,396 @@ +/* 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/>. */ + +import { buildMenu, showMenu } from "../../context-menu/menu"; +import { getSelectedLocation } from "../../utils/selected-location"; +import { isLineBlackboxed } from "../../utils/source"; +import { features } from "../../utils/prefs"; +import { formatKeyShortcut } from "../../utils/text"; + +import { + getBreakpointsList, + getSelectedSource, + getBlackBoxRanges, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors/index"; + +import { + removeBreakpoint, + setBreakpointOptions, +} from "../../actions/breakpoints/modify"; +import { + removeBreakpoints, + removeAllBreakpoints, + toggleBreakpoints, + toggleAllBreakpoints, + toggleDisabledBreakpoint, +} from "../../actions/breakpoints/index"; +import { selectSpecificLocation } from "../../actions/sources/select"; +import { openConditionalPanel } from "../../actions/ui"; + +export function showBreakpointContextMenu(event, breakpoint, source) { + return async ({ dispatch, getState }) => { + const state = getState(); + const breakpoints = getBreakpointsList(state); + const blackboxedRanges = getBlackBoxRanges(state); + const blackboxedRangesForSource = blackboxedRanges[source.url]; + const checkSourceOnIgnoreList = _source => + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, _source); + const selectedSource = getSelectedSource(state); + + const deleteSelfLabel = L10N.getStr("breakpointMenuItem.deleteSelf2.label"); + const deleteAllLabel = L10N.getStr("breakpointMenuItem.deleteAll2.label"); + const deleteOthersLabel = L10N.getStr( + "breakpointMenuItem.deleteOthers2.label" + ); + const enableSelfLabel = L10N.getStr("breakpointMenuItem.enableSelf2.label"); + const enableAllLabel = L10N.getStr("breakpointMenuItem.enableAll2.label"); + const enableOthersLabel = L10N.getStr( + "breakpointMenuItem.enableOthers2.label" + ); + const disableSelfLabel = L10N.getStr( + "breakpointMenuItem.disableSelf2.label" + ); + const disableAllLabel = L10N.getStr("breakpointMenuItem.disableAll2.label"); + const disableOthersLabel = L10N.getStr( + "breakpointMenuItem.disableOthers2.label" + ); + const enableDbgStatementLabel = L10N.getStr( + "breakpointMenuItem.enabledbg.label" + ); + const disableDbgStatementLabel = L10N.getStr( + "breakpointMenuItem.disabledbg.label" + ); + const removeConditionLabel = L10N.getStr( + "breakpointMenuItem.removeCondition2.label" + ); + const addConditionLabel = L10N.getStr( + "breakpointMenuItem.addCondition2.label" + ); + const editConditionLabel = L10N.getStr( + "breakpointMenuItem.editCondition2.label" + ); + + const deleteSelfKey = L10N.getStr( + "breakpointMenuItem.deleteSelf2.accesskey" + ); + const deleteAllKey = L10N.getStr("breakpointMenuItem.deleteAll2.accesskey"); + const deleteOthersKey = L10N.getStr( + "breakpointMenuItem.deleteOthers2.accesskey" + ); + const enableSelfKey = L10N.getStr( + "breakpointMenuItem.enableSelf2.accesskey" + ); + const enableAllKey = L10N.getStr("breakpointMenuItem.enableAll2.accesskey"); + const enableOthersKey = L10N.getStr( + "breakpointMenuItem.enableOthers2.accesskey" + ); + const disableSelfKey = L10N.getStr( + "breakpointMenuItem.disableSelf2.accesskey" + ); + const disableAllKey = L10N.getStr( + "breakpointMenuItem.disableAll2.accesskey" + ); + const disableOthersKey = L10N.getStr( + "breakpointMenuItem.disableOthers2.accesskey" + ); + const removeConditionKey = L10N.getStr( + "breakpointMenuItem.removeCondition2.accesskey" + ); + const editConditionKey = L10N.getStr( + "breakpointMenuItem.editCondition2.accesskey" + ); + const addConditionKey = L10N.getStr( + "breakpointMenuItem.addCondition2.accesskey" + ); + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const otherBreakpoints = breakpoints.filter(b => b.id !== breakpoint.id); + const enabledBreakpoints = breakpoints.filter(b => !b.disabled); + const disabledBreakpoints = breakpoints.filter(b => b.disabled); + const otherEnabledBreakpoints = breakpoints.filter( + b => !b.disabled && b.id !== breakpoint.id + ); + const otherDisabledBreakpoints = breakpoints.filter( + b => b.disabled && b.id !== breakpoint.id + ); + + const deleteSelfItem = { + id: "node-menu-delete-self", + label: deleteSelfLabel, + accesskey: deleteSelfKey, + disabled: false, + click: () => { + dispatch(removeBreakpoint(breakpoint)); + }, + }; + + const deleteAllItem = { + id: "node-menu-delete-all", + label: deleteAllLabel, + accesskey: deleteAllKey, + disabled: false, + click: () => dispatch(removeAllBreakpoints()), + }; + + const deleteOthersItem = { + id: "node-menu-delete-other", + label: deleteOthersLabel, + accesskey: deleteOthersKey, + disabled: false, + click: () => dispatch(removeBreakpoints(otherBreakpoints)), + }; + + const enableSelfItem = { + id: "node-menu-enable-self", + label: enableSelfLabel, + accesskey: enableSelfKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => { + dispatch(toggleDisabledBreakpoint(breakpoint)); + }, + }; + + const enableAllItem = { + id: "node-menu-enable-all", + label: enableAllLabel, + accesskey: enableAllKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => dispatch(toggleAllBreakpoints(false)), + }; + + const enableOthersItem = { + id: "node-menu-enable-others", + label: enableOthersLabel, + accesskey: enableOthersKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => dispatch(toggleBreakpoints(false, otherDisabledBreakpoints)), + }; + + const disableSelfItem = { + id: "node-menu-disable-self", + label: disableSelfLabel, + accesskey: disableSelfKey, + disabled: false, + click: () => { + dispatch(toggleDisabledBreakpoint(breakpoint)); + }, + }; + + const disableAllItem = { + id: "node-menu-disable-all", + label: disableAllLabel, + accesskey: disableAllKey, + disabled: false, + click: () => dispatch(toggleAllBreakpoints(true)), + }; + + const disableOthersItem = { + id: "node-menu-disable-others", + label: disableOthersLabel, + accesskey: disableOthersKey, + click: () => dispatch(toggleBreakpoints(true, otherEnabledBreakpoints)), + }; + + const enableDbgStatementItem = { + id: "node-menu-enable-dbgStatement", + label: enableDbgStatementLabel, + disabled: false, + click: () => + dispatch( + setBreakpointOptions(selectedLocation, { + ...breakpoint.options, + condition: null, + }) + ), + }; + + const disableDbgStatementItem = { + id: "node-menu-disable-dbgStatement", + label: disableDbgStatementLabel, + disabled: false, + click: () => + dispatch( + setBreakpointOptions(selectedLocation, { + ...breakpoint.options, + condition: "false", + }) + ), + }; + + const removeConditionItem = { + id: "node-menu-remove-condition", + label: removeConditionLabel, + accesskey: removeConditionKey, + disabled: false, + click: () => + dispatch( + setBreakpointOptions(selectedLocation, { + ...breakpoint.options, + condition: null, + }) + ), + }; + + const addConditionItem = { + id: "node-menu-add-condition", + label: addConditionLabel, + accesskey: addConditionKey, + click: async () => { + await dispatch(selectSpecificLocation(selectedLocation)); + await dispatch(openConditionalPanel(selectedLocation)); + }, + accelerator: formatKeyShortcut( + L10N.getStr("toggleCondPanel.breakpoint.key") + ), + }; + + const editConditionItem = { + id: "node-menu-edit-condition", + label: editConditionLabel, + accesskey: editConditionKey, + click: async () => { + await dispatch(selectSpecificLocation(selectedLocation)); + await dispatch(openConditionalPanel(selectedLocation)); + }, + accelerator: formatKeyShortcut( + L10N.getStr("toggleCondPanel.breakpoint.key") + ), + }; + + const addLogPointItem = { + id: "node-menu-add-log-point", + label: L10N.getStr("editor.addLogPoint"), + accesskey: L10N.getStr("editor.addLogPoint.accesskey"), + disabled: false, + click: async () => { + await dispatch(selectSpecificLocation(selectedLocation)); + await dispatch(openConditionalPanel(selectedLocation, true)); + }, + accelerator: formatKeyShortcut( + L10N.getStr("toggleCondPanel.logPoint.key") + ), + }; + + const editLogPointItem = { + id: "node-menu-edit-log-point", + label: L10N.getStr("editor.editLogPoint"), + accesskey: L10N.getStr("editor.editLogPoint.accesskey"), + disabled: false, + click: async () => { + await dispatch(selectSpecificLocation(selectedLocation)); + await dispatch(openConditionalPanel(selectedLocation, true)); + }, + accelerator: formatKeyShortcut( + L10N.getStr("toggleCondPanel.logPoint.key") + ), + }; + + const removeLogPointItem = { + id: "node-menu-remove-log", + label: L10N.getStr("editor.removeLogPoint.label"), + accesskey: L10N.getStr("editor.removeLogPoint.accesskey"), + disabled: false, + click: () => + dispatch( + setBreakpointOptions(selectedLocation, { + ...breakpoint.options, + logValue: null, + }) + ), + }; + + const logPointItem = breakpoint.options.logValue + ? editLogPointItem + : addLogPointItem; + + const hideEnableSelfItem = !breakpoint.disabled; + const hideEnableAllItem = disabledBreakpoints.length === 0; + const hideEnableOthersItem = otherDisabledBreakpoints.length === 0; + const hideDisableAllItem = enabledBreakpoints.length === 0; + const hideDisableOthersItem = otherEnabledBreakpoints.length === 0; + const hideDisableSelfItem = breakpoint.disabled; + const hideEnableDbgStatementItem = + !breakpoint.originalText.startsWith("debugger") || + (breakpoint.originalText.startsWith("debugger") && + breakpoint.options.condition !== "false"); + const hideDisableDbgStatementItem = + !breakpoint.originalText.startsWith("debugger") || + (breakpoint.originalText.startsWith("debugger") && + breakpoint.options.condition === "false"); + const items = [ + { item: enableSelfItem, hidden: () => hideEnableSelfItem }, + { item: enableAllItem, hidden: () => hideEnableAllItem }, + { item: enableOthersItem, hidden: () => hideEnableOthersItem }, + { + item: { type: "separator" }, + hidden: () => + hideEnableSelfItem && hideEnableAllItem && hideEnableOthersItem, + }, + { item: deleteSelfItem }, + { item: deleteAllItem }, + { item: deleteOthersItem, hidden: () => breakpoints.length === 1 }, + { + item: { type: "separator" }, + hidden: () => + hideDisableSelfItem && hideDisableAllItem && hideDisableOthersItem, + }, + + { item: disableSelfItem, hidden: () => hideDisableSelfItem }, + { item: disableAllItem, hidden: () => hideDisableAllItem }, + { item: disableOthersItem, hidden: () => hideDisableOthersItem }, + { + item: { type: "separator" }, + }, + { + item: enableDbgStatementItem, + hidden: () => hideEnableDbgStatementItem, + }, + { + item: disableDbgStatementItem, + hidden: () => hideDisableDbgStatementItem, + }, + { + item: { type: "separator" }, + hidden: () => hideDisableDbgStatementItem && hideEnableDbgStatementItem, + }, + { + item: addConditionItem, + hidden: () => breakpoint.options.condition, + }, + { + item: editConditionItem, + hidden: () => !breakpoint.options.condition, + }, + { + item: removeConditionItem, + hidden: () => !breakpoint.options.condition, + }, + { + item: logPointItem, + hidden: () => !features.logPoints, + }, + { + item: removeLogPointItem, + hidden: () => !features.logPoints || !breakpoint.options.logValue, + }, + ]; + + showMenu(event, buildMenu(items)); + }; +} diff --git a/devtools/client/debugger/src/actions/context-menus/editor-breakpoint.js b/devtools/client/debugger/src/actions/context-menus/editor-breakpoint.js new file mode 100644 index 0000000000..39ec2f1589 --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/editor-breakpoint.js @@ -0,0 +1,273 @@ +/* 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/>. */ + +import { showMenu } from "../../context-menu/menu"; +import { getSelectedLocation } from "../../utils/selected-location"; +import { features } from "../../utils/prefs"; +import { formatKeyShortcut } from "../../utils/text"; +import { isLineBlackboxed } from "../../utils/source"; + +import { + getSelectedSource, + getBlackBoxRanges, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors/index"; +import { + addBreakpoint, + removeBreakpoint, + setBreakpointOptions, +} from "../../actions/breakpoints/modify"; +import { + enableBreakpointsAtLine, + disableBreakpointsAtLine, + toggleDisabledBreakpoint, + removeBreakpointsAtLine, +} from "../../actions/breakpoints/index"; +import { openConditionalPanel } from "../../actions/ui"; + +export function showEditorEditBreakpointContextMenu(event, breakpoint) { + return async ({ dispatch, getState }) => { + const state = getState(); + const selectedSource = getSelectedSource(state); + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const blackboxedRanges = getBlackBoxRanges(state); + const blackboxedRangesForSelectedSource = + blackboxedRanges[selectedSource.url]; + const isSelectedSourceOnIgnoreList = + selectedSource && + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, selectedSource); + + const items = [ + removeBreakpointItem(breakpoint, dispatch), + toggleDisabledBreakpointItem( + breakpoint, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList, + dispatch + ), + ]; + + if (breakpoint.originalText.startsWith("debugger")) { + items.push( + { type: "separator" }, + toggleDbgStatementItem(selectedLocation, breakpoint, dispatch) + ); + } + + items.push( + { type: "separator" }, + removeBreakpointsOnLineItem(selectedLocation, dispatch), + breakpoint.disabled + ? enableBreakpointsOnLineItem( + selectedLocation, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList, + dispatch + ) + : disableBreakpointsOnLineItem(selectedLocation, dispatch), + { type: "separator" } + ); + + items.push( + conditionalBreakpointItem(breakpoint, selectedLocation, dispatch) + ); + items.push(logPointItem(breakpoint, selectedLocation, dispatch)); + + showMenu(event, items); + }; +} + +export function showEditorCreateBreakpointContextMenu( + event, + location, + lineText +) { + return async ({ dispatch, getState }) => { + const items = createBreakpointItems(location, lineText, dispatch); + + showMenu(event, items); + }; +} + +export function createBreakpointItems(location, lineText, dispatch) { + const items = [ + addBreakpointItem(location, dispatch), + addConditionalBreakpointItem(location, dispatch), + ]; + + if (features.logPoints) { + items.push(addLogPointItem(location, dispatch)); + } + + if (lineText && lineText.startsWith("debugger")) { + items.push(toggleDbgStatementItem(location, null, dispatch)); + } + return items; +} + +const addBreakpointItem = (location, dispatch) => ({ + id: "node-menu-add-breakpoint", + label: L10N.getStr("editor.addBreakpoint"), + accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"), + disabled: false, + click: () => dispatch(addBreakpoint(location)), + accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")), +}); + +const removeBreakpointItem = (breakpoint, dispatch) => ({ + id: "node-menu-remove-breakpoint", + label: L10N.getStr("editor.removeBreakpoint"), + accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"), + disabled: false, + click: () => dispatch(removeBreakpoint(breakpoint)), + accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")), +}); + +const addConditionalBreakpointItem = (location, dispatch) => ({ + id: "node-menu-add-conditional-breakpoint", + label: L10N.getStr("editor.addConditionBreakpoint"), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")), + accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"), + disabled: false, + click: () => dispatch(openConditionalPanel(location)), +}); + +const editConditionalBreakpointItem = (location, dispatch) => ({ + id: "node-menu-edit-conditional-breakpoint", + label: L10N.getStr("editor.editConditionBreakpoint"), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")), + accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"), + disabled: false, + click: () => dispatch(openConditionalPanel(location)), +}); + +const conditionalBreakpointItem = (breakpoint, location, dispatch) => { + const { + options: { condition }, + } = breakpoint; + return condition + ? editConditionalBreakpointItem(location, dispatch) + : addConditionalBreakpointItem(location, dispatch); +}; + +const addLogPointItem = (location, dispatch) => ({ + id: "node-menu-add-log-point", + label: L10N.getStr("editor.addLogPoint"), + accesskey: L10N.getStr("editor.addLogPoint.accesskey"), + disabled: false, + click: () => dispatch(openConditionalPanel(location, true)), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), +}); + +const editLogPointItem = (location, dispatch) => ({ + id: "node-menu-edit-log-point", + label: L10N.getStr("editor.editLogPoint"), + accesskey: L10N.getStr("editor.editLogPoint.accesskey"), + disabled: false, + click: () => dispatch(openConditionalPanel(location, true)), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), +}); + +const logPointItem = (breakpoint, location, dispatch) => { + const { + options: { logValue }, + } = breakpoint; + return logValue + ? editLogPointItem(location, dispatch) + : addLogPointItem(location, dispatch); +}; + +const toggleDisabledBreakpointItem = ( + breakpoint, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList, + dispatch +) => { + return { + accesskey: L10N.getStr("editor.disableBreakpoint.accesskey"), + disabled: isLineBlackboxed( + blackboxedRangesForSelectedSource, + breakpoint.location.line, + isSelectedSourceOnIgnoreList + ), + click: () => dispatch(toggleDisabledBreakpoint(breakpoint)), + ...(breakpoint.disabled + ? { + id: "node-menu-enable-breakpoint", + label: L10N.getStr("editor.enableBreakpoint"), + } + : { + id: "node-menu-disable-breakpoint", + label: L10N.getStr("editor.disableBreakpoint"), + }), + }; +}; + +const toggleDbgStatementItem = (location, breakpoint, dispatch) => { + if (breakpoint && breakpoint.options.condition === "false") { + return { + disabled: false, + id: "node-menu-enable-dbgStatement", + label: L10N.getStr("breakpointMenuItem.enabledbg.label"), + click: () => + dispatch( + setBreakpointOptions(location, { + ...breakpoint.options, + condition: null, + }) + ), + }; + } + + return { + disabled: false, + id: "node-menu-disable-dbgStatement", + label: L10N.getStr("breakpointMenuItem.disabledbg.label"), + click: () => + dispatch( + setBreakpointOptions(location, { + condition: "false", + }) + ), + }; +}; + +// ToDo: Only enable if there are more than one breakpoints on a line? +const removeBreakpointsOnLineItem = (location, dispatch) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.removeAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.removeAllAtLine.accesskey"), + disabled: false, + click: () => + dispatch(removeBreakpointsAtLine(location.source, location.line)), +}); + +const enableBreakpointsOnLineItem = ( + location, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList, + dispatch +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.enableAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.enableAllAtLine.accesskey"), + disabled: isLineBlackboxed( + blackboxedRangesForSelectedSource, + location.line, + isSelectedSourceOnIgnoreList + ), + click: () => + dispatch(enableBreakpointsAtLine(location.source, location.line)), +}); + +const disableBreakpointsOnLineItem = (location, dispatch) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.disableAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.disableAllAtLine.accesskey"), + disabled: false, + click: () => + dispatch(disableBreakpointsAtLine(location.source, location.line)), +}); diff --git a/devtools/client/debugger/src/actions/context-menus/editor.js b/devtools/client/debugger/src/actions/context-menus/editor.js new file mode 100644 index 0000000000..1125790a9b --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/editor.js @@ -0,0 +1,436 @@ +/* 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/>. */ + +import { showMenu } from "../../context-menu/menu"; + +import { copyToTheClipboard } from "../../utils/clipboard"; +import { + isPretty, + getRawSourceURL, + getFilename, + shouldBlackbox, + findBlackBoxRange, +} from "../../utils/source"; +import { toSourceLine } from "../../utils/editor/index"; +import { downloadFile } from "../../utils/utils"; +import { features } from "../../utils/prefs"; +import { isFulfilled } from "../../utils/async-value"; + +import { createBreakpointItems } from "./editor-breakpoint"; + +import { + getPrettySource, + getIsCurrentThreadPaused, + isSourceWithMap, + getBlackBoxRanges, + isSourceOnSourceMapIgnoreList, + isSourceMapIgnoreListEnabled, + getEditorWrapping, +} from "../../selectors/index"; + +import { continueToHere } from "../../actions/pause/continueToHere"; +import { jumpToMappedLocation } from "../../actions/sources/select"; +import { + showSource, + toggleInlinePreview, + toggleEditorWrapping, +} from "../../actions/ui"; +import { toggleBlackBox } from "../../actions/sources/blackbox"; +import { addExpression } from "../../actions/expressions"; +import { evaluateInConsole } from "../../actions/toolbox"; + +export function showEditorContextMenu(event, editor, location) { + return async ({ dispatch, getState }) => { + const { source } = location; + const state = getState(); + const blackboxedRanges = getBlackBoxRanges(state); + const isPaused = getIsCurrentThreadPaused(state); + const hasMappedLocation = + (source.isOriginal || + isSourceWithMap(state, source.id) || + isPretty(source)) && + !getPrettySource(state, source.id); + const isSourceOnIgnoreList = + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source); + const editorWrappingEnabled = getEditorWrapping(state); + + showMenu( + event, + editorMenuItems({ + blackboxedRanges, + hasMappedLocation, + location, + isPaused, + editorWrappingEnabled, + selectionText: editor.codeMirror.getSelection().trim(), + isTextSelected: editor.codeMirror.somethingSelected(), + editor, + isSourceOnIgnoreList, + dispatch, + }) + ); + }; +} + +export function showEditorGutterContextMenu(event, editor, location, lineText) { + return async ({ dispatch, getState }) => { + const { source } = location; + const state = getState(); + const blackboxedRanges = getBlackBoxRanges(state); + const isPaused = getIsCurrentThreadPaused(state); + const isSourceOnIgnoreList = + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source); + + showMenu(event, [ + ...createBreakpointItems(location, lineText, dispatch), + { type: "separator" }, + continueToHereItem(location, isPaused, dispatch), + { type: "separator" }, + blackBoxLineMenuItem( + source, + editor, + blackboxedRanges, + isSourceOnIgnoreList, + location.line, + dispatch + ), + ]); + }; +} + +// Menu Items +const continueToHereItem = (location, isPaused, dispatch) => ({ + accesskey: L10N.getStr("editor.continueToHere.accesskey"), + disabled: !isPaused, + click: () => dispatch(continueToHere(location)), + id: "node-menu-continue-to-here", + label: L10N.getStr("editor.continueToHere.label"), +}); + +const copyToClipboardItem = selectionText => ({ + id: "node-menu-copy-to-clipboard", + label: L10N.getStr("copyToClipboard.label"), + accesskey: L10N.getStr("copyToClipboard.accesskey"), + disabled: selectionText.length === 0, + click: () => copyToTheClipboard(selectionText), +}); + +const copySourceItem = selectedContent => ({ + id: "node-menu-copy-source", + label: L10N.getStr("copySource.label"), + accesskey: L10N.getStr("copySource.accesskey"), + disabled: false, + click: () => + selectedContent.type === "text" && + copyToTheClipboard(selectedContent.value), +}); + +const copySourceUri2Item = selectedSource => ({ + id: "node-menu-copy-source-url", + label: L10N.getStr("copySourceUri2"), + accesskey: L10N.getStr("copySourceUri2.accesskey"), + disabled: !selectedSource.url, + click: () => copyToTheClipboard(getRawSourceURL(selectedSource.url)), +}); + +const jumpToMappedLocationItem = (location, hasMappedLocation, dispatch) => ({ + id: "node-menu-jump", + label: L10N.getFormatStr( + "editor.jumpToMappedLocation1", + location.source.isOriginal + ? L10N.getStr("generated") + : L10N.getStr("original") + ), + accesskey: L10N.getStr("editor.jumpToMappedLocation1.accesskey"), + disabled: !hasMappedLocation, + click: () => dispatch(jumpToMappedLocation(location)), +}); + +const showSourceMenuItem = (selectedSource, dispatch) => ({ + id: "node-menu-show-source", + label: L10N.getStr("sourceTabs.revealInTree"), + accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"), + disabled: !selectedSource.url, + click: () => dispatch(showSource(selectedSource.id)), +}); + +const blackBoxMenuItem = ( + selectedSource, + blackboxedRanges, + isSourceOnIgnoreList, + dispatch +) => { + const isBlackBoxed = !!blackboxedRanges[selectedSource.url]; + return { + id: "node-menu-blackbox", + label: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore") + : L10N.getStr("ignoreContextItem.ignore"), + accesskey: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore.accesskey") + : L10N.getStr("ignoreContextItem.ignore.accesskey"), + disabled: isSourceOnIgnoreList || !shouldBlackbox(selectedSource), + click: () => dispatch(toggleBlackBox(selectedSource)), + }; +}; + +const blackBoxLineMenuItem = ( + selectedSource, + editor, + blackboxedRanges, + isSourceOnIgnoreList, + // the clickedLine is passed when the context menu + // is opened from the gutter, it is not available when the + // the context menu is opened from the editor. + clickedLine = null, + dispatch +) => { + const { codeMirror } = editor; + const from = codeMirror.getCursor("from"); + const to = codeMirror.getCursor("to"); + + const startLine = clickedLine ?? toSourceLine(selectedSource.id, from.line); + const endLine = clickedLine ?? toSourceLine(selectedSource.id, to.line); + + const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const selectedLineIsBlackBoxed = !!blackboxRange; + + const isSingleLine = selectedLineIsBlackBoxed + ? blackboxRange.start.line == blackboxRange.end.line + : startLine == endLine; + + const isSourceFullyBlackboxed = + blackboxedRanges[selectedSource.url] && + !blackboxedRanges[selectedSource.url].length; + + // The ignore/unignore line context menu item should be disabled when + // 1) The source is on the sourcemap ignore list + // 2) The whole source is blackboxed or + // 3) Multiple lines are blackboxed or + // 4) Multiple lines are selected in the editor + const shouldDisable = + isSourceOnIgnoreList || isSourceFullyBlackboxed || !isSingleLine; + + return { + id: "node-menu-blackbox-line", + label: !selectedLineIsBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLine") + : L10N.getStr("ignoreContextItem.unignoreLine"), + accesskey: !selectedLineIsBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLine.accesskey") + : L10N.getStr("ignoreContextItem.unignoreLine.accesskey"), + disabled: shouldDisable, + click: () => { + const selectionRange = { + start: { + line: startLine, + column: clickedLine == null ? from.ch : 0, + }, + end: { + line: endLine, + column: clickedLine == null ? to.ch : 0, + }, + }; + + dispatch( + toggleBlackBox( + selectedSource, + !selectedLineIsBlackBoxed, + selectedLineIsBlackBoxed ? [blackboxRange] : [selectionRange] + ) + ); + }, + }; +}; + +const blackBoxLinesMenuItem = ( + selectedSource, + editor, + blackboxedRanges, + isSourceOnIgnoreList, + clickedLine = null, + dispatch +) => { + const { codeMirror } = editor; + const from = codeMirror.getCursor("from"); + const to = codeMirror.getCursor("to"); + + const startLine = toSourceLine(selectedSource.id, from.line); + const endLine = toSourceLine(selectedSource.id, to.line); + + const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const selectedLinesAreBlackBoxed = !!blackboxRange; + + return { + id: "node-menu-blackbox-lines", + label: !selectedLinesAreBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLines") + : L10N.getStr("ignoreContextItem.unignoreLines"), + accesskey: !selectedLinesAreBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLines.accesskey") + : L10N.getStr("ignoreContextItem.unignoreLines.accesskey"), + disabled: isSourceOnIgnoreList, + click: () => { + const selectionRange = { + start: { + line: startLine, + column: from.ch, + }, + end: { + line: endLine, + column: to.ch, + }, + }; + + dispatch( + toggleBlackBox( + selectedSource, + !selectedLinesAreBlackBoxed, + selectedLinesAreBlackBoxed ? [blackboxRange] : [selectionRange] + ) + ); + }, + }; +}; + +const watchExpressionItem = (selectedSource, selectionText, dispatch) => ({ + id: "node-menu-add-watch-expression", + label: L10N.getStr("expressions.label"), + accesskey: L10N.getStr("expressions.accesskey"), + click: () => dispatch(addExpression(selectionText)), +}); + +const evaluateInConsoleItem = (selectedSource, selectionText, dispatch) => ({ + id: "node-menu-evaluate-in-console", + label: L10N.getStr("evaluateInConsole.label"), + click: () => dispatch(evaluateInConsole(selectionText)), +}); + +const downloadFileItem = (selectedSource, selectedContent) => ({ + id: "node-menu-download-file", + label: L10N.getStr("downloadFile.label"), + accesskey: L10N.getStr("downloadFile.accesskey"), + click: () => downloadFile(selectedContent, getFilename(selectedSource)), +}); + +const inlinePreviewItem = dispatch => ({ + id: "node-menu-inline-preview", + label: features.inlinePreview + ? L10N.getStr("inlinePreview.hide.label") + : L10N.getStr("inlinePreview.show.label"), + click: () => dispatch(toggleInlinePreview(!features.inlinePreview)), +}); + +const editorWrappingItem = (editorWrappingEnabled, dispatch) => ({ + id: "node-menu-editor-wrapping", + label: editorWrappingEnabled + ? L10N.getStr("editorWrapping.hide.label") + : L10N.getStr("editorWrapping.show.label"), + click: () => dispatch(toggleEditorWrapping(!editorWrappingEnabled)), +}); + +function editorMenuItems({ + blackboxedRanges, + location, + selectionText, + hasMappedLocation, + isTextSelected, + isPaused, + editorWrappingEnabled, + editor, + isSourceOnIgnoreList, + dispatch, +}) { + const items = []; + + const { source } = location; + + const content = + source.content && isFulfilled(source.content) ? source.content.value : null; + + items.push( + jumpToMappedLocationItem(location, hasMappedLocation, dispatch), + continueToHereItem(location, isPaused, dispatch), + { type: "separator" }, + copyToClipboardItem(selectionText), + ...(!source.isWasm + ? [ + ...(content ? [copySourceItem(content)] : []), + copySourceUri2Item(source), + ] + : []), + ...(content ? [downloadFileItem(source, content)] : []), + { type: "separator" }, + showSourceMenuItem(source, dispatch), + { type: "separator" }, + blackBoxMenuItem(source, blackboxedRanges, isSourceOnIgnoreList, dispatch) + ); + + const startLine = toSourceLine( + source.id, + editor.codeMirror.getCursor("from").line + ); + const endLine = toSourceLine( + source.id, + editor.codeMirror.getCursor("to").line + ); + + // Find any blackbox ranges that exist for the selected lines + const blackboxRange = findBlackBoxRange(source, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const isMultiLineSelection = blackboxRange + ? blackboxRange.start.line !== blackboxRange.end.line + : startLine !== endLine; + + // When the range is defined and is an empty array, + // the whole source is blackboxed + const theWholeSourceIsBlackBoxed = + blackboxedRanges[source.url] && !blackboxedRanges[source.url].length; + + if (!theWholeSourceIsBlackBoxed) { + const blackBoxSourceLinesMenuItem = isMultiLineSelection + ? blackBoxLinesMenuItem + : blackBoxLineMenuItem; + + items.push( + blackBoxSourceLinesMenuItem( + source, + editor, + blackboxedRanges, + isSourceOnIgnoreList, + null, + dispatch + ) + ); + } + + if (isTextSelected) { + items.push( + { type: "separator" }, + watchExpressionItem(source, selectionText, dispatch), + evaluateInConsoleItem(source, selectionText, dispatch) + ); + } + + items.push( + { type: "separator" }, + inlinePreviewItem(dispatch), + editorWrappingItem(editorWrappingEnabled, dispatch) + ); + + return items; +} diff --git a/devtools/client/debugger/src/actions/context-menus/frame.js b/devtools/client/debugger/src/actions/context-menus/frame.js new file mode 100644 index 0000000000..1d287b1028 --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/frame.js @@ -0,0 +1,97 @@ +/* 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/>. */ + +import { showMenu } from "../../context-menu/menu"; +import { copyToTheClipboard } from "../../utils/clipboard"; +import { + getShouldSelectOriginalLocation, + getCurrentThreadFrames, + getFrameworkGroupingState, +} from "../../selectors/index"; +import { toggleFrameworkGrouping } from "../../actions/ui"; +import { restart, toggleBlackBox } from "../../actions/pause/index"; +import { formatCopyName } from "../../utils/pause/frames/index"; + +function formatMenuElement(labelString, click, disabled = false) { + const label = L10N.getStr(labelString); + const accesskey = L10N.getStr(`${labelString}.accesskey`); + const id = `node-menu-${labelString}`; + return { + id, + label, + accesskey, + disabled, + click, + }; +} + +function isValidRestartFrame(frame, callbacks) { + // Any frame state than 'on-stack' is either dismissed by the server + // or can potentially cause unexpected errors. + // Global frame has frame.callee equal to null and can't be restarted. + return frame.type === "call" && frame.state === "on-stack"; +} + +function copyStackTrace() { + return async ({ dispatch, getState }) => { + const frames = getCurrentThreadFrames(getState()); + const shouldDisplayOriginalLocation = getShouldSelectOriginalLocation( + getState() + ); + + const framesToCopy = frames + .map(frame => formatCopyName(frame, L10N, shouldDisplayOriginalLocation)) + .join("\n"); + copyToTheClipboard(framesToCopy); + }; +} + +export function showFrameContextMenu(event, frame, hideRestart = false) { + return async ({ dispatch, getState }) => { + const items = []; + + // Hides 'Restart Frame' item for call stack groups context menu, + // otherwise can be misleading for the user which frame gets restarted. + if (!hideRestart && isValidRestartFrame(frame)) { + items.push( + formatMenuElement("restartFrame", () => dispatch(restart(frame))) + ); + } + + const toggleFrameWorkL10nLabel = getFrameworkGroupingState(getState()) + ? "framework.disableGrouping" + : "framework.enableGrouping"; + items.push( + formatMenuElement(toggleFrameWorkL10nLabel, () => + dispatch( + toggleFrameworkGrouping(!getFrameworkGroupingState(getState())) + ) + ) + ); + + const { source } = frame; + if (frame.source) { + items.push( + formatMenuElement("copySourceUri2", () => + copyToTheClipboard(source.url) + ) + ); + + const toggleBlackBoxL10nLabel = source.isBlackBoxed + ? "ignoreContextItem.unignore" + : "ignoreContextItem.ignore"; + items.push( + formatMenuElement(toggleBlackBoxL10nLabel, () => + dispatch(toggleBlackBox(source)) + ) + ); + } + + items.push( + formatMenuElement("copyStackTrace", () => dispatch(copyStackTrace())) + ); + + showMenu(event, items); + }; +} diff --git a/devtools/client/debugger/src/actions/context-menus/index.js b/devtools/client/debugger/src/actions/context-menus/index.js new file mode 100644 index 0000000000..c988d94ccc --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/index.js @@ -0,0 +1,12 @@ +/* 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/>. */ + +export * from "./breakpoint"; +export * from "./breakpoint-heading"; +export * from "./frame"; +export * from "./editor"; +export * from "./editor-breakpoint"; +export * from "./outline"; +export * from "./source-tree-item"; +export * from "./tab"; diff --git a/devtools/client/debugger/src/actions/context-menus/moz.build b/devtools/client/debugger/src/actions/context-menus/moz.build new file mode 100644 index 0000000000..776cb436f9 --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/moz.build @@ -0,0 +1,16 @@ +# 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/. + +CompiledModules( + "breakpoint.js", + "breakpoint-heading.js", + "frame.js", + "editor.js", + "editor-breakpoint.js", + "index.js", + "outline.js", + "source-tree-item.js", + "tab.js", +) diff --git a/devtools/client/debugger/src/actions/context-menus/outline.js b/devtools/client/debugger/src/actions/context-menus/outline.js new file mode 100644 index 0000000000..4ba0fe8f6f --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/outline.js @@ -0,0 +1,54 @@ +/* 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/>. */ + +import { showMenu } from "../../context-menu/menu"; +import { copyToTheClipboard } from "../../utils/clipboard"; +import { findFunctionText } from "../../utils/function"; + +import { flashLineRange } from "../../actions/ui"; + +import { + getSelectedSource, + getSelectedSourceTextContent, +} from "../../selectors/index"; + +export function showOutlineContextMenu(event, func, symbols) { + return async ({ dispatch, getState }) => { + const state = getState(); + + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + return; + } + const selectedSourceTextContent = getSelectedSourceTextContent(state); + + const sourceLine = func.location.start.line; + const functionText = findFunctionText( + sourceLine, + selectedSource, + selectedSourceTextContent, + symbols + ); + + const copyFunctionItem = { + id: "node-menu-copy-function", + label: L10N.getStr("copyFunction.label"), + accesskey: L10N.getStr("copyFunction.accesskey"), + disabled: !functionText, + click: () => { + dispatch( + flashLineRange({ + start: sourceLine, + end: func.location.end.line, + sourceId: selectedSource.id, + }) + ); + return copyToTheClipboard(functionText); + }, + }; + + const items = [copyFunctionItem]; + showMenu(event, items); + }; +} diff --git a/devtools/client/debugger/src/actions/context-menus/source-tree-item.js b/devtools/client/debugger/src/actions/context-menus/source-tree-item.js new file mode 100644 index 0000000000..1b7bc37dc3 --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/source-tree-item.js @@ -0,0 +1,281 @@ +/* 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/>. */ + +import { showMenu } from "../../context-menu/menu"; + +import { + isSourceOverridden, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, + getProjectDirectoryRoot, + getSourcesTreeSources, + getBlackBoxRanges, +} from "../../selectors/index"; + +import { setOverrideSource, removeOverrideSource } from "../sources/index"; +import { loadSourceText } from "../sources/loadSourceText"; +import { toggleBlackBox, blackBoxSources } from "../sources/blackbox"; +import { + setProjectDirectoryRoot, + clearProjectDirectoryRoot, +} from "../sources-tree"; + +import { shouldBlackbox } from "../../utils/source"; +import { copyToTheClipboard } from "../../utils/clipboard"; +import { saveAsLocalFile } from "../../utils/utils"; + +/** + * Show the context menu of SourceTreeItem. + * + * @param {object} event + * The context-menu DOM event. + * @param {object} item + * Source Tree Item object. + */ +export function showSourceTreeItemContextMenu( + event, + item, + depth, + setExpanded, + itemName +) { + return async ({ dispatch, getState }) => { + const copySourceUri2Label = L10N.getStr("copySourceUri2"); + const copySourceUri2Key = L10N.getStr("copySourceUri2.accesskey"); + const setDirectoryRootLabel = L10N.getStr("setDirectoryRoot.label"); + const setDirectoryRootKey = L10N.getStr("setDirectoryRoot.accesskey"); + const removeDirectoryRootLabel = L10N.getStr("removeDirectoryRoot.label"); + + const menuOptions = []; + + const state = getState(); + const isOverridden = isSourceOverridden(state, item.source); + const isSourceOnIgnoreList = + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, item.source); + const projectRoot = getProjectDirectoryRoot(state); + + if (item.type == "source") { + const { source } = item; + const copySourceUri2 = { + id: "node-menu-copy-source", + label: copySourceUri2Label, + accesskey: copySourceUri2Key, + disabled: false, + click: () => copyToTheClipboard(source.url), + }; + + const ignoreStr = item.isBlackBoxed ? "unignore" : "ignore"; + const blackBoxMenuItem = { + id: "node-menu-blackbox", + label: L10N.getStr(`ignoreContextItem.${ignoreStr}`), + accesskey: L10N.getStr(`ignoreContextItem.${ignoreStr}.accesskey`), + disabled: isSourceOnIgnoreList || !shouldBlackbox(source), + click: () => dispatch(toggleBlackBox(source)), + }; + const downloadFileItem = { + id: "node-menu-download-file", + label: L10N.getStr("downloadFile.label"), + accesskey: L10N.getStr("downloadFile.accesskey"), + disabled: false, + click: () => saveLocalFile(dispatch, source), + }; + + const overrideStr = !isOverridden ? "override" : "removeOverride"; + const overridesItem = { + id: "node-menu-overrides", + label: L10N.getStr(`overridesContextItem.${overrideStr}`), + accesskey: L10N.getStr(`overridesContextItem.${overrideStr}.accesskey`), + disabled: !!source.isHTML, + click: () => handleLocalOverride(dispatch, source, isOverridden), + }; + + menuOptions.push( + copySourceUri2, + blackBoxMenuItem, + downloadFileItem, + overridesItem + ); + } + + // All other types other than source are folder-like + if (item.type != "source") { + addCollapseExpandAllOptions(menuOptions, item, setExpanded); + + if (projectRoot == item.uniquePath) { + menuOptions.push({ + id: "node-remove-directory-root", + label: removeDirectoryRootLabel, + disabled: false, + click: () => dispatch(clearProjectDirectoryRoot()), + }); + } else { + menuOptions.push({ + id: "node-set-directory-root", + label: setDirectoryRootLabel, + accesskey: setDirectoryRootKey, + disabled: false, + click: () => + dispatch(setProjectDirectoryRoot(item.uniquePath, itemName)), + }); + } + + addBlackboxAllOption(dispatch, state, menuOptions, item, depth); + } + + showMenu(event, menuOptions); + }; +} + +async function saveLocalFile(dispatch, source) { + if (!source) { + return null; + } + + const data = await dispatch(loadSourceText(source)); + if (!data) { + return null; + } + return saveAsLocalFile(data.value, source.displayURL.filename); +} + +async function handleLocalOverride(dispatch, source, isOverridden) { + if (!isOverridden) { + const localPath = await saveLocalFile(dispatch, source); + if (localPath) { + dispatch(setOverrideSource(source, localPath)); + } + } else { + dispatch(removeOverrideSource(source)); + } +} + +function addBlackboxAllOption(dispatch, state, menuOptions, item, depth) { + const { + sourcesInside, + sourcesOutside, + allInsideBlackBoxed, + allOutsideBlackBoxed, + } = getBlackBoxSourcesGroups(state, item); + const projectRoot = getProjectDirectoryRoot(state); + + let blackBoxInsideMenuItemLabel; + let blackBoxOutsideMenuItemLabel; + if (depth === 0 || (depth === 1 && projectRoot === "")) { + blackBoxInsideMenuItemLabel = allInsideBlackBoxed + ? L10N.getStr("unignoreAllInGroup.label") + : L10N.getStr("ignoreAllInGroup.label"); + if (sourcesOutside.length) { + blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed + ? L10N.getStr("unignoreAllOutsideGroup.label") + : L10N.getStr("ignoreAllOutsideGroup.label"); + } + } else { + blackBoxInsideMenuItemLabel = allInsideBlackBoxed + ? L10N.getStr("unignoreAllInDir.label") + : L10N.getStr("ignoreAllInDir.label"); + if (sourcesOutside.length) { + blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed + ? L10N.getStr("unignoreAllOutsideDir.label") + : L10N.getStr("ignoreAllOutsideDir.label"); + } + } + + const blackBoxInsideMenuItem = { + id: allInsideBlackBoxed + ? "node-unblackbox-all-inside" + : "node-blackbox-all-inside", + label: blackBoxInsideMenuItemLabel, + disabled: false, + click: () => dispatch(blackBoxSources(sourcesInside, !allInsideBlackBoxed)), + }; + + if (sourcesOutside.length) { + menuOptions.push({ + id: "node-blackbox-all", + label: L10N.getStr("ignoreAll.label"), + submenu: [ + blackBoxInsideMenuItem, + { + id: allOutsideBlackBoxed + ? "node-unblackbox-all-outside" + : "node-blackbox-all-outside", + label: blackBoxOutsideMenuItemLabel, + disabled: false, + click: () => + dispatch(blackBoxSources(sourcesOutside, !allOutsideBlackBoxed)), + }, + ], + }); + } else { + menuOptions.push(blackBoxInsideMenuItem); + } +} + +function addCollapseExpandAllOptions(menuOptions, item, setExpanded) { + menuOptions.push({ + id: "node-menu-collapse-all", + label: L10N.getStr("collapseAll.label"), + disabled: false, + click: () => setExpanded(item, false, true), + }); + + menuOptions.push({ + id: "node-menu-expand-all", + label: L10N.getStr("expandAll.label"), + disabled: false, + click: () => setExpanded(item, true, true), + }); +} + +/** + * Computes 4 lists: + * - `sourcesInside`: the list of all Source Items that are + * children of the current item (can be thread/group/directory). + * This include any nested level of children. + * - `sourcesOutside`: all other Source Items. + * i.e. all sources that are in any other folder of any group/thread. + * - `allInsideBlackBoxed`, all sources of `sourcesInside` which are currently + * blackboxed. + * - `allOutsideBlackBoxed`, all sources of `sourcesOutside` which are currently + * blackboxed. + */ +function getBlackBoxSourcesGroups(state, item) { + const allSources = []; + function collectAllSources(list, _item) { + if (_item.children) { + _item.children.forEach(i => collectAllSources(list, i)); + } + if (_item.type == "source") { + list.push(_item.source); + } + } + + const rootItems = getSourcesTreeSources(state); + const blackBoxRanges = getBlackBoxRanges(state); + + for (const rootItem of rootItems) { + collectAllSources(allSources, rootItem); + } + + const sourcesInside = []; + collectAllSources(sourcesInside, item); + + const sourcesOutside = allSources.filter( + source => !sourcesInside.includes(source) + ); + const allInsideBlackBoxed = sourcesInside.every( + source => blackBoxRanges[source.url] + ); + const allOutsideBlackBoxed = sourcesOutside.every( + source => blackBoxRanges[source.url] + ); + + return { + sourcesInside, + sourcesOutside, + allInsideBlackBoxed, + allOutsideBlackBoxed, + }; +} diff --git a/devtools/client/debugger/src/actions/context-menus/tab.js b/devtools/client/debugger/src/actions/context-menus/tab.js new file mode 100644 index 0000000000..193396a746 --- /dev/null +++ b/devtools/client/debugger/src/actions/context-menus/tab.js @@ -0,0 +1,128 @@ +/* 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/>. */ + +import { showMenu, buildMenu } from "../../context-menu/menu"; +import { getTabMenuItems } from "../../utils/tabs"; + +import { + getSelectedLocation, + getSourcesForTabs, + isSourceBlackBoxed, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors/index"; + +import { toggleBlackBox } from "../sources/blackbox"; +import { prettyPrintAndSelectSource } from "../sources/prettyPrint"; +import { copyToClipboard, showSource } from "../ui"; +import { closeTab, closeTabs } from "../tabs"; + +import { getRawSourceURL, isPretty, shouldBlackbox } from "../../utils/source"; +import { copyToTheClipboard } from "../../utils/clipboard"; + +/** + * Show the context menu of Tab. + * + * @param {object} event + * The context-menu DOM event. + * @param {object} source + * Source object of the related Tab. + */ +export function showTabContextMenu(event, source) { + return async ({ dispatch, getState }) => { + const state = getState(); + const selectedLocation = getSelectedLocation(state); + + const isBlackBoxed = isSourceBlackBoxed(state, source); + const isSourceOnIgnoreList = + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source); + const tabsSources = getSourcesForTabs(state); + + const otherTabsSources = tabsSources.filter(s => s !== source); + const tabIndex = tabsSources.findIndex(s => s === source); + const followingTabsSources = tabsSources.slice(tabIndex + 1); + + const tabMenuItems = getTabMenuItems(); + const items = [ + { + item: { + ...tabMenuItems.closeTab, + click: () => dispatch(closeTab(source)), + }, + }, + { + item: { + ...tabMenuItems.closeOtherTabs, + disabled: otherTabsSources.length === 0, + click: () => dispatch(closeTabs(otherTabsSources)), + }, + }, + { + item: { + ...tabMenuItems.closeTabsToEnd, + disabled: followingTabsSources.length === 0, + click: () => { + dispatch(closeTabs(followingTabsSources)); + }, + }, + }, + { + item: { + ...tabMenuItems.closeAllTabs, + click: () => dispatch(closeTabs(tabsSources)), + }, + }, + { item: { type: "separator" } }, + { + item: { + ...tabMenuItems.copySource, + // Only enable when this is the selected source as this requires the source to be loaded, + // which may not be the case if the tab wasn't ever selected. + // + // Note that when opening the debugger, you may have tabs opened from a previous session, + // but no selected location. + disabled: selectedLocation?.source.id !== source.id, + click: () => { + dispatch(copyToClipboard(selectedLocation)); + }, + }, + }, + { + item: { + ...tabMenuItems.copySourceUri2, + disabled: !source.url, + click: () => copyToTheClipboard(getRawSourceURL(source.url)), + }, + }, + { + item: { + ...tabMenuItems.showSource, + // Source Tree only shows sources with URL + disabled: !source.url, + click: () => dispatch(showSource(source.id)), + }, + }, + { + item: { + ...tabMenuItems.toggleBlackBox, + label: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore") + : L10N.getStr("ignoreContextItem.ignore"), + disabled: isSourceOnIgnoreList || !shouldBlackbox(source), + click: () => dispatch(toggleBlackBox(source)), + }, + }, + { + item: { + ...tabMenuItems.prettyPrint, + disabled: isPretty(source), + click: () => dispatch(prettyPrintAndSelectSource(source)), + }, + }, + ]; + + showMenu(event, buildMenu(items)); + }; +} diff --git a/devtools/client/debugger/src/actions/event-listeners.js b/devtools/client/debugger/src/actions/event-listeners.js new file mode 100644 index 0000000000..6eca5d7e9c --- /dev/null +++ b/devtools/client/debugger/src/actions/event-listeners.js @@ -0,0 +1,77 @@ +/* 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/>. */ + +import { + getActiveEventListeners, + getEventListenerExpanded, + shouldLogEventBreakpoints, +} from "../selectors/index"; + +async function updateBreakpoints(dispatch, client, newEvents) { + await client.setEventListenerBreakpoints(newEvents); + dispatch({ type: "UPDATE_EVENT_LISTENERS", active: newEvents }); +} + +async function updateExpanded(dispatch, newExpanded) { + dispatch({ + type: "UPDATE_EVENT_LISTENER_EXPANDED", + expanded: newExpanded, + }); +} + +export function addEventListenerBreakpoints(eventsToAdd) { + return async ({ dispatch, client, getState }) => { + const activeListenerBreakpoints = await getActiveEventListeners(getState()); + + const newEvents = [ + ...new Set([...eventsToAdd, ...activeListenerBreakpoints]), + ]; + await updateBreakpoints(dispatch, client, newEvents); + }; +} + +export function removeEventListenerBreakpoints(eventsToRemove) { + return async ({ dispatch, client, getState }) => { + const activeListenerBreakpoints = await getActiveEventListeners(getState()); + + const newEvents = activeListenerBreakpoints.filter( + event => !eventsToRemove.includes(event) + ); + + await updateBreakpoints(dispatch, client, newEvents); + }; +} + +export function toggleEventLogging() { + return async ({ dispatch, getState, client }) => { + const logEventBreakpoints = !shouldLogEventBreakpoints(getState()); + await client.toggleEventLogging(logEventBreakpoints); + dispatch({ type: "TOGGLE_EVENT_LISTENERS", logEventBreakpoints }); + }; +} + +export function addEventListenerExpanded(category) { + return async ({ dispatch, getState }) => { + const expanded = await getEventListenerExpanded(getState()); + const newExpanded = [...new Set([...expanded, category])]; + await updateExpanded(dispatch, newExpanded); + }; +} + +export function removeEventListenerExpanded(category) { + return async ({ dispatch, getState }) => { + const expanded = await getEventListenerExpanded(getState()); + + const newExpanded = expanded.filter(expand => expand != category); + + updateExpanded(dispatch, newExpanded); + }; +} + +export function getEventListenerBreakpointTypes() { + return async ({ dispatch, client }) => { + const categories = await client.getEventListenerBreakpointTypes(); + dispatch({ type: "RECEIVE_EVENT_LISTENER_TYPES", categories }); + }; +} diff --git a/devtools/client/debugger/src/actions/exceptions.js b/devtools/client/debugger/src/actions/exceptions.js new file mode 100644 index 0000000000..f1746ec2bb --- /dev/null +++ b/devtools/client/debugger/src/actions/exceptions.js @@ -0,0 +1,30 @@ +/* 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/>. */ + +export function addExceptionFromResources(resources) { + return async function ({ dispatch }) { + for (const resource of resources) { + const { pageError } = resource; + if (!pageError.error) { + continue; + } + const { columnNumber, lineNumber, sourceId, errorMessage } = pageError; + const stacktrace = pageError.stacktrace || []; + + const exception = { + columnNumber, + lineNumber, + sourceActorId: sourceId, + errorMessage, + stacktrace, + threadActorId: resource.targetFront.targetForm.threadActor, + }; + + dispatch({ + type: "ADD_EXCEPTION", + exception, + }); + } + }; +} diff --git a/devtools/client/debugger/src/actions/expressions.js b/devtools/client/debugger/src/actions/expressions.js new file mode 100644 index 0000000000..8ccb6013ba --- /dev/null +++ b/devtools/client/debugger/src/actions/expressions.js @@ -0,0 +1,210 @@ +/* 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/>. */ + +import { + getExpression, + getExpressions, + getSelectedSource, + getSelectedScopeMappings, + getSelectedFrameBindings, + getIsPaused, + getSelectedFrame, + getCurrentThread, + isMapScopesEnabled, +} from "../selectors/index"; +import { PROMISE } from "./utils/middleware/promise"; +import { wrapExpression } from "../utils/expressions"; +import { features } from "../utils/prefs"; + +/** + * Add expression for debugger to watch + * + * @param {string} input + */ +export function addExpression(input) { + return async ({ dispatch, getState, parserWorker }) => { + if (!input) { + return null; + } + + // If the expression already exists, only update its evaluation + let expression = getExpression(getState(), input); + if (!expression) { + // This will only display the expression input, + // evaluateExpression will update its value. + dispatch({ type: "ADD_EXPRESSION", input }); + + expression = getExpression(getState(), input); + // When there is an expression error, we won't store the expression + if (!expression) { + return null; + } + } + + return dispatch(evaluateExpression(expression)); + }; +} + +export function autocomplete(input, cursor) { + return async ({ dispatch, getState, client }) => { + if (!input) { + return; + } + const thread = getCurrentThread(getState()); + const selectedFrame = getSelectedFrame(getState(), thread); + const result = await client.autocomplete(input, cursor, selectedFrame?.id); + // Pass both selectedFrame and thread in case selectedFrame is null + dispatch({ type: "AUTOCOMPLETE", selectedFrame, thread, input, result }); + }; +} + +export function clearAutocomplete() { + return { type: "CLEAR_AUTOCOMPLETE" }; +} + +export function updateExpression(input, expression) { + return async ({ getState, dispatch, parserWorker }) => { + if (!input) { + return; + } + + dispatch({ + type: "UPDATE_EXPRESSION", + expression, + input, + }); + + await dispatch(evaluateExpressionsForCurrentContext()); + }; +} + +/** + * + * @param {object} expression + * @param {string} expression.input + */ +export function deleteExpression(expression) { + return { + type: "DELETE_EXPRESSION", + input: expression.input, + }; +} + +export function evaluateExpressionsForCurrentContext() { + return async ({ getState, dispatch }) => { + const thread = getCurrentThread(getState()); + const selectedFrame = getSelectedFrame(getState(), thread); + await dispatch(evaluateExpressions(selectedFrame)); + }; +} + +/** + * Update all the expressions by querying the server for updated values. + * + * @param {object} selectedFrame + * If defined, will evaluate the expression against this given frame, + * otherwise it will use the global scope of the thread. + */ +export function evaluateExpressions(selectedFrame) { + return async function ({ dispatch, getState, client }) { + const expressions = getExpressions(getState()); + const inputs = expressions.map(({ input }) => input); + // Fallback to global scope of the current thread when selectedFrame is null + const thread = selectedFrame?.thread || getCurrentThread(getState()); + const results = await client.evaluateExpressions(inputs, { + // We will only have a specific frame when passing a Selected frame context. + frameId: selectedFrame?.id, + threadId: thread, + }); + // Pass both selectedFrame and thread in case selectedFrame is null + dispatch({ + type: "EVALUATE_EXPRESSIONS", + + selectedFrame, + // As `selectedFrame` can be null, pass `thread` to help + // the reducer know what is the related thread of this action. + thread, + + inputs, + results, + }); + }; +} + +function evaluateExpression(expression) { + return async function (thunkArgs) { + let { input } = expression; + if (!input) { + console.warn("Expressions should not be empty"); + return null; + } + + const { dispatch, getState, client } = thunkArgs; + const thread = getCurrentThread(getState()); + const selectedFrame = getSelectedFrame(getState(), thread); + + const selectedSource = getSelectedSource(getState()); + // Only map when we are paused and if the currently selected source is original, + // and the paused location is also original. + if ( + selectedFrame && + selectedSource && + selectedFrame.location.source.isOriginal && + selectedSource.isOriginal + ) { + const mapResult = await getMappedExpression( + input, + selectedFrame.thread, + thunkArgs + ); + if (mapResult) { + input = mapResult.expression; + } + } + + // Pass both selectedFrame and thread in case selectedFrame is null + return dispatch({ + type: "EVALUATE_EXPRESSION", + selectedFrame, + // When we aren't passing a frame, we have to pass a thread to the pause reducer + thread: selectedFrame ? null : thread, + input: expression.input, + [PROMISE]: client.evaluate(wrapExpression(input), { + // When evaluating against the global scope (when not paused) + // frameId will be null here. + frameId: selectedFrame?.id, + }), + }); + }; +} + +/** + * Gets information about original variable names from the source map + * and replaces all possible generated names. + */ +export function getMappedExpression(expression, thread, thunkArgs) { + const { getState, parserWorker } = thunkArgs; + const mappings = getSelectedScopeMappings(getState(), thread); + const bindings = getSelectedFrameBindings(getState(), thread); + + // We bail early if we do not need to map the expression. This is important + // because mapping an expression can be slow if the parserWorker + // worker is busy doing other work. + // + // 1. there are no mappings - we do not need to map original expressions + // 2. does not contain `await` - we do not need to map top level awaits + // 3. does not contain `=` - we do not need to map assignments + const shouldMapScopes = isMapScopesEnabled(getState()) && mappings; + if (!shouldMapScopes && !expression.match(/(await|=)/)) { + return null; + } + + return parserWorker.mapExpression( + expression, + mappings, + bindings || [], + features.mapExpressionBindings && getIsPaused(getState(), thread), + features.mapAwaitExpression + ); +} diff --git a/devtools/client/debugger/src/actions/file-search.js b/devtools/client/debugger/src/actions/file-search.js new file mode 100644 index 0000000000..cc5794d7ab --- /dev/null +++ b/devtools/client/debugger/src/actions/file-search.js @@ -0,0 +1,51 @@ +/* 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/>. */ + +import { searchSourceForHighlight } from "../utils/editor/index"; + +import { + getSelectedSourceTextContent, + getSearchOptions, +} from "../selectors/index"; + +import { closeActiveSearch, clearHighlightLineRange } from "./ui"; + +export function doSearchForHighlight(query, editor, line, ch) { + return async ({ getState, dispatch }) => { + const sourceTextContent = getSelectedSourceTextContent(getState()); + if (!sourceTextContent) { + return; + } + + dispatch(searchContentsForHighlight(query, editor, line, ch)); + }; +} + +// Expose an action to the React component, so that it can call the searchWorker. +export function querySearchWorker(query, text, modifiers) { + return ({ searchWorker }) => { + return searchWorker.getMatches(query, text, modifiers); + }; +} + +export function searchContentsForHighlight(query, editor, line, ch) { + return async ({ getState, dispatch }) => { + const modifiers = getSearchOptions(getState(), "file-search"); + const sourceTextContent = getSelectedSourceTextContent(getState()); + + if (!query || !editor || !sourceTextContent || !modifiers) { + return; + } + + const ctx = { ed: editor, cm: editor.codeMirror }; + searchSourceForHighlight(ctx, false, query, true, modifiers, line, ch); + }; +} + +export function closeFileSearch() { + return ({ dispatch }) => { + dispatch(closeActiveSearch()); + dispatch(clearHighlightLineRange()); + }; +} diff --git a/devtools/client/debugger/src/actions/index.js b/devtools/client/debugger/src/actions/index.js new file mode 100644 index 0000000000..cc568f9681 --- /dev/null +++ b/devtools/client/debugger/src/actions/index.js @@ -0,0 +1,50 @@ +/* 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/>. */ + +import * as ast from "./ast/index"; +import * as breakpoints from "./breakpoints/index"; +import * as exceptions from "./exceptions"; +import * as expressions from "./expressions"; +import * as eventListeners from "./event-listeners"; +import * as pause from "./pause/index"; +import * as navigation from "./navigation"; +import * as ui from "./ui"; +import * as fileSearch from "./file-search"; +import * as projectTextSearch from "./project-text-search"; +import * as quickOpen from "./quick-open"; +import * as sourcesTree from "./sources-tree"; +import * as sources from "./sources/index"; +import * as sourcesActors from "./source-actors"; +import * as tabs from "./tabs"; +import * as threads from "./threads"; +import * as toolbox from "./toolbox"; +import * as preview from "./preview"; +import * as tracing from "./tracing"; +import * as contextMenu from "./context-menus/index"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +export default { + ...ast, + ...navigation, + ...breakpoints, + ...exceptions, + ...expressions, + ...eventListeners, + ...sources, + ...sourcesActors, + ...tabs, + ...pause, + ...ui, + ...fileSearch, + ...objectInspector.actions, + ...projectTextSearch, + ...quickOpen, + ...sourcesTree, + ...threads, + ...toolbox, + ...preview, + ...tracing, + ...contextMenu, +}; diff --git a/devtools/client/debugger/src/actions/moz.build b/devtools/client/debugger/src/actions/moz.build new file mode 100644 index 0000000000..72b1fac04c --- /dev/null +++ b/devtools/client/debugger/src/actions/moz.build @@ -0,0 +1,32 @@ +# 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/. + +DIRS += [ + "ast", + "breakpoints", + "context-menus", + "pause", + "sources", + "utils", +] + +CompiledModules( + "event-listeners.js", + "exceptions.js", + "expressions.js", + "file-search.js", + "index.js", + "navigation.js", + "preview.js", + "project-text-search.js", + "quick-open.js", + "source-actors.js", + "sources-tree.js", + "tabs.js", + "toolbox.js", + "tracing.js", + "threads.js", + "ui.js", +) diff --git a/devtools/client/debugger/src/actions/navigation.js b/devtools/client/debugger/src/actions/navigation.js new file mode 100644 index 0000000000..1b437837c2 --- /dev/null +++ b/devtools/client/debugger/src/actions/navigation.js @@ -0,0 +1,57 @@ +/* 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/>. */ + +import sourceQueue from "../utils/source-queue"; + +import { clearWasmStates } from "../utils/wasm"; +import { getMainThread } from "../selectors/index"; +import { evaluateExpressionsForCurrentContext } from "../actions/expressions"; + +/** + * Redux actions for the navigation state + * @module actions/navigation + */ + +/** + * @memberof actions/navigation + * @static + */ +export function willNavigate(event) { + return async function ({ + dispatch, + getState, + client, + sourceMapLoader, + parserWorker, + }) { + sourceQueue.clear(); + sourceMapLoader.clearSourceMaps(); + clearWasmStates(); + const thread = getMainThread(getState()); + + dispatch({ + type: "NAVIGATE", + mainThread: { ...thread, url: event.url }, + }); + }; +} + +/** + * @memberof actions/navigation + * @static + */ +export function navigated() { + return async function ({ getState, dispatch, panel }) { + try { + // Update the watched expressions once the page is fully loaded + await dispatch(evaluateExpressionsForCurrentContext()); + } catch (e) { + // This may throw if we resume during the page load. + // browser_dbg-debugger-buttons.js highlights this, especially on MacOS or when ran many times + console.error("Failed to update expression on navigation", e); + } + + panel.emit("reloaded"); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/breakOnNext.js b/devtools/client/debugger/src/actions/pause/breakOnNext.js new file mode 100644 index 0000000000..13f9f5f79c --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/breakOnNext.js @@ -0,0 +1,20 @@ +/* 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/>. */ + +import { getCurrentThread } from "../../selectors/index"; +/** + * Debugger breakOnNext command. + * It's different from the comand action because we also want to + * highlight the pause icon. + * + * @memberof actions/pause + * @static + */ +export function breakOnNext() { + return async ({ dispatch, getState, client }) => { + const thread = getCurrentThread(getState()); + await client.breakOnNext(thread); + return dispatch({ type: "BREAK_ON_NEXT", thread }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/commands.js b/devtools/client/debugger/src/actions/pause/commands.js new file mode 100644 index 0000000000..0bb371bf6e --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/commands.js @@ -0,0 +1,147 @@ +/* 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/>. */ + +import { + getSelectedFrame, + getCurrentThread, + getIsCurrentThreadPaused, + getIsPaused, +} from "../../selectors/index"; +import { PROMISE } from "../utils/middleware/promise"; +import { evaluateExpressions } from "../expressions"; +import { selectLocation } from "../sources/index"; +import { fetchScopes } from "./fetchScopes"; +import { fetchFrames } from "./fetchFrames"; +import { recordEvent } from "../../utils/telemetry"; +import { validateFrame } from "../../utils/context"; + +export function selectThread(thread) { + return async ({ dispatch, getState, client }) => { + if (getCurrentThread(getState()) === thread) { + return; + } + dispatch({ type: "SELECT_THREAD", thread }); + + const selectedFrame = getSelectedFrame(getState(), thread); + + const serverRequests = []; + // Update the watched expressions as we may never have evaluated them against this thread + // Note that selectedFrame may be null if the thread isn't paused. + serverRequests.push(dispatch(evaluateExpressions(selectedFrame))); + + // If we were paused on the newly selected thread, ensure: + // - select the source where we are paused, + // - fetching the paused stackframes, + // - fetching the paused scope, so that variable preview are working on the selected source. + // (frames and scopes is supposed to be fetched on pause, + // but if two threads pause concurrently, it might be cancelled) + if (selectedFrame) { + serverRequests.push(dispatch(selectLocation(selectedFrame.location))); + serverRequests.push(dispatch(fetchFrames(thread))); + + serverRequests.push(dispatch(fetchScopes(selectedFrame))); + } + + await Promise.all(serverRequests); + }; +} + +/** + * Debugger commands like stepOver, stepIn, stepOut, resume + * + * @param string type + */ +export function command(type) { + return async ({ dispatch, getState, client }) => { + if (!type) { + return null; + } + // For now, all commands are by default against the currently selected thread + const thread = getCurrentThread(getState()); + + const frame = getSelectedFrame(getState(), thread); + + return dispatch({ + type: "COMMAND", + command: type, + thread, + [PROMISE]: client[type](thread, frame?.id), + }); + }; +} + +/** + * StepIn + * + * @returns {Function} {@link command} + */ +export function stepIn() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepIn")); + }; +} + +/** + * stepOver + * + * @returns {Function} {@link command} + */ +export function stepOver() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepOver")); + }; +} + +/** + * stepOut + * + * @returns {Function} {@link command} + */ +export function stepOut() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + return dispatch(command("stepOut")); + }; +} + +/** + * resume + * + * @returns {Function} {@link command} + */ +export function resume() { + return ({ dispatch, getState }) => { + if (!getIsCurrentThreadPaused(getState())) { + return null; + } + recordEvent("continue"); + return dispatch(command("resume")); + }; +} + +/** + * restart frame + */ +export function restart(frame) { + return async ({ dispatch, getState, client }) => { + if (!getIsPaused(getState(), frame.thread)) { + return null; + } + validateFrame(getState(), frame); + return dispatch({ + type: "COMMAND", + command: "restart", + thread: frame.thread, + [PROMISE]: client.restart(frame.thread, frame.id), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/continueToHere.js b/devtools/client/debugger/src/actions/pause/continueToHere.js new file mode 100644 index 0000000000..046ad4d69a --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/continueToHere.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/>. */ + +import { + getSelectedSource, + getSelectedFrame, + getClosestBreakpointPosition, + getBreakpoint, + getCurrentThread, +} from "../../selectors/index"; +import { createLocation } from "../../utils/location"; +import { addHiddenBreakpoint } from "../breakpoints/index"; +import { setBreakpointPositions } from "../breakpoints/breakpointPositions"; + +import { resume } from "./commands"; + +export function continueToHere(location) { + return async function ({ dispatch, getState }) { + const { line, column } = location; + const thread = getCurrentThread(getState()); + const selectedSource = getSelectedSource(getState()); + const selectedFrame = getSelectedFrame(getState(), thread); + + if (!selectedFrame || !selectedSource) { + return; + } + + const debugLine = selectedFrame.location.line; + // If the user selects a line to continue to, + // it must be different than the currently paused line. + if (!column && debugLine == line) { + return; + } + + await dispatch(setBreakpointPositions(location)); + const position = getClosestBreakpointPosition(getState(), location); + + // If the user selects a location in the editor, + // there must be a place we can pause on that line. + if (column && !position) { + return; + } + + const pauseLocation = column && position ? position.location : location; + + // Set a hidden breakpoint if we do not already have a breakpoint + // at the closest position + if (!getBreakpoint(getState(), pauseLocation)) { + await dispatch( + addHiddenBreakpoint( + createLocation({ + source: selectedSource, + line: pauseLocation.line, + column: pauseLocation.column, + }) + ) + ); + } + + dispatch(resume()); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/expandScopes.js b/devtools/client/debugger/src/actions/pause/expandScopes.js new file mode 100644 index 0000000000..af95f16fea --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/expandScopes.js @@ -0,0 +1,16 @@ +/* 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/>. */ + +import { getScopeItemPath } from "../../utils/pause/scopes"; + +export function setExpandedScope(selectedFrame, item, expanded) { + return function ({ dispatch, getState }) { + return dispatch({ + type: "SET_EXPANDED_SCOPE", + selectedFrame, + path: getScopeItemPath(item), + expanded, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/fetchFrames.js b/devtools/client/debugger/src/actions/pause/fetchFrames.js new file mode 100644 index 0000000000..8db22a852e --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/fetchFrames.js @@ -0,0 +1,22 @@ +/* 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/>. */ + +import { getIsPaused } from "../../selectors/index"; + +export function fetchFrames(thread) { + return async function ({ dispatch, client, getState }) { + let frames; + try { + frames = await client.getFrames(thread); + } catch (e) { + // getFrames will fail if the thread has resumed. In this case the thread + // should no longer be valid and the frames we would have fetched would be + // discarded anyways. + if (getIsPaused(getState(), thread)) { + throw e; + } + } + dispatch({ type: "FETCHED_FRAMES", thread, frames }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/fetchScopes.js b/devtools/client/debugger/src/actions/pause/fetchScopes.js new file mode 100644 index 0000000000..1de472d1ed --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/fetchScopes.js @@ -0,0 +1,37 @@ +/* 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/>. */ + +import { + getGeneratedFrameScope, + getOriginalFrameScope, +} from "../../selectors/index"; +import { mapScopes } from "./mapScopes"; +import { generateInlinePreview } from "./inlinePreview"; +import { PROMISE } from "../utils/middleware/promise"; + +export function fetchScopes(selectedFrame) { + return async function ({ dispatch, getState, client }) { + // See if we already fetched the scopes. + // We may have pause on multiple thread and re-select a paused thread + // for which we already fetched the scopes. + // Ignore pending scopes as the previous action may have been cancelled + // by context assertions. + let scopes = getGeneratedFrameScope(getState(), selectedFrame); + if (!scopes?.scope) { + scopes = dispatch({ + type: "ADD_SCOPES", + selectedFrame, + [PROMISE]: client.getFrameScopes(selectedFrame), + }); + + scopes.then(() => { + dispatch(generateInlinePreview(selectedFrame)); + }); + } + + if (!getOriginalFrameScope(getState(), selectedFrame)) { + await dispatch(mapScopes(selectedFrame, scopes)); + } + }; +} diff --git a/devtools/client/debugger/src/actions/pause/index.js b/devtools/client/debugger/src/actions/pause/index.js new file mode 100644 index 0000000000..6ead921921 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/index.js @@ -0,0 +1,32 @@ +/* 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/>. */ + +/** + * Redux actions for the pause state + * @module actions/pause + */ + +export { + selectThread, + stepIn, + stepOver, + stepOut, + resume, + restart, +} from "./commands"; +export { fetchFrames } from "./fetchFrames"; +export { fetchScopes } from "./fetchScopes"; +export { paused } from "./paused"; +export { resumed } from "./resumed"; +export { continueToHere } from "./continueToHere"; +export { breakOnNext } from "./breakOnNext"; +export { resetBreakpointsPaneState } from "./resetBreakpointsPaneState"; +export { mapFrames } from "./mapFrames"; +export { pauseOnDebuggerStatement } from "./pauseOnDebuggerStatement"; +export { pauseOnExceptions } from "./pauseOnExceptions"; +export { selectFrame } from "./selectFrame"; +export { toggleSkipPausing, setSkipPausing } from "./skipPausing"; +export { toggleMapScopes } from "./mapScopes"; +export { setExpandedScope } from "./expandScopes"; +export { generateInlinePreview } from "./inlinePreview"; diff --git a/devtools/client/debugger/src/actions/pause/inlinePreview.js b/devtools/client/debugger/src/actions/pause/inlinePreview.js new file mode 100644 index 0000000000..4f32ff6292 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/inlinePreview.js @@ -0,0 +1,239 @@ +/* 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/>. */ + +import { + getOriginalFrameScope, + getGeneratedFrameScope, + getInlinePreviews, + getSelectedLocation, +} from "../../selectors/index"; +import { features } from "../../utils/prefs"; +import { validateSelectedFrame } from "../../utils/context"; + +// We need to display all variables in the current functional scope so +// include all data for block scopes until the first functional scope +function getLocalScopeLevels(originalAstScopes) { + let levels = 0; + while ( + originalAstScopes[levels] && + originalAstScopes[levels].type === "block" + ) { + levels++; + } + return levels; +} + +export function generateInlinePreview(selectedFrame) { + return async function ({ dispatch, getState, parserWorker, client }) { + if (!features.inlinePreview) { + return null; + } + + // Avoid regenerating inline previews when we already have preview data + if (getInlinePreviews(getState(), selectedFrame.thread, selectedFrame.id)) { + return null; + } + + const originalFrameScopes = getOriginalFrameScope( + getState(), + selectedFrame + ); + + const generatedFrameScopes = getGeneratedFrameScope( + getState(), + selectedFrame + ); + + let scopes = originalFrameScopes?.scope || generatedFrameScopes?.scope; + + if (!scopes || !scopes.bindings) { + return null; + } + + // It's important to use selectedLocation, because we don't know + // if we'll be viewing the original or generated frame location + const selectedLocation = getSelectedLocation(getState()); + if (!selectedLocation) { + return null; + } + + if (!parserWorker.isLocationSupported(selectedLocation)) { + return null; + } + + const originalAstScopes = await parserWorker.getScopes(selectedLocation); + validateSelectedFrame(getState(), selectedFrame); + + if (!originalAstScopes) { + return null; + } + + const allPreviews = []; + const pausedOnLine = selectedLocation.line; + const levels = getLocalScopeLevels(originalAstScopes); + + for ( + let curLevel = 0; + curLevel <= levels && scopes && scopes.bindings; + curLevel++ + ) { + const bindings = { ...scopes.bindings.variables }; + scopes.bindings.arguments.forEach(argument => { + Object.keys(argument).forEach(key => { + bindings[key] = argument[key]; + }); + }); + + const previewBindings = Object.keys(bindings).map(async name => { + // We want to show values of properties of objects only and not + // function calls on other data types like someArr.forEach etc.. + let properties = null; + const objectGrip = bindings[name].value; + if (objectGrip.actor && objectGrip.class === "Object") { + properties = await client.loadObjectProperties( + { + name, + path: name, + contents: { value: objectGrip }, + }, + selectedFrame.thread + ); + } + + const previewsFromBindings = getBindingValues( + originalAstScopes, + pausedOnLine, + name, + bindings[name].value, + curLevel, + properties + ); + + allPreviews.push(...previewsFromBindings); + }); + await Promise.all(previewBindings); + + scopes = scopes.parent; + } + + // Sort previews by line and column so they're displayed in the right order in the editor + allPreviews.sort((previewA, previewB) => { + if (previewA.line < previewB.line) { + return -1; + } + if (previewA.line > previewB.line) { + return 1; + } + // If we have the same line number + return previewA.column < previewB.column ? -1 : 1; + }); + + const previews = {}; + for (const preview of allPreviews) { + const { line } = preview; + if (!previews[line]) { + previews[line] = []; + } + previews[line].push(preview); + } + + return dispatch({ + type: "ADD_INLINE_PREVIEW", + selectedFrame, + previews, + }); + }; +} + +function getBindingValues( + originalAstScopes, + pausedOnLine, + name, + value, + curLevel, + properties +) { + const previews = []; + + const binding = originalAstScopes[curLevel]?.bindings[name]; + if (!binding) { + return previews; + } + + // Show a variable only once ( an object and it's child property are + // counted as different ) + const identifiers = new Set(); + + // We start from end as we want to show values besides variable + // located nearest to the breakpoint + for (let i = binding.refs.length - 1; i >= 0; i--) { + const ref = binding.refs[i]; + // Subtracting 1 from line as codemirror lines are 0 indexed + const line = ref.start.line - 1; + const column = ref.start.column; + // We don't want to render inline preview below the paused line + if (line >= pausedOnLine - 1) { + continue; + } + + const { displayName, displayValue } = getExpressionNameAndValue( + name, + value, + ref, + properties + ); + + // Variable with same name exists, display value of current or + // closest to the current scope's variable + if (identifiers.has(displayName)) { + continue; + } + identifiers.add(displayName); + + previews.push({ + line, + column, + name: displayName, + value: displayValue, + }); + } + return previews; +} + +function getExpressionNameAndValue( + name, + value, + // TODO: Add data type to ref + ref, + properties +) { + let displayName = name; + let displayValue = value; + + // Only variables of type Object will have properties + if (properties) { + let { meta } = ref; + // Presence of meta property means expression contains child property + // reference eg: objName.propName + while (meta) { + // Initially properties will be an array, after that it will be an object + if (displayValue === value) { + const property = properties.find(prop => prop.name === meta.property); + displayValue = property?.contents.value; + displayName += `.${meta.property}`; + } else if (displayValue?.preview?.ownProperties) { + const { ownProperties } = displayValue.preview; + Object.keys(ownProperties).forEach(prop => { + if (prop === meta.property) { + displayValue = ownProperties[prop].value; + displayName += `.${meta.property}`; + } + }); + } + meta = meta.parent; + } + } + + return { displayName, displayValue }; +} diff --git a/devtools/client/debugger/src/actions/pause/mapFrames.js b/devtools/client/debugger/src/actions/pause/mapFrames.js new file mode 100644 index 0000000000..9ce7052db1 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapFrames.js @@ -0,0 +1,161 @@ +/* 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/>. */ + +import { + getFrames, + getBlackBoxRanges, + getSelectedFrame, +} from "../../selectors/index"; + +import { isFrameBlackBoxed } from "../../utils/source"; + +import assert from "../../utils/assert"; +import { getOriginalLocation } from "../../utils/source-maps"; +import { + debuggerToSourceMapLocation, + sourceMapToDebuggerLocation, +} from "../../utils/location"; +import { annotateFramesWithLibrary } from "../../utils/pause/frames/annotateFrames"; +import { createWasmOriginalFrame } from "../../client/firefox/create"; + +import { getOriginalFunctionDisplayName } from "../sources/index"; + +function getSelectedFrameId(state, thread, frames) { + let selectedFrame = getSelectedFrame(state, thread); + const blackboxedRanges = getBlackBoxRanges(state); + + if (selectedFrame && !isFrameBlackBoxed(selectedFrame, blackboxedRanges)) { + return selectedFrame.id; + } + + selectedFrame = frames.find(frame => { + return !isFrameBlackBoxed(frame, blackboxedRanges); + }); + return selectedFrame?.id; +} + +async function updateFrameLocationAndDisplayName(frame, thunkArgs) { + // Ignore WASM original sources + if (frame.isOriginal) { + return frame; + } + + const location = await getOriginalLocation(frame.location, thunkArgs, { + waitForSource: true, + }); + // Avoid instantiating new frame objects if the frame location isn't mapped + if (location == frame.location) { + return frame; + } + + // As we now know that this frame relates to an original source... + // Fetch the symbols for it and compute the frame's originalDisplayName. + const originalDisplayName = await thunkArgs.dispatch( + getOriginalFunctionDisplayName(location) + ); + + // As we modify frame object, fork it to force causing re-renders + return { + ...frame, + location, + generatedLocation: frame.generatedLocation || frame.location, + originalDisplayName, + }; +} + +function isWasmOriginalSourceFrame(frame) { + if (!frame.location.source.isOriginal) { + return false; + } + + return Boolean(frame.generatedLocation?.source.isWasm); +} + +/** + * Wasm Source Maps can come with an non-standard "xScopes" attribute + * which allows mapping the scope of a given location. + */ +async function expandWasmFrames(frames, { getState, sourceMapLoader }) { + const result = []; + for (let i = 0; i < frames.length; ++i) { + const frame = frames[i]; + if (frame.isOriginal || !isWasmOriginalSourceFrame(frame)) { + result.push(frame); + continue; + } + const originalFrames = await sourceMapLoader.getOriginalStackFrames( + debuggerToSourceMapLocation(frame.generatedLocation) + ); + if (!originalFrames) { + result.push(frame); + continue; + } + + assert(!!originalFrames.length, "Expected at least one original frame"); + // First entry has no specific location -- use one from the generated frame. + originalFrames[0].location = frame.location; + + originalFrames.forEach((originalFrame, j) => { + if (!originalFrame.location) { + return; + } + + // Keep outer most frame with true actor ID, and generate unique + // one for the nested frames. + const id = j == 0 ? frame.id : `${frame.id}-originalFrame${j}`; + const originalFrameLocation = sourceMapToDebuggerLocation( + getState(), + originalFrame.location + ); + result.push( + createWasmOriginalFrame(frame, id, originalFrame, originalFrameLocation) + ); + }); + } + return result; +} + +/** + * Map call stack frame locations and display names to originals. + * e.g. + * 1. When the debuggee pauses + * 2. When a source is pretty printed + * 3. When symbols are loaded + * @memberof actions/pause + * @static + */ +export function mapFrames(thread) { + return async function (thunkArgs) { + const { dispatch, getState } = thunkArgs; + const frames = getFrames(getState(), thread); + if (!frames || !frames.length) { + return; + } + + // Update frame's location/generatedLocation/originalDisplayNames in case it relates to an original source + let mappedFrames = await Promise.all( + frames.map(frame => updateFrameLocationAndDisplayName(frame, thunkArgs)) + ); + + mappedFrames = await expandWasmFrames(mappedFrames, thunkArgs); + + // Add the "library" attribute on all frame objects (if relevant) + annotateFramesWithLibrary(mappedFrames); + + // After having mapped the frames, we should update the selected frame + // just in case the selected frame is now set on a blackboxed original source + const selectedFrameId = getSelectedFrameId( + getState(), + thread, + mappedFrames + ); + + dispatch({ + type: "MAP_FRAMES", + thread, + frames: mappedFrames, + selectedFrameId, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/mapScopes.js b/devtools/client/debugger/src/actions/pause/mapScopes.js new file mode 100644 index 0000000000..8cb096c0a8 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/mapScopes.js @@ -0,0 +1,201 @@ +/* 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/>. */ + +import { + getSettledSourceTextContent, + isMapScopesEnabled, + getSelectedFrame, + getGeneratedFrameScope, + getOriginalFrameScope, + getFirstSourceActorForGeneratedSource, + getCurrentThread, +} from "../../selectors/index"; +import { + loadOriginalSourceText, + loadGeneratedSourceText, +} from "../sources/loadSourceText"; +import { validateSelectedFrame } from "../../utils/context"; +import { PROMISE } from "../utils/middleware/promise"; + +import { log } from "../../utils/log"; + +import { buildMappedScopes } from "../../utils/pause/mapScopes/index"; +import { isFulfilled } from "../../utils/async-value"; + +import { getMappedLocation } from "../../utils/source-maps"; + +const expressionRegex = /\bfp\(\)/g; + +export async function buildOriginalScopes( + selectedFrame, + client, + generatedScopes +) { + if (!selectedFrame.originalVariables) { + throw new TypeError("(frame.originalVariables: XScopeVariables)"); + } + const originalVariables = selectedFrame.originalVariables; + const frameBase = originalVariables.frameBase || ""; + + const inputs = []; + for (let i = 0; i < originalVariables.vars.length; i++) { + const { expr } = originalVariables.vars[i]; + const expression = expr + ? expr.replace(expressionRegex, frameBase) + : "void 0"; + + inputs[i] = expression; + } + + const results = await client.evaluateExpressions(inputs, { + frameId: selectedFrame.id, + }); + + const variables = {}; + for (let i = 0; i < originalVariables.vars.length; i++) { + const { name } = originalVariables.vars[i]; + variables[name] = { value: results[i].result }; + } + + const bindings = { + arguments: [], + variables, + }; + + const { actor } = await generatedScopes; + const scope = { + type: "function", + scopeKind: "", + actor, + bindings, + parent: null, + function: null, + block: null, + }; + return { + mappings: {}, + scope, + }; +} + +export function toggleMapScopes() { + return async function ({ dispatch, getState }) { + if (isMapScopesEnabled(getState())) { + dispatch({ type: "TOGGLE_MAP_SCOPES", mapScopes: false }); + return; + } + + dispatch({ type: "TOGGLE_MAP_SCOPES", mapScopes: true }); + + // Ignore the call if there is no selected frame (we are not paused?) + const state = getState(); + const selectedFrame = getSelectedFrame(state, getCurrentThread(state)); + if (!selectedFrame) { + return; + } + + if (getOriginalFrameScope(getState(), selectedFrame)) { + return; + } + + // Also ignore the call if we didn't fetch the scopes for the selected frame + const scopes = getGeneratedFrameScope(getState(), selectedFrame); + if (!scopes) { + return; + } + + dispatch(mapScopes(selectedFrame, Promise.resolve(scopes.scope))); + }; +} + +export function mapScopes(selectedFrame, scopes) { + return async function (thunkArgs) { + const { getState, dispatch, client } = thunkArgs; + + await dispatch({ + type: "MAP_SCOPES", + selectedFrame, + [PROMISE]: (async function () { + if (selectedFrame.isOriginal && selectedFrame.originalVariables) { + return buildOriginalScopes(selectedFrame, client, scopes); + } + + // getMappedScopes is only specific to the sources where we map the variables + // in scope and so only need a thread context. Assert that we are on the same thread + // before retrieving a thread context. + validateSelectedFrame(getState(), selectedFrame); + + return dispatch(getMappedScopes(scopes, selectedFrame)); + })(), + }); + }; +} + +/** + * Get scopes mapped for a precise location. + * + * @param {Promise} scopes + * Can be null. Result of Commands.js's client.getFrameScopes + * @param {Objects locations + * Frame object, or custom object with 'location' and 'generatedLocation' attributes. + */ +export function getMappedScopes(scopes, locations) { + return async function (thunkArgs) { + const { getState, dispatch } = thunkArgs; + const generatedSource = locations.generatedLocation.source; + const source = locations.location.source; + + if ( + !isMapScopesEnabled(getState()) || + !source || + !generatedSource || + generatedSource.isWasm || + source.isPrettyPrinted || + !source.isOriginal + ) { + return null; + } + + // Load source text for the original source + await dispatch(loadOriginalSourceText(source)); + + const generatedSourceActor = getFirstSourceActorForGeneratedSource( + getState(), + generatedSource.id + ); + + // Also load source text for its corresponding generated source + await dispatch(loadGeneratedSourceText(generatedSourceActor)); + + try { + const content = + // load original source text content + getSettledSourceTextContent(getState(), locations.location); + + return await buildMappedScopes( + source, + content && isFulfilled(content) + ? content.value + : { type: "text", value: "", contentType: undefined }, + locations, + await scopes, + thunkArgs + ); + } catch (e) { + log(e); + return null; + } + }; +} + +/** + * Used to map variables used within conditional and log breakpoints. + */ +export function getMappedScopesForLocation(location) { + return async function (thunkArgs) { + const { dispatch } = thunkArgs; + const mappedLocation = await getMappedLocation(location, thunkArgs); + return dispatch(getMappedScopes(null, mappedLocation)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/moz.build b/devtools/client/debugger/src/actions/pause/moz.build new file mode 100644 index 0000000000..e8f65996ed --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/moz.build @@ -0,0 +1,26 @@ +# 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/. + +DIRS += [] + +CompiledModules( + "breakOnNext.js", + "commands.js", + "continueToHere.js", + "expandScopes.js", + "fetchFrames.js", + "fetchScopes.js", + "index.js", + "inlinePreview.js", + "mapFrames.js", + "mapScopes.js", + "paused.js", + "pauseOnDebuggerStatement.js", + "pauseOnExceptions.js", + "resetBreakpointsPaneState.js", + "resumed.js", + "selectFrame.js", + "skipPausing.js", +) diff --git a/devtools/client/debugger/src/actions/pause/pauseOnDebuggerStatement.js b/devtools/client/debugger/src/actions/pause/pauseOnDebuggerStatement.js new file mode 100644 index 0000000000..7b2b1d70cb --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/pauseOnDebuggerStatement.js @@ -0,0 +1,17 @@ +/* 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/>. */ + +import { PROMISE } from "../utils/middleware/promise"; + +export function pauseOnDebuggerStatement(shouldPauseOnDebuggerStatement) { + return ({ dispatch, getState, client }) => { + return dispatch({ + type: "PAUSE_ON_DEBUGGER_STATEMENT", + shouldPauseOnDebuggerStatement, + [PROMISE]: client.pauseOnDebuggerStatement( + shouldPauseOnDebuggerStatement + ), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js b/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js new file mode 100644 index 0000000000..e7c04ded61 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/pauseOnExceptions.js @@ -0,0 +1,34 @@ +/* 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/>. */ + +import { PROMISE } from "../utils/middleware/promise"; +import { recordEvent } from "../../utils/telemetry"; + +/** + * + * @memberof actions/pause + * @static + */ +export function pauseOnExceptions( + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions +) { + return ({ dispatch, getState, client }) => { + recordEvent("pause_on_exceptions", { + exceptions: shouldPauseOnExceptions, + // There's no "n" in the key below (#1463117) + ["caught_exceptio"]: shouldPauseOnCaughtExceptions, + }); + + return dispatch({ + type: "PAUSE_ON_EXCEPTIONS", + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + [PROMISE]: client.pauseOnExceptions( + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions + ), + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/paused.js b/devtools/client/debugger/src/actions/pause/paused.js new file mode 100644 index 0000000000..a7a631c28c --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/paused.js @@ -0,0 +1,69 @@ +/* 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/>. */ + +import { + getHiddenBreakpoint, + isEvaluatingExpression, + getSelectedFrame, +} from "../../selectors/index"; + +import { mapFrames, fetchFrames } from "./index"; +import { removeBreakpoint } from "../breakpoints/index"; +import { evaluateExpressions } from "../expressions"; +import { selectLocation } from "../sources/index"; +import { validateSelectedFrame } from "../../utils/context"; + +import { fetchScopes } from "./fetchScopes"; + +/** + * Debugger has just paused + * + * @param {object} pauseInfo + * See `createPause` method. + */ +export function paused(pauseInfo) { + return async function ({ dispatch, getState }) { + const { thread, frame, why } = pauseInfo; + + dispatch({ type: "PAUSED", thread, why, topFrame: frame }); + + // When we use "continue to here" feature we register an "hidden" breakpoint + // that should be removed on the next paused, even if we didn't hit it and + // paused for any other reason. + const hiddenBreakpoint = getHiddenBreakpoint(getState()); + if (hiddenBreakpoint) { + dispatch(removeBreakpoint(hiddenBreakpoint)); + } + + // The THREAD_STATE's "paused" resource only passes the top level stack frame, + // we dispatch the PAUSED action with it so that we can right away + // display it and update the UI to be paused. + // But we then fetch all the other frames: + await dispatch(fetchFrames(thread)); + // And map them to original source locations. + // Note that this will wait for all related original sources to be loaded in the reducers. + // So this step may pause for a little while. + await dispatch(mapFrames(thread)); + + // If we paused on a particular frame, automatically select the related source + // and highlight the paused line + const selectedFrame = getSelectedFrame(getState(), thread); + if (selectedFrame) { + await dispatch(selectLocation(selectedFrame.location)); + // We might have resumed while opening the location. + // Prevent further computation if this happens. + validateSelectedFrame(getState(), selectedFrame); + + // Fetch the previews for variables visible in the currently selected paused stackframe + await dispatch(fetchScopes(selectedFrame)); + + // Run after fetching scoping data so that it may make use of the sourcemap + // expression mappings for local variables. + const atException = why.type == "exception"; + if (!atException || !isEvaluatingExpression(getState(), thread)) { + await dispatch(evaluateExpressions(selectedFrame)); + } + } + }; +} diff --git a/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js b/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js new file mode 100644 index 0000000000..a602c58896 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Action for the breakpoints panel while paused. + * + * @memberof actions/pause + * @static + */ +export function resetBreakpointsPaneState(thread) { + return async ({ dispatch }) => { + dispatch({ + type: "RESET_BREAKPOINTS_PANE_STATE", + thread, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/resumed.js b/devtools/client/debugger/src/actions/pause/resumed.js new file mode 100644 index 0000000000..47d55f84ca --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/resumed.js @@ -0,0 +1,31 @@ +/* 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/>. */ + +import { + isStepping, + getPauseReason, + getSelectedFrame, +} from "../../selectors/index"; +import { evaluateExpressions } from "../expressions"; +import { inDebuggerEval } from "../../utils/pause/index"; + +/** + * Debugger has just resumed. + */ +export function resumed(thread) { + return async ({ dispatch, client, getState }) => { + const why = getPauseReason(getState(), thread); + const wasPausedInEval = inDebuggerEval(why); + const wasStepping = isStepping(getState(), thread); + + dispatch({ type: "RESUME", thread, wasStepping }); + + // Avoid updating expression if we are stepping and would re-pause right after, + // the expression will be updated on next pause. + if (!wasStepping && !wasPausedInEval) { + const selectedFrame = getSelectedFrame(getState(), thread); + await dispatch(evaluateExpressions(selectedFrame)); + } + }; +} diff --git a/devtools/client/debugger/src/actions/pause/selectFrame.js b/devtools/client/debugger/src/actions/pause/selectFrame.js new file mode 100644 index 0000000000..49ebacffe5 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/selectFrame.js @@ -0,0 +1,37 @@ +/* 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/>. */ + +import { selectLocation } from "../sources/index"; +import { evaluateExpressions } from "../expressions"; +import { fetchScopes } from "./fetchScopes"; +import { validateSelectedFrame } from "../../utils/context"; + +/** + * @memberof actions/pause + * @static + */ +export function selectFrame(frame) { + return async ({ dispatch, getState }) => { + // Frames that aren't on-stack do not support evalling and may not + // have live inspectable scopes, so we do not allow selecting them. + if (frame.state !== "on-stack") { + dispatch(selectLocation(frame.location)); + return; + } + + dispatch({ + type: "SELECT_FRAME", + frame, + }); + + // It's important that we wait for selectLocation to finish because + // we rely on the source being loaded and symbols fetched below. + await dispatch(selectLocation(frame.location)); + validateSelectedFrame(getState(), frame); + + await dispatch(evaluateExpressions(frame)); + + await dispatch(fetchScopes(frame)); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/skipPausing.js b/devtools/client/debugger/src/actions/pause/skipPausing.js new file mode 100644 index 0000000000..a9f1550dc1 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/skipPausing.js @@ -0,0 +1,33 @@ +/* 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/>. */ + +import { getSkipPausing } from "../../selectors/index"; + +/** + * @memberof actions/pause + * @static + */ +export function toggleSkipPausing() { + return async ({ dispatch, client, getState }) => { + const skipPausing = !getSkipPausing(getState()); + await client.setSkipPausing(skipPausing); + dispatch({ type: "TOGGLE_SKIP_PAUSING", skipPausing }); + }; +} + +/** + * @memberof actions/pause + * @static + */ +export function setSkipPausing(skipPausing) { + return async ({ dispatch, client, getState }) => { + const currentlySkipping = getSkipPausing(getState()); + if (currentlySkipping === skipPausing) { + return; + } + + await client.setSkipPausing(skipPausing); + dispatch({ type: "TOGGLE_SKIP_PAUSING", skipPausing }); + }; +} diff --git a/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap b/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap new file mode 100644 index 0000000000..55b8d3e724 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pauseOnExceptions should track telemetry for pauseOnException changes 1`] = ` +Array [ + Object { + "caught_exceptio": false, + "exceptions": true, + }, +] +`; diff --git a/devtools/client/debugger/src/actions/pause/tests/pause.spec.js b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js new file mode 100644 index 0000000000..f8bd87375a --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js @@ -0,0 +1,290 @@ +/* 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/>. */ + +import { + actions, + selectors, + createStore, + createSourceObject, + makeSource, + makeOriginalSource, + makeFrame, +} from "../../../utils/test-head"; + +import { makeWhyNormal } from "../../../utils/test-mockup"; +import { createLocation } from "../../../utils/location"; + +const mockCommandClient = { + stepIn: () => new Promise(), + stepOver: () => new Promise(_resolve => _resolve), + evaluate: async () => {}, + evaluateExpressions: async () => [], + resume: async () => {}, + getFrameScopes: async frame => frame.scope, + getFrames: async () => [], + setBreakpoint: () => new Promise(_resolve => {}), + sourceContents: ({ source }) => { + return new Promise((resolve, reject) => { + switch (source) { + case "foo1": + return resolve({ + source: "function foo1() {\n return 5;\n}", + contentType: "text/javascript", + }); + case "await": + return resolve({ + source: "async function aWait() {\n await foo(); return 5;\n}", + contentType: "text/javascript", + }); + + case "foo": + return resolve({ + source: "function foo() {\n return -5;\n}", + contentType: "text/javascript", + }); + case "foo-original": + return resolve({ + source: "\n\nfunction fooOriginal() {\n return -5;\n}", + contentType: "text/javascript", + }); + case "foo-wasm": + return resolve({ + source: { binary: new ArrayBuffer(0) }, + contentType: "application/wasm", + }); + case "foo-wasm/originalSource": + return resolve({ + source: "fn fooBar() {}\nfn barZoo() { fooBar() }", + contentType: "text/rust", + }); + } + + return resolve(); + }); + }, + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + actorID: "threadActorID", +}; + +const mockFrameId = "1"; + +function createPauseInfo( + frameLocation = createLocation({ + source: createSourceObject("foo1"), + line: 2, + }), + frameOpts = {} +) { + const frames = [ + makeFrame( + { id: mockFrameId, sourceId: frameLocation.source.id }, + { + location: frameLocation, + generatedLocation: frameLocation, + ...frameOpts, + } + ), + ]; + return { + thread: "FakeThread", + frame: frames[0], + frames, + loadedObjects: [], + why: makeWhyNormal(), + }; +} + +function debuggerToSourceMapLocation(l) { + return { + sourceId: l.source.id, + line: l.line, + column: l.column, + }; +} + +describe("pause", () => { + describe("stepping", () => { + it("should only step when paused", async () => { + const client = { stepIn: jest.fn() }; + const { dispatch } = createStore(client); + + dispatch(actions.stepIn()); + expect(client.stepIn.mock.calls).toHaveLength(0); + }); + + it("getting frame scopes with bindings", async () => { + const client = { ...mockCommandClient }; + const store = createStore(client, {}); + const { dispatch, getState } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + const generatedLocation = createLocation({ + source, + line: 1, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ), + }); + const mockPauseInfo = createPauseInfo(generatedLocation, { + scope: { + bindings: { + variables: { b: { value: {} } }, + arguments: [{ a: { value: {} } }], + }, + }, + }); + + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + await dispatch(actions.newOriginalSources([makeOriginalSource(source)])); + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + id: mockFrameId, + generatedLocation, + location: generatedLocation, + library: null, + scope: { + bindings: { + arguments: [{ a: { value: {} } }], + variables: { b: { value: {} } }, + }, + }, + thread: "FakeThread", + }, + ]); + + expect(selectors.getFrameScopes(getState(), "FakeThread")).toEqual({ + generated: { + 1: { + pending: false, + scope: { + bindings: { + arguments: [{ a: { value: {} } }], + variables: { b: { value: {} } }, + }, + }, + }, + }, + mappings: { 1: undefined }, + original: { 1: { pending: false, scope: undefined } }, + }); + + expect( + selectors.getSelectedFrameBindings(getState(), "FakeThread") + ).toEqual(["b", "a"]); + }); + + it("maps frame to original frames", async () => { + const sourceMapLoaderMock = { + getOriginalStackFrames: loc => Promise.resolve(originStackFrames), + getOriginalLocation: () => + Promise.resolve(debuggerToSourceMapLocation(originalLocation)), + getOriginalLocations: async items => + items.map(debuggerToSourceMapLocation), + getOriginalSourceText: async () => ({ + text: "fn fooBar() {}\nfn barZoo() { fooBar() }", + contentType: "text/rust", + }), + getGeneratedRangesForOriginal: async () => [], + }; + + const client = { ...mockCommandClient }; + const store = createStore(client, {}, sourceMapLoaderMock); + const { dispatch, getState } = store; + + const generatedSource = await dispatch( + actions.newGeneratedSource( + makeSource("foo-wasm", { introductionType: "wasm" }) + ) + ); + + const generatedLocation = createLocation({ + source: generatedSource, + line: 1, + column: 0, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + generatedSource.id + ), + }); + const mockPauseInfo = createPauseInfo(generatedLocation); + const { frames } = mockPauseInfo; + client.getFrames = async () => frames; + + const [originalSource] = await dispatch( + actions.newOriginalSources([makeOriginalSource(generatedSource)]) + ); + + const originalLocation = createLocation({ + source: originalSource, + line: 1, + column: 1, + }); + const originalLocation2 = createLocation({ + source: originalSource, + line: 2, + column: 14, + sourceActor: selectors.getFirstSourceActorForGeneratedSource( + getState(), + originalSource.id + ), + }); + + const originStackFrames = [ + { + displayName: "fooBar", + thread: "FakeThread", + }, + { + displayName: "barZoo", + location: originalLocation2, + thread: "FakeThread", + }, + ]; + + await dispatch(actions.paused(mockPauseInfo)); + expect(selectors.getFrames(getState(), "FakeThread")).toEqual([ + { + asyncCause: undefined, + displayName: "fooBar", + generatedLocation, + id: "1", + index: undefined, + isOriginal: true, + library: null, + location: originalLocation, + originalDisplayName: "fooBar", + originalVariables: undefined, + state: undefined, + this: undefined, + thread: "FakeThread", + type: undefined, + }, + { + asyncCause: undefined, + displayName: "barZoo", + generatedLocation, + id: "1-originalFrame1", + index: undefined, + isOriginal: true, + library: null, + location: originalLocation2, + originalDisplayName: "barZoo", + originalVariables: undefined, + state: undefined, + this: undefined, + thread: "FakeThread", + type: undefined, + }, + ]); + }); + }); +}); diff --git a/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js b/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js new file mode 100644 index 0000000000..bc8d000697 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js @@ -0,0 +1,24 @@ +/* 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/>. */ + +import { + actions, + createStore, + getTelemetryEvents, +} from "../../../utils/test-head"; + +import { + getShouldPauseOnExceptions, + getShouldPauseOnCaughtExceptions, +} from "../../../selectors/pause"; + +describe("pauseOnExceptions", () => { + it("should track telemetry for pauseOnException changes", async () => { + const { dispatch, getState } = createStore({ pauseOnExceptions: () => {} }); + dispatch(actions.pauseOnExceptions(true, false)); + expect(getTelemetryEvents("pause_on_exceptions")).toMatchSnapshot(); + expect(getShouldPauseOnExceptions(getState())).toBe(true); + expect(getShouldPauseOnCaughtExceptions(getState())).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js b/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js new file mode 100644 index 0000000000..83006c3089 --- /dev/null +++ b/devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { actions, selectors, createStore } from "../../../utils/test-head"; + +describe("sources - pretty print", () => { + it("returns a pretty source for a minified file", async () => { + const client = { setSkipPausing: jest.fn() }; + const { dispatch, getState } = createStore(client); + + await dispatch(actions.toggleSkipPausing()); + expect(selectors.getSkipPausing(getState())).toBe(true); + + await dispatch(actions.toggleSkipPausing()); + expect(selectors.getSkipPausing(getState())).toBe(false); + }); +}); diff --git a/devtools/client/debugger/src/actions/preview.js b/devtools/client/debugger/src/actions/preview.js new file mode 100644 index 0000000000..c3bc8dbffd --- /dev/null +++ b/devtools/client/debugger/src/actions/preview.js @@ -0,0 +1,159 @@ +/* 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/>. */ + +import { isConsole } from "../utils/preview"; +import { getGrip, getFront } from "../utils/evaluation-result"; +import { getExpressionFromCoords } from "../utils/editor/get-expression"; + +import { + isLineInScope, + isSelectedFrameVisible, + getSelectedSource, + getSelectedLocation, + getSelectedFrame, + getCurrentThread, + getSelectedException, +} from "../selectors/index"; + +import { getMappedExpression } from "./expressions"; + +async function findExpressionMatch(state, parserWorker, codeMirror, tokenPos) { + const location = getSelectedLocation(state); + if (!location) { + return null; + } + + // Fallback on expression from codemirror cursor if parser worker misses symbols + // or is unable to find a match. + const match = await parserWorker.findBestMatchExpression( + location.source.id, + tokenPos + ); + if (match) { + return match; + } + return getExpressionFromCoords(codeMirror, tokenPos); +} + +export function getPreview(target, tokenPos, codeMirror) { + return async thunkArgs => { + const { getState, client, parserWorker } = thunkArgs; + if ( + !isSelectedFrameVisible(getState()) || + !isLineInScope(getState(), tokenPos.line) + ) { + return null; + } + + const source = getSelectedSource(getState()); + if (!source) { + return null; + } + const thread = getCurrentThread(getState()); + const selectedFrame = getSelectedFrame(getState(), thread); + if (!selectedFrame) { + return null; + } + + const match = await findExpressionMatch( + getState(), + parserWorker, + codeMirror, + tokenPos + ); + if (!match) { + return null; + } + + let { expression, location } = match; + + if (isConsole(expression)) { + return null; + } + + if (location && source.isOriginal) { + const mapResult = await getMappedExpression( + expression, + thread, + thunkArgs + ); + if (mapResult) { + expression = mapResult.expression; + } + } + + const { result } = await client.evaluate(expression, { + frameId: selectedFrame.id, + }); + + const resultGrip = getGrip(result); + + // Error case occurs for a token that follows an errored evaluation + // https://github.com/firefox-devtools/debugger/pull/8056 + // Accommodating for null allows us to show preview for falsy values + // line "", false, null, Nan, and more + if (resultGrip === null) { + return null; + } + + // Handle cases where the result is invisible to the debugger + // and not possible to preview. Bug 1548256 + if ( + resultGrip && + resultGrip.class && + typeof resultGrip.class === "string" && + resultGrip.class.includes("InvisibleToDebugger") + ) { + return null; + } + + const root = { + path: expression, + contents: { + value: resultGrip, + front: getFront(result), + }, + }; + + return { + target, + tokenPos, + cursorPos: target.getBoundingClientRect(), + expression, + root, + resultGrip, + }; + }; +} + +export function getExceptionPreview(target, tokenPos, codeMirror) { + return async ({ dispatch, getState, parserWorker }) => { + const match = await findExpressionMatch( + getState(), + parserWorker, + codeMirror, + tokenPos + ); + if (!match) { + return null; + } + + const tokenColumnStart = match.location.start.column + 1; + const exception = getSelectedException( + getState(), + tokenPos.line, + tokenColumnStart + ); + if (!exception) { + return null; + } + + return { + target, + tokenPos, + cursorPos: target.getBoundingClientRect(), + exception, + }; + }; +} diff --git a/devtools/client/debugger/src/actions/project-text-search.js b/devtools/client/debugger/src/actions/project-text-search.js new file mode 100644 index 0000000000..70a74d560c --- /dev/null +++ b/devtools/client/debugger/src/actions/project-text-search.js @@ -0,0 +1,142 @@ +/* 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/>. */ + +/** + * Redux actions for the search state + * @module actions/search + */ + +import { isFulfilled } from "../utils/async-value"; +import { + getFirstSourceActorForGeneratedSource, + getSourceList, + getSettledSourceTextContent, + isSourceBlackBoxed, + getSearchOptions, +} from "../selectors/index"; +import { createLocation } from "../utils/location"; +import { matchesGlobPatterns } from "../utils/source"; +import { loadSourceText } from "./sources/loadSourceText"; +import { searchKeys } from "../constants"; + +export function searchSources(query, onUpdatedResults, signal) { + return async ({ dispatch, getState, searchWorker }) => { + dispatch({ + type: "SET_PROJECT_SEARCH_QUERY", + query, + }); + + const searchOptions = getSearchOptions( + getState(), + searchKeys.PROJECT_SEARCH + ); + const validSources = getSourceList(getState()).filter( + source => + !isSourceBlackBoxed(getState(), source) && + !matchesGlobPatterns(source, searchOptions.excludePatterns) + ); + // Sort original entries first so that search results are more useful. + // Deprioritize third-party scripts, so their results show last. + validSources.sort((a, b) => { + function isThirdParty(source) { + return ( + source?.url && + (source.url.includes("node_modules") || + source.url.includes("bower_components")) + ); + } + + if (a.isOriginal && !isThirdParty(a)) { + return -1; + } + + if (b.isOriginal && !isThirdParty(b)) { + return 1; + } + + if (!isThirdParty(a) && isThirdParty(b)) { + return -1; + } + if (isThirdParty(a) && !isThirdParty(b)) { + return 1; + } + return 0; + }); + const results = []; + for (const source of validSources) { + const sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(loadSourceText(source, sourceActor)); + + // This is the only asynchronous call in this method. + // We may have stopped the search by closing the search panel or changing the query. + // Avoid any further unecessary computation when the React Component tells us the query was cancelled. + if (signal.aborted) { + return; + } + + const result = await searchSource(source, sourceActor, query, { + getState, + searchWorker, + }); + if (signal.aborted) { + return; + } + + if (result) { + results.push(result); + onUpdatedResults(results, false, signal); + } + } + onUpdatedResults(results, true, signal); + }; +} + +export async function searchSource( + source, + sourceActor, + query, + { getState, searchWorker } +) { + const state = getState(); + const location = createLocation({ + source, + sourceActor, + }); + + const content = getSettledSourceTextContent(state, location); + let matches = []; + + if (content && isFulfilled(content) && content.value.type === "text") { + const options = getSearchOptions(state, searchKeys.PROJECT_SEARCH); + matches = await searchWorker.findSourceMatches( + content.value, + query, + options + ); + } + if (!matches.length) { + return null; + } + return { + type: "RESULT", + location, + // `matches` are generated by project-search worker's `findSourceMatches` method + matches: matches.map(m => ({ + type: "MATCH", + location: createLocation({ + ...location, + // `matches` only contain line and column + // `location` will already refer to the right source/sourceActor + line: m.line, + column: m.column, + }), + matchIndex: m.matchIndex, + match: m.match, + value: m.value, + })), + }; +} diff --git a/devtools/client/debugger/src/actions/quick-open.js b/devtools/client/debugger/src/actions/quick-open.js new file mode 100644 index 0000000000..e5f5352292 --- /dev/null +++ b/devtools/client/debugger/src/actions/quick-open.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/>. */ + +export function setQuickOpenQuery(query) { + return { + type: "SET_QUICK_OPEN_QUERY", + query, + }; +} + +export function openQuickOpen(query) { + if (query != null) { + return { type: "OPEN_QUICK_OPEN", query }; + } + return { type: "OPEN_QUICK_OPEN" }; +} + +export function closeQuickOpen() { + return { type: "CLOSE_QUICK_OPEN" }; +} diff --git a/devtools/client/debugger/src/actions/source-actors.js b/devtools/client/debugger/src/actions/source-actors.js new file mode 100644 index 0000000000..9782e493b3 --- /dev/null +++ b/devtools/client/debugger/src/actions/source-actors.js @@ -0,0 +1,12 @@ +/* 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/>. */ + +export function insertSourceActors(sourceActors) { + return function ({ dispatch }) { + dispatch({ + type: "INSERT_SOURCE_ACTORS", + sourceActors, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources-tree.js b/devtools/client/debugger/src/actions/sources-tree.js new file mode 100644 index 0000000000..9d54c08ac9 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources-tree.js @@ -0,0 +1,43 @@ +/* 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/>. */ + +import { getMainThread } from "../selectors/index"; + +export function setExpandedState(expanded) { + return { type: "SET_EXPANDED_STATE", expanded }; +} + +export function focusItem(item) { + return { type: "SET_FOCUSED_SOURCE_ITEM", item }; +} + +export function setProjectDirectoryRoot(newRootItemUniquePath, newName) { + return ({ dispatch, getState }) => { + // If the new project root is against the top level thread, + // replace its thread ID with "top-level", so that later, + // getDirectoryForUniquePath could match the project root, + // even after a page reload where the new top level thread actor ID + // will be different. + const mainThread = getMainThread(getState()); + if (mainThread && newRootItemUniquePath.startsWith(mainThread.actor)) { + newRootItemUniquePath = newRootItemUniquePath.replace( + mainThread.actor, + "top-level" + ); + } + dispatch({ + type: "SET_PROJECT_DIRECTORY_ROOT", + uniquePath: newRootItemUniquePath, + name: newName, + }); + }; +} + +export function clearProjectDirectoryRoot() { + return { + type: "SET_PROJECT_DIRECTORY_ROOT", + uniquePath: "", + name: "", + }; +} diff --git a/devtools/client/debugger/src/actions/sources/blackbox.js b/devtools/client/debugger/src/actions/sources/blackbox.js new file mode 100644 index 0000000000..3cf4df2a70 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/blackbox.js @@ -0,0 +1,211 @@ +/* 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/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/index"; +import { recordEvent } from "../../utils/telemetry"; +import { toggleBreakpoints } from "../breakpoints/index"; +import { + getSourceActorsForSource, + isSourceBlackBoxed, + getBlackBoxRanges, + getBreakpointsForSource, +} from "../../selectors/index"; + +export async function blackboxSourceActorsForSource( + thunkArgs, + source, + shouldBlackBox, + ranges = [] +) { + const { getState, client, sourceMapLoader } = thunkArgs; + let sourceId = source.id; + // If the source is the original, then get the source id of its generated file + // and the range for where the original is represented in the generated file + // (which might be a bundle including other files). + if (source.isOriginal) { + sourceId = originalToGeneratedId(source.id); + const range = await sourceMapLoader.getFileGeneratedRange(source.id); + ranges = []; + if (range) { + ranges.push(range); + // TODO bug 1752108: Investigate blackboxing lines in original files, + // there is likely to be issues as the whole genrated file + // representing the original file will always be blackboxed. + console.warn( + "The might be unxpected issues when ignoring lines in an original file. " + + "The whole original source is being blackboxed." + ); + } else { + throw new Error( + `Unable to retrieve generated ranges for original source ${source.url}` + ); + } + } + + for (const actor of getSourceActorsForSource(getState(), sourceId)) { + await client.blackBox(actor, shouldBlackBox, ranges); + } +} + +/** + * Toggle blackboxing for the whole source or for specific lines in a source + * + * @param {Object} source - The source to be blackboxed/unblackboxed. + * @param {Boolean} [shouldBlackBox] - Specifies if the source should be blackboxed (true + * or unblackboxed (false). When this is not provided + * option is decided based on the blackboxed state + * of the source. + * @param {Array} [ranges] - List of line/column offsets to blackbox, these + * are provided only when blackboxing lines. + * The range structure: + * const range = { + * start: { line: 1, column: 5 }, + * end: { line: 3, column: 4 }, + * } + */ +export function toggleBlackBox(source, shouldBlackBox, ranges = []) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + + shouldBlackBox = + typeof shouldBlackBox == "boolean" + ? shouldBlackBox + : !isSourceBlackBoxed(getState(), source); + + await blackboxSourceActorsForSource( + thunkArgs, + source, + shouldBlackBox, + ranges + ); + + if (shouldBlackBox) { + recordEvent("blackbox"); + // If ranges is an empty array, it would mean we are blackboxing the whole + // source. To do that lets reset the content to an empty array. + if (!ranges.length) { + dispatch({ type: "BLACKBOX_WHOLE_SOURCES", sources: [source] }); + await toggleBreakpointsInBlackboxedSources({ + thunkArgs, + shouldDisable: true, + sources: [source], + }); + } else { + const currentRanges = getBlackBoxRanges(getState())[source.url] || []; + ranges = ranges.filter(newRange => { + // To avoid adding duplicate ranges make sure + // no range already exists with same start and end lines. + const duplicate = currentRanges.findIndex( + r => + r.start.line == newRange.start.line && + r.end.line == newRange.end.line + ); + return duplicate == -1; + }); + dispatch({ type: "BLACKBOX_SOURCE_RANGES", source, ranges }); + await toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + shouldDisable: true, + source, + ranges, + }); + } + } else { + // if there are no ranges to blackbox, then we are unblackboxing + // the whole source + // eslint-disable-next-line no-lonely-if + if (!ranges.length) { + dispatch({ type: "UNBLACKBOX_WHOLE_SOURCES", sources: [source] }); + toggleBreakpointsInBlackboxedSources({ + thunkArgs, + shouldDisable: false, + sources: [source], + }); + } else { + dispatch({ type: "UNBLACKBOX_SOURCE_RANGES", source, ranges }); + const blackboxRanges = getBlackBoxRanges(getState()); + if (!blackboxRanges[source.url].length) { + dispatch({ type: "UNBLACKBOX_WHOLE_SOURCES", sources: [source] }); + } + await toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + shouldDisable: false, + source, + ranges, + }); + } + } + }; +} + +async function toggleBreakpointsInRangesForBlackboxedSource({ + thunkArgs, + shouldDisable, + source, + ranges, +}) { + const { dispatch, getState } = thunkArgs; + for (const range of ranges) { + const breakpoints = getBreakpointsForSource(getState(), source, range); + await dispatch(toggleBreakpoints(shouldDisable, breakpoints)); + } +} + +async function toggleBreakpointsInBlackboxedSources({ + thunkArgs, + shouldDisable, + sources, +}) { + const { dispatch, getState } = thunkArgs; + for (const source of sources) { + const breakpoints = getBreakpointsForSource(getState(), source); + await dispatch(toggleBreakpoints(shouldDisable, breakpoints)); + } +} + +/* + * Blackboxes a group of sources together + * + * @param {Array} sourcesToBlackBox - The list of sources to blackbox + * @param {Boolean} shouldBlackbox - Specifies if the sources should blackboxed (true) + * or unblackboxed (false). + */ +export function blackBoxSources(sourcesToBlackBox, shouldBlackBox) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + + const sources = sourcesToBlackBox.filter( + source => isSourceBlackBoxed(getState(), source) !== shouldBlackBox + ); + + if (!sources.length) { + return; + } + + for (const source of sources) { + await blackboxSourceActorsForSource(thunkArgs, source, shouldBlackBox); + } + + if (shouldBlackBox) { + recordEvent("blackbox"); + } + + dispatch({ + type: shouldBlackBox + ? "BLACKBOX_WHOLE_SOURCES" + : "UNBLACKBOX_WHOLE_SOURCES", + sources, + }); + await toggleBreakpointsInBlackboxedSources({ + thunkArgs, + shouldDisable: shouldBlackBox, + sources, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/breakableLines.js b/devtools/client/debugger/src/actions/sources/breakableLines.js new file mode 100644 index 0000000000..98d0b49a37 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/breakableLines.js @@ -0,0 +1,68 @@ +/* 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/>. */ + +import { + getBreakableLines, + getSourceActorBreakableLines, +} from "../../selectors/index"; +import { setBreakpointPositions } from "../breakpoints/breakpointPositions"; + +function calculateBreakableLines(positions) { + const lines = []; + for (const line in positions) { + if (positions[line].length) { + lines.push(Number(line)); + } + } + + return lines; +} + +/** + * Ensure that breakable lines for a given source are fetched. + * + * @param Object location + */ +export function setBreakableLines(location) { + return async ({ getState, dispatch, client }) => { + let breakableLines; + if (location.source.isOriginal) { + const positions = await dispatch(setBreakpointPositions(location)); + breakableLines = calculateBreakableLines(positions); + + const existingBreakableLines = getBreakableLines( + getState(), + location.source.id + ); + if (existingBreakableLines) { + breakableLines = [ + ...new Set([...existingBreakableLines, ...breakableLines]), + ]; + } + + dispatch({ + type: "SET_ORIGINAL_BREAKABLE_LINES", + source: location.source, + breakableLines, + }); + } else { + // Ignore re-fetching the breakable lines for source actor we already fetched + breakableLines = getSourceActorBreakableLines( + getState(), + location.sourceActor.id + ); + if (breakableLines) { + return; + } + breakableLines = await client.getSourceActorBreakableLines( + location.sourceActor + ); + dispatch({ + type: "SET_SOURCE_ACTOR_BREAKABLE_LINES", + sourceActor: location.sourceActor, + breakableLines, + }); + } + }; +} diff --git a/devtools/client/debugger/src/actions/sources/index.js b/devtools/client/debugger/src/actions/sources/index.js new file mode 100644 index 0000000000..9e2041dd31 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/index.js @@ -0,0 +1,40 @@ +/* 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/>. */ + +export * from "./blackbox"; +export * from "./breakableLines"; +export * from "./loadSourceText"; +export * from "./newSources"; +export * from "./prettyPrint"; +export * from "./select"; +export * from "./symbols"; + +export function setOverrideSource(source, path) { + return ({ client, dispatch }) => { + if (!source || !source.url) { + return; + } + const { url } = source; + client.setOverride(url, path); + dispatch({ + type: "SET_OVERRIDE", + url, + path, + }); + }; +} + +export function removeOverrideSource(source) { + return ({ client, dispatch }) => { + if (!source || !source.url) { + return; + } + const { url } = source; + client.removeOverride(url); + dispatch({ + type: "REMOVE_OVERRIDE", + url, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/loadSourceText.js b/devtools/client/debugger/src/actions/sources/loadSourceText.js new file mode 100644 index 0000000000..d3bbd53871 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/loadSourceText.js @@ -0,0 +1,252 @@ +/* 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/>. */ + +import { PROMISE } from "../utils/middleware/promise"; +import { + getSourceTextContent, + getSettledSourceTextContent, + getGeneratedSource, + getSourcesEpoch, + getBreakpointsForSource, + getSourceActorsForSource, + getFirstSourceActorForGeneratedSource, +} from "../../selectors/index"; +import { addBreakpoint } from "../breakpoints/index"; + +import { prettyPrintSourceTextContent } from "./prettyPrint"; +import { isFulfilled, fulfilled } from "../../utils/async-value"; + +import { isPretty } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { memoizeableAction } from "../../utils/memoizableAction"; + +async function loadGeneratedSource(sourceActor, { client }) { + // If no source actor can be found then the text for the + // source cannot be loaded. + if (!sourceActor) { + throw new Error("Source actor is null or not defined"); + } + + let response; + try { + response = await client.sourceContents(sourceActor); + } catch (e) { + throw new Error(`sourceContents failed: ${e}`); + } + + return { + text: response.source, + contentType: response.contentType || "text/javascript", + }; +} + +async function loadOriginalSource( + source, + { getState, client, sourceMapLoader, prettyPrintWorker } +) { + if (isPretty(source)) { + const generatedSource = getGeneratedSource(getState(), source); + if (!generatedSource) { + throw new Error("Unable to find minified original."); + } + + const content = getSettledSourceTextContent( + getState(), + createLocation({ + source: generatedSource, + }) + ); + + return prettyPrintSourceTextContent( + sourceMapLoader, + prettyPrintWorker, + generatedSource, + content, + getSourceActorsForSource(getState(), generatedSource.id) + ); + } + + const result = await sourceMapLoader.getOriginalSourceText(source.id); + if (!result) { + // The way we currently try to load and select a pending + // selected location, it is possible that we will try to fetch the + // original source text right after the source map has been cleared + // after a navigation event. + throw new Error("Original source text unavailable"); + } + return result; +} + +async function loadGeneratedSourceTextPromise(sourceActor, thunkArgs) { + const { dispatch, getState } = thunkArgs; + const epoch = getSourcesEpoch(getState()); + + await dispatch({ + type: "LOAD_GENERATED_SOURCE_TEXT", + sourceActor, + epoch, + [PROMISE]: loadGeneratedSource(sourceActor, thunkArgs), + }); + + await onSourceTextContentAvailable( + sourceActor.sourceObject, + sourceActor, + thunkArgs + ); +} + +async function loadOriginalSourceTextPromise(source, thunkArgs) { + const { dispatch, getState } = thunkArgs; + const epoch = getSourcesEpoch(getState()); + await dispatch({ + type: "LOAD_ORIGINAL_SOURCE_TEXT", + source, + epoch, + [PROMISE]: loadOriginalSource(source, thunkArgs), + }); + + await onSourceTextContentAvailable(source, null, thunkArgs); +} + +/** + * Function called everytime a new original or generated source gets its text content + * fetched from the server and registered in the reducer. + * + * @param {Object} source + * @param {Object} sourceActor (optional) + * If this is a generated source, we expect a precise source actor. + * @param {Object} thunkArgs + */ +async function onSourceTextContentAvailable( + source, + sourceActor, + { dispatch, getState, parserWorker } +) { + const location = createLocation({ + source, + sourceActor, + }); + const content = getSettledSourceTextContent(getState(), location); + if (!content) { + return; + } + + if (parserWorker.isLocationSupported(location)) { + parserWorker.setSource( + source.id, + isFulfilled(content) + ? content.value + : { type: "text", value: "", contentType: undefined } + ); + } + + // Update the text in any breakpoints for this source by re-adding them. + const breakpoints = getBreakpointsForSource(getState(), source); + for (const breakpoint of breakpoints) { + await dispatch( + addBreakpoint( + breakpoint.location, + breakpoint.options, + breakpoint.disabled + ) + ); + } +} + +/** + * Loads the source text for the generated source based of the source actor + * @param {Object} sourceActor + * There can be more than one source actor per source + * so the source actor needs to be specified. This is + * required for generated sources but will be null for + * original/pretty printed sources. + */ +export const loadGeneratedSourceText = memoizeableAction( + "loadGeneratedSourceText", + { + getValue: (sourceActor, { getState }) => { + if (!sourceActor) { + return null; + } + + const sourceTextContent = getSourceTextContent( + getState(), + createLocation({ + source: sourceActor.sourceObject, + sourceActor, + }) + ); + + if (!sourceTextContent || sourceTextContent.state === "pending") { + return sourceTextContent; + } + + // This currently swallows source-load-failure since we return fulfilled + // here when content.state === "rejected". In an ideal world we should + // propagate that error upward. + return fulfilled(sourceTextContent); + }, + createKey: (sourceActor, { getState }) => { + const epoch = getSourcesEpoch(getState()); + return `${epoch}:${sourceActor.actor}`; + }, + action: (sourceActor, thunkArgs) => + loadGeneratedSourceTextPromise(sourceActor, thunkArgs), + } +); + +/** + * Loads the source text for an original source and source actor + * @param {Object} source + * The original source to load the source text + */ +export const loadOriginalSourceText = memoizeableAction( + "loadOriginalSourceText", + { + getValue: (source, { getState }) => { + if (!source) { + return null; + } + + const sourceTextContent = getSourceTextContent( + getState(), + createLocation({ + source, + }) + ); + if (!sourceTextContent || sourceTextContent.state === "pending") { + return sourceTextContent; + } + + // This currently swallows source-load-failure since we return fulfilled + // here when content.state === "rejected". In an ideal world we should + // propagate that error upward. + return fulfilled(sourceTextContent); + }, + createKey: (source, { getState }) => { + const epoch = getSourcesEpoch(getState()); + return `${epoch}:${source.id}`; + }, + action: (source, thunkArgs) => + loadOriginalSourceTextPromise(source, thunkArgs), + } +); + +export function loadSourceText(source, sourceActor) { + return async ({ dispatch, getState }) => { + if (!source) { + return null; + } + if (source.isOriginal) { + return dispatch(loadOriginalSourceText(source)); + } + if (!sourceActor) { + sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + } + return dispatch(loadGeneratedSourceText(sourceActor)); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/moz.build b/devtools/client/debugger/src/actions/sources/moz.build new file mode 100644 index 0000000000..9972e9f09b --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/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/. + +DIRS += [] + +CompiledModules( + "blackbox.js", + "breakableLines.js", + "index.js", + "loadSourceText.js", + "newSources.js", + "prettyPrint.js", + "select.js", + "symbols.js", +) diff --git a/devtools/client/debugger/src/actions/sources/newSources.js b/devtools/client/debugger/src/actions/sources/newSources.js new file mode 100644 index 0000000000..4d9c2cd5f7 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/newSources.js @@ -0,0 +1,382 @@ +/* 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/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ +import { insertSourceActors } from "../../actions/source-actors"; +import { + makeSourceId, + createGeneratedSource, + createSourceMapOriginalSource, + createSourceActor, +} from "../../client/firefox/create"; +import { toggleBlackBox } from "./blackbox"; +import { syncPendingBreakpoint } from "../breakpoints/index"; +import { loadSourceText } from "./loadSourceText"; +import { prettyPrintAndSelectSource } from "./prettyPrint"; +import { toggleSourceMapIgnoreList } from "../ui"; +import { selectLocation, setBreakableLines } from "../sources/index"; + +import { getRawSourceURL, isPrettyURL } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { + getBlackBoxRanges, + getSource, + getSourceFromId, + hasSourceActor, + getSourceByActorId, + getPendingSelectedLocation, + getPendingBreakpointsForSource, +} from "../../selectors/index"; + +import { prefs } from "../../utils/prefs"; +import sourceQueue from "../../utils/source-queue"; +import { validateSourceActor, ContextError } from "../../utils/context"; + +function loadSourceMaps(sources) { + return async function ({ dispatch }) { + try { + const sourceList = await Promise.all( + sources.map(async sourceActor => { + const originalSourcesInfo = await dispatch( + loadSourceMap(sourceActor) + ); + originalSourcesInfo.forEach( + sourcesInfo => (sourcesInfo.sourceActor = sourceActor) + ); + sourceQueue.queueOriginalSources(originalSourcesInfo); + return originalSourcesInfo; + }) + ); + + await sourceQueue.flush(); + return sourceList.flat(); + } catch (error) { + if (!(error instanceof ContextError)) { + throw error; + } + } + return []; + }; +} + +/** + * @memberof actions/sources + * @static + */ +function loadSourceMap(sourceActor) { + return async function ({ dispatch, getState, sourceMapLoader, panel }) { + if (!prefs.clientSourceMapsEnabled || !sourceActor.sourceMapURL) { + return []; + } + + let sources, ignoreListUrls, resolvedSourceMapURL, exception; + try { + // Ignore sourceMapURL on scripts that are part of HTML files, since + // we currently treat sourcemaps as Source-wide, not SourceActor-specific. + const source = getSourceByActorId(getState(), sourceActor.id); + if (source) { + ({ sources, ignoreListUrls, resolvedSourceMapURL, exception } = + await sourceMapLoader.loadSourceMap({ + // Using source ID here is historical and eventually we'll want to + // switch to all of this being per-source-actor. + id: source.id, + url: sourceActor.url || "", + sourceMapBaseURL: sourceActor.sourceMapBaseURL || "", + sourceMapURL: sourceActor.sourceMapURL || "", + isWasm: sourceActor.introductionType === "wasm", + })); + } + } catch (e) { + exception = `Internal error: ${e.message}`; + } + + if (resolvedSourceMapURL) { + dispatch({ + type: "RESOLVED_SOURCEMAP_URL", + sourceActor, + resolvedSourceMapURL, + }); + } + + if (ignoreListUrls?.length) { + dispatch({ + type: "ADD_SOURCEMAP_IGNORE_LIST_SOURCES", + ignoreListUrls, + }); + } + + if (exception) { + // Catch all errors and log them to the Web Console for users to see. + const message = L10N.getFormatStr( + "toolbox.sourceMapFailure", + exception, + sourceActor.url, + sourceActor.sourceMapURL + ); + panel.toolbox.commands.targetCommand.targetFront.logWarningInPage( + message, + "source map", + resolvedSourceMapURL + ); + + dispatch({ + type: "SOURCE_MAP_ERROR", + sourceActor, + errorMessage: exception, + }); + + // If this source doesn't have a sourcemap or there are no original files + // existing, enable it for pretty printing + dispatch({ + type: "CLEAR_SOURCE_ACTOR_MAP_URL", + sourceActor, + }); + return []; + } + + // Before dispatching this action, ensure that the related sourceActor is still registered + validateSourceActor(getState(), sourceActor); + return sources; + }; +} + +// If a request has been made to show this source, go ahead and +// select it. +function checkSelectedSource(sourceId) { + return async ({ dispatch, getState }) => { + const state = getState(); + const pendingLocation = getPendingSelectedLocation(state); + + if (!pendingLocation || !pendingLocation.url) { + return; + } + + const source = getSource(state, sourceId); + + if (!source || !source.url) { + return; + } + + const pendingUrl = pendingLocation.url; + const rawPendingUrl = getRawSourceURL(pendingUrl); + + if (rawPendingUrl === source.url) { + if (isPrettyURL(pendingUrl)) { + const prettySource = await dispatch(prettyPrintAndSelectSource(source)); + dispatch(checkPendingBreakpoints(prettySource, null)); + return; + } + + await dispatch( + selectLocation( + createLocation({ + source, + line: + typeof pendingLocation.line === "number" + ? pendingLocation.line + : 0, + column: pendingLocation.column, + }) + ) + ); + } + }; +} + +function checkPendingBreakpoints(source, sourceActor) { + return async ({ dispatch, getState }) => { + const pendingBreakpoints = getPendingBreakpointsForSource( + getState(), + source + ); + + if (pendingBreakpoints.length === 0) { + return; + } + + // load the source text if there is a pending breakpoint for it + await dispatch(loadSourceText(source, sourceActor)); + await dispatch(setBreakableLines(createLocation({ source, sourceActor }))); + + await Promise.all( + pendingBreakpoints.map(pendingBp => { + return dispatch(syncPendingBreakpoint(source, pendingBp)); + }) + ); + }; +} + +function restoreBlackBoxedSources(sources) { + return async ({ dispatch, getState }) => { + const currentRanges = getBlackBoxRanges(getState()); + + if (!Object.keys(currentRanges).length) { + return; + } + + for (const source of sources) { + const ranges = currentRanges[source.url]; + if (ranges) { + // If the ranges is an empty then the whole source was blackboxed. + await dispatch(toggleBlackBox(source, true, ranges)); + } + } + + if (prefs.sourceMapIgnoreListEnabled) { + await dispatch(toggleSourceMapIgnoreList(true)); + } + }; +} + +export function newOriginalSources(originalSourcesInfo) { + return async ({ dispatch, getState }) => { + const state = getState(); + const seen = new Set(); + + const actors = []; + const actorsSources = {}; + + for (const { id, url, sourceActor } of originalSourcesInfo) { + if (seen.has(id) || getSource(state, id)) { + continue; + } + seen.add(id); + + if (!actorsSources[sourceActor.actor]) { + actors.push(sourceActor); + actorsSources[sourceActor.actor] = []; + } + + actorsSources[sourceActor.actor].push( + createSourceMapOriginalSource(id, url) + ); + } + + // Add the original sources per the generated source actors that + // they are primarily from. + actors.forEach(sourceActor => { + dispatch({ + type: "ADD_ORIGINAL_SOURCES", + originalSources: actorsSources[sourceActor.actor], + generatedSourceActor: sourceActor, + }); + }); + + // Accumulate the sources back into one list + const actorsSourcesValues = Object.values(actorsSources); + let sources = []; + if (actorsSourcesValues.length) { + sources = actorsSourcesValues.reduce((acc, sourceList) => + acc.concat(sourceList) + ); + } + + await dispatch(checkNewSources(sources)); + + for (const source of sources) { + dispatch(checkPendingBreakpoints(source, null)); + } + + return sources; + }; +} + +// Wrapper around newGeneratedSources, only used by tests +export function newGeneratedSource(sourceInfo) { + return async ({ dispatch }) => { + const sources = await dispatch(newGeneratedSources([sourceInfo])); + return sources[0]; + }; +} + +export function newGeneratedSources(sourceResources) { + return async ({ dispatch, getState, client }) => { + if (!sourceResources.length) { + return []; + } + + const resultIds = []; + const newSourcesObj = {}; + const newSourceActors = []; + + for (const sourceResource of sourceResources) { + // By the time we process the sources, the related target + // might already have been destroyed. It means that the sources + // are also about to be destroyed, so ignore them. + // (This is covered by browser_toolbox_backward_forward_navigation.js) + if (sourceResource.targetFront.isDestroyed()) { + continue; + } + const id = makeSourceId(sourceResource); + + if (!getSource(getState(), id) && !newSourcesObj[id]) { + newSourcesObj[id] = createGeneratedSource(sourceResource); + } + + const actorId = sourceResource.actor; + + // We are sometimes notified about a new source multiple times if we + // request a new source list and also get a source event from the server. + if (!hasSourceActor(getState(), actorId)) { + newSourceActors.push( + createSourceActor( + sourceResource, + getSource(getState(), id) || newSourcesObj[id] + ) + ); + } + + resultIds.push(id); + } + + const newSources = Object.values(newSourcesObj); + + dispatch({ type: "ADD_SOURCES", sources: newSources }); + dispatch(insertSourceActors(newSourceActors)); + + await dispatch(checkNewSources(newSources)); + + (async () => { + await dispatch(loadSourceMaps(newSourceActors)); + + // We would like to sync breakpoints after we are done + // loading source maps as sometimes generated and original + // files share the same paths. + for (const sourceActor of newSourceActors) { + // For HTML pages, we fetch all new incoming inline script, + // which will be related to one dedicated source actor. + // Whereas, for regular sources, if we have many source actors, + // this is for the same URL. And code expecting to have breakable lines + // will request breakable lines for that particular source actor. + if (sourceActor.sourceObject.isHTML) { + await dispatch( + setBreakableLines( + createLocation({ source: sourceActor.sourceObject, sourceActor }) + ) + ); + } + dispatch( + checkPendingBreakpoints(sourceActor.sourceObject, sourceActor) + ); + } + })(); + + return resultIds.map(id => getSourceFromId(getState(), id)); + }; +} + +function checkNewSources(sources) { + return async ({ dispatch, getState }) => { + for (const source of sources) { + dispatch(checkSelectedSource(source.id)); + } + + await dispatch(restoreBlackBoxedSources(sources)); + + return sources; + }; +} diff --git a/devtools/client/debugger/src/actions/sources/prettyPrint.js b/devtools/client/debugger/src/actions/sources/prettyPrint.js new file mode 100644 index 0000000000..6a12a34240 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/prettyPrint.js @@ -0,0 +1,358 @@ +/* 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/>. */ + +import { generatedToOriginalId } from "devtools/client/shared/source-map-loader/index"; + +import assert from "../../utils/assert"; +import { recordEvent } from "../../utils/telemetry"; +import { updateBreakpointsForNewPrettyPrintedSource } from "../breakpoints/index"; + +import { getPrettySourceURL, isJavaScript } from "../../utils/source"; +import { isFulfilled, fulfilled } from "../../utils/async-value"; +import { getOriginalLocation } from "../../utils/source-maps"; +import { prefs } from "../../utils/prefs"; +import { + loadGeneratedSourceText, + loadOriginalSourceText, +} from "./loadSourceText"; +import { mapFrames } from "../pause/index"; +import { selectSpecificLocation } from "../sources/index"; +import { createPrettyPrintOriginalSource } from "../../client/firefox/create"; + +import { + getFirstSourceActorForGeneratedSource, + getSourceFromId, + getSelectedLocation, +} from "../../selectors/index"; + +import { selectSource } from "./select"; +import { memoizeableAction } from "../../utils/memoizableAction"; + +import DevToolsUtils from "devtools/shared/DevToolsUtils"; + +const LINE_BREAK_REGEX = /\r\n?|\n|\u2028|\u2029/g; +function matchAllLineBreaks(str) { + return Array.from(str.matchAll(LINE_BREAK_REGEX)); +} + +function getPrettyOriginalSourceURL(generatedSource) { + return getPrettySourceURL(generatedSource.url || generatedSource.id); +} + +export async function prettyPrintSourceTextContent( + sourceMapLoader, + prettyPrintWorker, + generatedSource, + content, + actors +) { + if (!content || !isFulfilled(content)) { + throw new Error("Cannot pretty-print a file that has not loaded"); + } + + const contentValue = content.value; + if ( + (!isJavaScript(generatedSource, contentValue) && !generatedSource.isHTML) || + contentValue.type !== "text" + ) { + throw new Error( + `Can't prettify ${contentValue.contentType} files, only HTML and Javascript.` + ); + } + + const url = getPrettyOriginalSourceURL(generatedSource); + + let prettyPrintWorkerResult; + if (generatedSource.isHTML) { + prettyPrintWorkerResult = await prettyPrintHtmlFile({ + prettyPrintWorker, + generatedSource, + content, + actors, + }); + } else { + prettyPrintWorkerResult = await prettyPrintWorker.prettyPrint({ + sourceText: contentValue.value, + indent: " ".repeat(prefs.indentSize), + url, + }); + } + + // The source map URL service used by other devtools listens to changes to + // sources based on their actor IDs, so apply the sourceMap there too. + const generatedSourceIds = [ + generatedSource.id, + ...actors.map(item => item.actor), + ]; + await sourceMapLoader.setSourceMapForGeneratedSources( + generatedSourceIds, + prettyPrintWorkerResult.sourceMap + ); + + return { + text: prettyPrintWorkerResult.code, + contentType: contentValue.contentType, + }; +} + +/** + * Pretty print inline script inside an HTML file + * + * @param {Object} options + * @param {PrettyPrintDispatcher} options.prettyPrintWorker: The prettyPrint worker + * @param {Object} options.generatedSource: The HTML source we want to pretty print + * @param {Object} options.content + * @param {Array} options.actors: An array of the HTML file inline script sources data + * + * @returns Promise<Object> A promise that resolves with an object of the following shape: + * - {String} code: The prettified HTML text + * - {Object} sourceMap: The sourceMap object + */ +async function prettyPrintHtmlFile({ + prettyPrintWorker, + generatedSource, + content, + actors, +}) { + const url = getPrettyOriginalSourceURL(generatedSource); + const contentValue = content.value; + const htmlFileText = contentValue.value; + const prettyPrintWorkerResult = { code: htmlFileText }; + + const allLineBreaks = matchAllLineBreaks(htmlFileText); + let lineCountDelta = 0; + + // Sort inline script actors so they are in the same order as in the html document. + actors.sort((a, b) => { + if (a.sourceStartLine === b.sourceStartLine) { + return a.sourceStartColumn > b.sourceStartColumn; + } + return a.sourceStartLine > b.sourceStartLine; + }); + + const prettyPrintTaskId = generatedSource.id; + + // We don't want to replace part of the HTML document in the loop since it would require + // to account for modified lines for each iteration. + // Instead, we'll put each sections to replace in this array, where elements will be + // objects of the following shape: + // {Integer} startIndex: The start index in htmlFileText of the section we want to replace + // {Integer} endIndex: The end index in htmlFileText of the section we want to replace + // {String} prettyText: The pretty text we'll replace the original section with + // Once we iterated over all the inline scripts, we'll do the replacements (on the html + // file text) in reverse order, so we don't need have to care about the modified lines + // for each iteration. + const replacements = []; + + const seenLocations = new Set(); + + for (const sourceInfo of actors) { + // We can get duplicate source actors representing the same inline script which will + // cause trouble in the pretty printing here. This should be fixed on the server (see + // Bug 1824979), but in the meantime let's not handle the same location twice so the + // pretty printing is not impacted. + const location = `${sourceInfo.sourceStartLine}:${sourceInfo.sourceStartColumn}`; + if (!sourceInfo.sourceLength || seenLocations.has(location)) { + continue; + } + seenLocations.add(location); + // Here we want to get the index of the last line break before the script tag. + // In allLineBreaks, this would be the item at (script tag line - 1) + // Since sourceInfo.sourceStartLine is 1-based, we need to get the item at (sourceStartLine - 2) + const indexAfterPreviousLineBreakInHtml = + sourceInfo.sourceStartLine > 1 + ? allLineBreaks[sourceInfo.sourceStartLine - 2].index + 1 + : 0; + const startIndex = + indexAfterPreviousLineBreakInHtml + sourceInfo.sourceStartColumn; + const endIndex = startIndex + sourceInfo.sourceLength; + const scriptText = htmlFileText.substring(startIndex, endIndex); + DevToolsUtils.assert( + scriptText.length == sourceInfo.sourceLength, + "script text has expected length" + ); + + // Here we're going to pretty print each inline script content. + // Since we want to have a sourceMap that we'll apply to the whole HTML file, + // we'll only collect the sourceMap once we handled all inline scripts. + // `taskId` allows us to signal to the worker that all those calls are part of the + // same bigger file, and we'll use it later to get the sourceMap. + const prettyText = await prettyPrintWorker.prettyPrintInlineScript({ + taskId: prettyPrintTaskId, + sourceText: scriptText, + indent: " ".repeat(prefs.indentSize), + url, + originalStartLine: sourceInfo.sourceStartLine, + originalStartColumn: sourceInfo.sourceStartColumn, + // The generated line will be impacted by the previous inline scripts that were + // pretty printed, which is why we offset with lineCountDelta + generatedStartLine: sourceInfo.sourceStartLine + lineCountDelta, + generatedStartColumn: sourceInfo.sourceStartColumn, + lineCountDelta, + }); + + // We need to keep track of the line added/removed in order to properly offset + // the mapping of the pretty-print text + lineCountDelta += + matchAllLineBreaks(prettyText).length - + matchAllLineBreaks(scriptText).length; + + replacements.push({ + startIndex, + endIndex, + prettyText, + }); + } + + // `getSourceMap` allow us to collect the computed source map resulting of the calls + // to `prettyPrint` with the same taskId. + prettyPrintWorkerResult.sourceMap = await prettyPrintWorker.getSourceMap( + prettyPrintTaskId + ); + + // Sort replacement in reverse order so we can replace code in the HTML file more easily + replacements.sort((a, b) => a.startIndex < b.startIndex); + for (const { startIndex, endIndex, prettyText } of replacements) { + prettyPrintWorkerResult.code = + prettyPrintWorkerResult.code.substring(0, startIndex) + + prettyText + + prettyPrintWorkerResult.code.substring(endIndex); + } + + return prettyPrintWorkerResult; +} + +function createPrettySource(source, sourceActor) { + return async ({ dispatch, sourceMapLoader, getState }) => { + const url = getPrettyOriginalSourceURL(source); + const id = generatedToOriginalId(source.id, url); + const prettySource = createPrettyPrintOriginalSource(id, url); + + dispatch({ + type: "ADD_ORIGINAL_SOURCES", + originalSources: [prettySource], + generatedSourceActor: sourceActor, + }); + return prettySource; + }; +} + +function selectPrettyLocation(prettySource) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + let location = getSelectedLocation(getState()); + + // If we were selecting a particular line in the minified/generated source, + // try to select the matching line in the prettified/original source. + if ( + location && + location.line >= 1 && + getPrettySourceURL(location.source.url) == prettySource.url + ) { + // Note that it requires to have called `prettyPrintSourceTextContent` and `sourceMapLoader.setSourceMapForGeneratedSources` + // to be functional and so to be called after `loadOriginalSourceText` completed. + location = await getOriginalLocation(location, thunkArgs); + + // If the precise line/column correctly mapped to the pretty printed source, select that precise location. + // Otherwise fallback to selectSource in order to select the first line instead of the current line within the bundle. + if (location.source == prettySource) { + return dispatch(selectSpecificLocation(location)); + } + } + + return dispatch(selectSource(prettySource)); + }; +} + +/** + * Toggle the pretty printing of a source's text. + * Nothing will happen for non-javascript files. + * + * @param Object source + * The source object for the minified/generated source. + * @returns Promise + * A promise that resolves to the Pretty print/original source object. + */ +export async function prettyPrintSource(source, thunkArgs) { + const { dispatch, getState } = thunkArgs; + recordEvent("pretty_print"); + + assert( + !source.isOriginal, + "Pretty-printing only allowed on generated sources" + ); + + const sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(loadGeneratedSourceText(sourceActor)); + + const newPrettySource = await dispatch( + createPrettySource(source, sourceActor) + ); + + // Force loading the pretty source/original text. + // This will end up calling prettyPrintSourceTextContent() of this module, and + // more importantly, will populate the sourceMapLoader, which is used by selectPrettyLocation. + await dispatch(loadOriginalSourceText(newPrettySource)); + + // Update frames to the new pretty/original source (in case we were paused). + // Map the frames before selecting the pretty source in order to avoid + // having bundle/generated source for frames (we may compute scope things for the bundle). + await dispatch(mapFrames(sourceActor.thread)); + + // Update breakpoints locations to the new pretty/original source + await dispatch(updateBreakpointsForNewPrettyPrintedSource(source)); + + // A mutated flag, only meant to be used within this module + // to know when we are done loading the pretty printed source. + // This is important for the callsite in `selectLocation` + // in order to ensure all action are completed and especially `mapFrames`. + // Otherwise we may use generated frames there. + newPrettySource._loaded = true; + + return newPrettySource; +} + +// Use memoization in order to allow calling this actions many times +// while ensuring creating the pretty source only once. +const memoizedPrettyPrintSource = memoizeableAction("setSymbols", { + getValue: (source, { getState }) => { + // Lookup for an already existing pretty source + const url = getPrettyOriginalSourceURL(source); + const id = generatedToOriginalId(source.id, url); + const s = getSourceFromId(getState(), id); + // Avoid returning it if doTogglePrettyPrint isn't completed. + if (!s || !s._loaded) { + return undefined; + } + return fulfilled(s); + }, + createKey: source => source.id, + action: (source, thunkArgs) => prettyPrintSource(source, thunkArgs), +}); + +export function prettyPrintAndSelectSource(source) { + return async ({ dispatch, sourceMapLoader, getState }) => { + const prettySource = await dispatch(memoizedPrettyPrintSource(source)); + + // Select the pretty/original source based on the location we may + // have had against the minified/generated source. + // This uses source map to map locations. + // Also note that selecting a location force many things: + // * opening tabs + // * fetching symbols/inline scope + // * fetching breakable lines + // + // This isn't part of memoizedTogglePrettyPrint/doTogglePrettyPrint + // because if the source is already pretty printed, the memoization + // would avoid trying to update to the mapped location based + // on current location on the minified source. + await dispatch(selectPrettyLocation(prettySource)); + + return prettySource; + }; +} diff --git a/devtools/client/debugger/src/actions/sources/select.js b/devtools/client/debugger/src/actions/sources/select.js new file mode 100644 index 0000000000..63200a398a --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/select.js @@ -0,0 +1,368 @@ +/* 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/>. */ + +/** + * Redux actions for the sources state + * @module actions/sources + */ + +import { setSymbols } from "./symbols"; +import { setInScopeLines } from "../ast/index"; +import { prettyPrintAndSelectSource } from "./prettyPrint"; +import { addTab, closeTab } from "../tabs"; +import { loadSourceText } from "./loadSourceText"; +import { setBreakableLines } from "./breakableLines"; + +import { prefs } from "../../utils/prefs"; +import { isMinified } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { + getRelatedMapLocation, + getOriginalLocation, +} from "../../utils/source-maps"; + +import { + getSource, + getFirstSourceActorForGeneratedSource, + getSourceByURL, + getPrettySource, + getSelectedLocation, + getShouldSelectOriginalLocation, + canPrettyPrintSource, + getSourceTextContent, + tabExists, + hasSource, + hasSourceActor, + hasPrettyTab, + isSourceActorWithSourceMap, +} from "../../selectors/index"; + +// This is only used by jest tests (and within this module) +export const setSelectedLocation = ( + location, + shouldSelectOriginalLocation, + shouldHighlightSelectedLocation +) => ({ + type: "SET_SELECTED_LOCATION", + location, + shouldSelectOriginalLocation, + shouldHighlightSelectedLocation, +}); + +// This is only used by jest tests (and within this module) +export const setPendingSelectedLocation = (url, options) => ({ + type: "SET_PENDING_SELECTED_LOCATION", + url, + line: options?.line, + column: options?.column, +}); + +// This is only used by jest tests (and within this module) +export const clearSelectedLocation = () => ({ + type: "CLEAR_SELECTED_LOCATION", +}); + +/** + * Deterministically select a source that has a given URL. This will + * work regardless of the connection status or if the source exists + * yet. + * + * This exists mostly for external things to interact with the + * debugger. + */ +export function selectSourceURL(url, options) { + return async ({ dispatch, getState }) => { + const source = getSourceByURL(getState(), url); + if (!source) { + return dispatch(setPendingSelectedLocation(url, options)); + } + + const location = createLocation({ ...options, source }); + return dispatch(selectLocation(location)); + }; +} + +/** + * Wrapper around selectLocation, which creates the location object for us. + * Note that it ignores the currently selected source and will select + * the precise generated/original source passed as argument. + * + * @param {String} source + * The precise source to select. + * @param {String} sourceActor + * The specific source actor of the source to + * select the source text. This is optional. + */ +export function selectSource(source, sourceActor) { + return async ({ dispatch }) => { + // `createLocation` requires a source object, but we may use selectSource to close the last tab, + // where source will be null and the location will be an empty object. + const location = source ? createLocation({ source, sourceActor }) : {}; + + return dispatch(selectSpecificLocation(location)); + }; +} + +/** + * Select a new location. + * This will automatically select the source in the source tree (if visible) + * and open the source (a new tab and the source editor) + * as well as highlight a precise line in the editor. + * + * Note that by default, this may map your passed location to the original + * or generated location based on the selected source state. (see keepContext) + * + * @param {Object} location + * @param {Object} options + * @param {boolean} options.keepContext + * If false, this will ignore the currently selected source + * and select the generated or original location, even if we + * were currently selecting the other source type. + * @param {boolean} options.highlight + * True by default. To be set to false in order to preveng highlighting the selected location in the editor. + * We will only show the location, but do not put a special background on the line. + */ +export function selectLocation( + location, + { keepContext = true, highlight = true } = {} +) { + return async thunkArgs => { + const { dispatch, getState, client } = thunkArgs; + + if (!client) { + // No connection, do nothing. This happens when the debugger is + // shut down too fast and it tries to display a default source. + return; + } + + let source = location.source; + + if (!source) { + // If there is no source we deselect the current selected source + dispatch(clearSelectedLocation()); + return; + } + + // Preserve the current source map context (original / generated) + // when navigating to a new location. + // i.e. if keepContext isn't manually overriden to false, + // we will convert the source we want to select to either + // original/generated in order to match the currently selected one. + // If the currently selected source is original, we will + // automatically map `location` to refer to the original source, + // even if that used to refer only to the generated source. + let shouldSelectOriginalLocation = getShouldSelectOriginalLocation( + getState() + ); + if (keepContext) { + // Pretty print source may not be registered yet and getRelatedMapLocation may not return it. + // Wait for the pretty print source to be fully processed. + if ( + !location.source.isOriginal && + shouldSelectOriginalLocation && + hasPrettyTab(getState(), location.source) + ) { + // Note that prettyPrintAndSelectSource has already been called a bit before when this generated source has been added + // but it is a slow operation and is most likely not resolved yet. + // prettyPrintAndSelectSource uses memoization to avoid doing the operation more than once, while waiting from both callsites. + await dispatch(prettyPrintAndSelectSource(location.source)); + } + if (shouldSelectOriginalLocation != location.source.isOriginal) { + // getRelatedMapLocation will convert to the related generated/original location. + // i.e if the original location is passed, the related generated location will be returned and vice versa. + location = await getRelatedMapLocation(location, thunkArgs); + // Note that getRelatedMapLocation may return the exact same location. + // For example, if the source-map is half broken, it may return a generated location + // while we were selecting original locations. So we may be seeing bundles intermittently + // when stepping through broken source maps. And we will see original sources when stepping + // through functional original sources. + + source = location.source; + } + } else { + shouldSelectOriginalLocation = location.source.isOriginal; + } + + let sourceActor = location.sourceActor; + if (!sourceActor) { + sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + location = createLocation({ ...location, sourceActor }); + } + + if (!tabExists(getState(), source.id)) { + dispatch(addTab(source, sourceActor)); + } + + dispatch( + setSelectedLocation(location, shouldSelectOriginalLocation, highlight) + ); + + await dispatch(loadSourceText(source, sourceActor)); + + // Stop the async work if we started selecting another location + if (getSelectedLocation(getState()) != location) { + return; + } + + await dispatch(setBreakableLines(location)); + + // Stop the async work if we started selecting another location + if (getSelectedLocation(getState()) != location) { + return; + } + + const loadedSource = getSource(getState(), source.id); + + if (!loadedSource) { + // If there was a navigation while we were loading the loadedSource + return; + } + + const sourceTextContent = getSourceTextContent(getState(), location); + + if ( + keepContext && + prefs.autoPrettyPrint && + !getPrettySource(getState(), loadedSource.id) && + canPrettyPrintSource(getState(), location) && + isMinified(source, sourceTextContent) + ) { + await dispatch(prettyPrintAndSelectSource(loadedSource)); + dispatch(closeTab(loadedSource)); + } + + await dispatch(setSymbols(location)); + + // Stop the async work if we started selecting another location + if (getSelectedLocation(getState()) != location) { + return; + } + + // /!\ we don't historicaly wait for this async action + dispatch(setInScopeLines()); + + // When we select a generated source which has a sourcemap, + // asynchronously fetch the related original location in order to display + // the mapped location in the editor's footer. + if ( + !location.source.isOriginal && + isSourceActorWithSourceMap(getState(), sourceActor.id) + ) { + let originalLocation = await getOriginalLocation(location, thunkArgs, { + looseSearch: true, + }); + // We pass a null original location when the location doesn't map + // in order to know when we are done processing the source map. + // * `getOriginalLocation` would return the exact same location if it doesn't map + // * `getOriginalLocation` may also return a distinct location object, + // but refering to the same `source` object (which is the bundle) when it doesn't + // map to any known original location. + if (originalLocation.source === location.source) { + originalLocation = null; + } + dispatch({ + type: "SET_ORIGINAL_SELECTED_LOCATION", + location, + originalLocation, + }); + } + }; +} + +/** + * Select a location while ignoring the currently selected source. + * This will select the generated location even if the currently + * select source is an original source. And the other way around. + * + * @param {Object} location + * The location to select, object which includes enough + * information to specify a precise source, line and column. + */ +export function selectSpecificLocation(location) { + return selectLocation(location, { keepContext: false }); +} + +/** + * Similar to `selectSpecificLocation`, but if the precise Source object + * is missing, this will fallback to select any source having the same URL. + * In this fallback scenario, sources without a URL will be ignored. + * + * This is typically used when trying to select a source (e.g. in project search result) + * after reload, because the source objects are new on each new page load, but source + * with the same URL may still exist. + * + * @param {Object} location + * The location to select. + * @return {function} + * The action will return true if a matching source was found. + */ +export function selectSpecificLocationOrSameUrl(location) { + return async ({ dispatch, getState }) => { + // If this particular source no longer exists, open any matching URL. + // This will typically happen on reload. + if (!hasSource(getState(), location.source.id)) { + // Some sources, like evaled script won't have a URL attribute + // and can't be re-selected if we don't find the exact same source object. + if (!location.source.url) { + return false; + } + const source = getSourceByURL(getState(), location.source.url); + if (!source) { + return false; + } + // Also reset the sourceActor, as it won't match the same source. + const sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + location.source.id + ); + location = createLocation({ ...location, source, sourceActor }); + } else if (!hasSourceActor(getState(), location.sourceActor.id)) { + // If the specific source actor no longer exists, match any still available. + const sourceActor = getFirstSourceActorForGeneratedSource( + getState(), + location.source.id + ); + location = createLocation({ ...location, sourceActor }); + } + await dispatch(selectSpecificLocation(location)); + return true; + }; +} + +/** + * Select the "mapped location". + * + * If the passed location is on a generated source, select the + * related location in the original source. + * If the passed location is on an original source, select the + * related location in the generated source. + */ +export function jumpToMappedLocation(location) { + return async function (thunkArgs) { + const { client, dispatch } = thunkArgs; + if (!client) { + return null; + } + + // Map to either an original or a generated source location + const pairedLocation = await getRelatedMapLocation(location, thunkArgs); + + return dispatch(selectSpecificLocation(pairedLocation)); + }; +} + +// This is only used by tests +export function jumpToMappedSelectedLocation() { + return async function ({ dispatch, getState }) { + const location = getSelectedLocation(getState()); + if (!location) { + return; + } + + await dispatch(jumpToMappedLocation(location)); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/symbols.js b/devtools/client/debugger/src/actions/sources/symbols.js new file mode 100644 index 0000000000..c7b9132c32 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/symbols.js @@ -0,0 +1,62 @@ +/* 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/>. */ + +import { getSymbols } from "../../selectors/index"; + +import { PROMISE } from "../utils/middleware/promise"; +import { loadSourceText } from "./loadSourceText"; + +import { memoizeableAction } from "../../utils/memoizableAction"; +import { fulfilled } from "../../utils/async-value"; + +async function doSetSymbols(location, { dispatch, getState, parserWorker }) { + await dispatch(loadSourceText(location.source, location.sourceActor)); + + await dispatch({ + type: "SET_SYMBOLS", + location, + [PROMISE]: parserWorker.getSymbols(location.source.id), + }); +} + +export const setSymbols = memoizeableAction("setSymbols", { + getValue: (location, { getState, parserWorker }) => { + if (!parserWorker.isLocationSupported(location)) { + return fulfilled(null); + } + + const symbols = getSymbols(getState(), location); + if (!symbols) { + return null; + } + + return fulfilled(symbols); + }, + createKey: location => location.source.id, + action: (location, thunkArgs) => doSetSymbols(location, thunkArgs), +}); + +export function getOriginalFunctionDisplayName(location) { + return async ({ parserWorker, dispatch }) => { + // Make sure the source for the symbols exist in the parser worker. + await dispatch(loadSourceText(location.source, location.sourceActor)); + return parserWorker.getClosestFunctionName(location); + }; +} + +export function getFunctionSymbols(location, maxResults) { + return async ({ parserWorker, dispatch }) => { + // Make sure the source for the symbols exist in the parser worker. + await dispatch(loadSourceText(location.source, location.sourceActor)); + return parserWorker.getFunctionSymbols(location.source.id, maxResults); + }; +} + +export function getClassSymbols(location) { + return async ({ parserWorker, dispatch }) => { + // See comment in getFunctionSymbols + await dispatch(loadSourceText(location.source, location.sourceActor)); + return parserWorker.getClassSymbols(location.source.id); + }; +} diff --git a/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js new file mode 100644 index 0000000000..9a7c69ee32 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.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/>. */ + +import { + actions, + selectors, + createStore, + makeSource, +} from "../../../utils/test-head"; + +import { initialSourceBlackBoxState } from "../../../reducers/source-blackbox"; + +describe("blackbox", () => { + it("should blackbox and unblackbox a source based on the current state of the source", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + await dispatch(actions.toggleBlackBox(fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([]); + + await dispatch(actions.toggleBlackBox(fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should blackbox and unblackbox a source when explicilty specified", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + // check the state before trying to blackbox + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + + // should blackbox the whole source + await dispatch(actions.toggleBlackBox(fooSource, true, [])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([]); + + // should unblackbox the whole source + await dispatch(actions.toggleBlackBox(fooSource, false, [])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should blackbox and unblackbox lines in a source", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + const range1 = { + start: { line: 10, column: 3 }, + end: { line: 15, column: 4 }, + }; + + const range2 = { + start: { line: 5, column: 3 }, + end: { line: 7, column: 6 }, + }; + + await dispatch(actions.toggleBlackBox(fooSource, true, [range1])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([range1]); + + // add new blackbox lines in the second range + await dispatch(actions.toggleBlackBox(fooSource, true, [range2])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + // ranges are stored asc order + expect(blackboxRanges[fooSource.url]).toEqual([range2, range1]); + + // un-blackbox lines in the first range + await dispatch(actions.toggleBlackBox(fooSource, false, [range1])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual([range2]); + + // un-blackbox lines in the second range + await dispatch(actions.toggleBlackBox(fooSource, false, [range2])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should undo blackboxed lines when whole source unblackboxed", async () => { + const store = createStore({ + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + const range1 = { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }; + + const range2 = { + start: { line: 5, column: 3 }, + end: { line: 7, column: 6 }, + }; + + await dispatch(actions.toggleBlackBox(fooSource, true, [range1, range2])); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + // The ranges are ordered in based on the lines & cols in ascending + expect(blackboxRanges[fooSource.url]).toEqual([range2, range1]); + + // un-blackbox the whole source + await dispatch(actions.toggleBlackBox(fooSource)); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); + + it("should restore the blackboxed state correctly debugger load", async () => { + const mockAsyncStoreBlackBoxedRanges = { + "http://localhost:8000/examples/foo": [ + { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }, + ], + }; + + function loadInitialState() { + const blackboxedRanges = mockAsyncStoreBlackBoxedRanges; + return { + sourceBlackBox: initialSourceBlackBoxState({ blackboxedRanges }), + }; + } + const store = createStore( + { + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }, + loadInitialState() + ); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + const blackboxRanges = selectors.getBlackBoxRanges(getState()); + const mockFooSourceRange = mockAsyncStoreBlackBoxedRanges[fooSource.url]; + expect(blackboxRanges[fooSource.url]).toEqual(mockFooSourceRange); + }); + + it("should unblackbox lines after blackboxed state has been restored", async () => { + const mockAsyncStoreBlackBoxedRanges = { + "http://localhost:8000/examples/foo": [ + { + start: { line: 1, column: 5 }, + end: { line: 3, column: 4 }, + }, + ], + }; + + function loadInitialState() { + const blackboxedRanges = mockAsyncStoreBlackBoxedRanges; + return { + sourceBlackBox: initialSourceBlackBoxState({ blackboxedRanges }), + }; + } + const store = createStore( + { + blackBox: async () => true, + getSourceActorBreakableLines: async () => [], + }, + loadInitialState() + ); + const { dispatch, getState } = store; + + const fooSource = await dispatch( + actions.newGeneratedSource(makeSource("foo")) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true); + + let blackboxRanges = selectors.getBlackBoxRanges(getState()); + const mockFooSourceRange = mockAsyncStoreBlackBoxedRanges[fooSource.url]; + expect(blackboxRanges[fooSource.url]).toEqual(mockFooSourceRange); + + //unblackbox the blackboxed line + await dispatch( + actions.toggleBlackBox(fooSource, false, mockFooSourceRange) + ); + + expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(false); + + blackboxRanges = selectors.getBlackBoxRanges(getState()); + expect(blackboxRanges[fooSource.url]).toEqual(undefined); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js new file mode 100644 index 0000000000..8de08eb8a8 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js @@ -0,0 +1,216 @@ +/* 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/>. */ + +import { + actions, + selectors, + watchForState, + createStore, + makeSource, +} from "../../../utils/test-head"; +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; +import { isFulfilled, isRejected } from "../../../utils/async-value"; +import { createLocation } from "../../../utils/location"; + +describe("loadGeneratedSourceText", () => { + it("should load source text", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState } = store; + + const foo1Source = await dispatch( + actions.newGeneratedSource(makeSource("foo1")) + ); + const foo1SourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + foo1Source.id + ); + await dispatch(actions.loadGeneratedSourceText(foo1SourceActor)); + + const foo1Content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source: foo1Source, + sourceActor: foo1SourceActor, + }) + ); + + expect( + foo1Content && + isFulfilled(foo1Content) && + foo1Content.value.type === "text" + ? foo1Content.value.value.indexOf("return foo1") + : -1 + ).not.toBe(-1); + + const foo2Source = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + const foo2SourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + foo2Source.id + ); + + await dispatch(actions.loadGeneratedSourceText(foo2SourceActor)); + + const foo2Content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source: foo2Source, + sourceActor: foo2SourceActor, + }) + ); + + expect( + foo2Content && + isFulfilled(foo2Content) && + foo2Content.value.type === "text" + ? foo2Content.value.value.indexOf("return foo2") + : -1 + ).not.toBe(-1); + }); + + it("loads two sources w/ one request", async () => { + let resolve; + let count = 0; + const { dispatch, getState } = createStore({ + sourceContents: () => + new Promise(r => { + count++; + resolve = r; + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + }); + const id = "foo"; + + const source = await dispatch(actions.newGeneratedSource(makeSource(id))); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + dispatch(actions.loadGeneratedSourceText(sourceActor)); + + const loading = dispatch(actions.loadGeneratedSourceText(sourceActor)); + + if (!resolve) { + throw new Error("no resolve"); + } + resolve({ source: "yay", contentType: "text/javascript" }); + await loading; + expect(count).toEqual(1); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && + isFulfilled(content) && + content.value.type === "text" && + content.value.value + ).toEqual("yay"); + }); + + it("doesn't re-load loaded sources", async () => { + let resolve; + let count = 0; + const { dispatch, getState } = createStore({ + sourceContents: () => + new Promise(r => { + count++; + resolve = r; + }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + }); + const id = "foo"; + + const source = await dispatch(actions.newGeneratedSource(makeSource(id))); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + const loading = dispatch(actions.loadGeneratedSourceText(sourceActor)); + + if (!resolve) { + throw new Error("no resolve"); + } + resolve({ source: "yay", contentType: "text/javascript" }); + await loading; + + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + expect(count).toEqual(1); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && + isFulfilled(content) && + content.value.type === "text" && + content.value.value + ).toEqual("yay"); + }); + + it("should indicate a loading source", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo2")) + ); + + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + const wasLoading = watchForState(store, state => { + return !selectors.getSettledSourceTextContent( + state, + createLocation({ + source, + sourceActor, + }) + ); + }); + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + expect(wasLoading()).toBe(true); + }); + + it("should indicate an errored source text", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bad-id")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + const content = selectors.getSettledSourceTextContent( + getState(), + createLocation({ + source, + sourceActor, + }) + ); + expect( + content && isRejected(content) && typeof content.value === "string" + ? content.value.indexOf("sourceContents failed") + : -1 + ).not.toBe(-1); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js new file mode 100644 index 0000000000..cef9eca31e --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js @@ -0,0 +1,103 @@ +/* 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/>. */ + +import { + actions, + selectors, + createStore, + makeSource, + makeSourceURL, + makeOriginalSource, +} from "../../../utils/test-head"; +const { getSource, getSourceCount, getSelectedSource } = selectors; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; + +describe("sources - new sources", () => { + it("should add sources to state", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + await dispatch(actions.newGeneratedSource(makeSource("jquery.js"))); + + expect(getSourceCount(getState())).toEqual(2); + const base = getSource(getState(), "base.js"); + const jquery = getSource(getState(), "jquery.js"); + expect(base && base.id).toEqual("base.js"); + expect(jquery && jquery.id).toEqual("jquery.js"); + }); + + it("should not add multiple identical generated sources", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + const generated = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + await dispatch(actions.newOriginalSources([makeOriginalSource(generated)])); + await dispatch(actions.newOriginalSources([makeOriginalSource(generated)])); + + expect(getSourceCount(getState())).toEqual(2); + }); + + it("should not add multiple identical original sources", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + + expect(getSourceCount(getState())).toEqual(1); + }); + + it("should automatically select a pending source", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const baseSourceURL = makeSourceURL("base.js"); + await dispatch(actions.selectSourceURL(baseSourceURL)); + + expect(getSelectedSource(getState())).toBe(undefined); + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const selected = getSelectedSource(getState()); + expect(selected && selected.url).toBe(baseSource.url); + }); + + // eslint-disable-next-line + it("should not attempt to fetch original sources if it's missing a source map url", async () => { + const loadSourceMap = jest.fn(); + const { dispatch } = createStore( + mockCommandClient, + {}, + { + loadSourceMap, + getOriginalLocations: async items => items, + getOriginalLocation: location => location, + } + ); + + await dispatch(actions.newGeneratedSource(makeSource("base.js"))); + expect(loadSourceMap).not.toHaveBeenCalled(); + }); + + // eslint-disable-next-line + it("should process new sources immediately, without waiting for source maps to be fetched first", async () => { + const { dispatch, getState } = createStore( + mockCommandClient, + {}, + { + loadSourceMap: async () => new Promise(_ => {}), + getOriginalLocations: async items => items, + getOriginalLocation: location => location, + } + ); + await dispatch( + actions.newGeneratedSource( + makeSource("base.js", { sourceMapURL: "base.js.map" }) + ) + ); + expect(getSourceCount(getState())).toEqual(1); + const base = getSource(getState(), "base.js"); + expect(base && base.id).toEqual("base.js"); + }); +}); diff --git a/devtools/client/debugger/src/actions/sources/tests/select.spec.js b/devtools/client/debugger/src/actions/sources/tests/select.spec.js new file mode 100644 index 0000000000..5f4feba6c0 --- /dev/null +++ b/devtools/client/debugger/src/actions/sources/tests/select.spec.js @@ -0,0 +1,195 @@ +/* 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/>. */ + +import { + actions, + selectors, + createStore, + createSourceObject, + makeSource, + makeSourceURL, + waitForState, + makeOriginalSource, +} from "../../../utils/test-head"; +import { + getSourceCount, + getSelectedSource, + getSourceTabs, + getSelectedLocation, + getSymbols, +} from "../../../selectors/"; +import { createLocation } from "../../../utils/location"; + +import { mockCommandClient } from "../../tests/helpers/mockCommandClient"; + +process.on("unhandledRejection", (reason, p) => {}); + +function initialLocation(sourceId) { + return createLocation({ source: createSourceObject(sourceId), line: 1 }); +} + +describe("sources", () => { + it("should open a tab for the source", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.selectLocation(initialLocation("foo.js"))); + + const tabs = getSourceTabs(getState()); + expect(tabs).toHaveLength(1); + expect(tabs[0].url).toEqual("http://localhost:8000/examples/foo.js"); + }); + + it("should keep the selected source when other tab closed", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + await dispatch(actions.newGeneratedSource(makeSource("foo.js"))); + await dispatch(actions.newGeneratedSource(makeSource("bar.js"))); + const bazSource = await dispatch( + actions.newGeneratedSource(makeSource("baz.js")) + ); + + // 3rd tab + await dispatch(actions.selectLocation(initialLocation("foo.js"))); + + // 2nd tab + await dispatch(actions.selectLocation(initialLocation("bar.js"))); + + // 1st tab + await dispatch(actions.selectLocation(initialLocation("baz.js"))); + + // 3rd tab is reselected + await dispatch(actions.selectLocation(initialLocation("foo.js"))); + await dispatch(actions.closeTab(bazSource)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe("foo.js"); + expect(getSourceTabs(getState())).toHaveLength(2); + }); + + it("should not select new sources that lack a URL", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + + await dispatch( + actions.newGeneratedSource({ + ...makeSource("foo"), + url: "", + }) + ); + + expect(getSourceCount(getState())).toEqual(1); + const selectedLocation = getSelectedLocation(getState()); + expect(selectedLocation).toEqual(undefined); + }); + + it("sets and clears selected location correctly", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const source = await dispatch( + actions.newGeneratedSource(makeSource("testSource")) + ); + const location = createLocation({ source }); + + // set value + dispatch(actions.setSelectedLocation(location)); + expect(getSelectedLocation(getState())).toEqual({ + ...location, + }); + + // clear value + dispatch(actions.clearSelectedLocation()); + expect(getSelectedLocation(getState())).toEqual(null); + }); + + it("sets and clears pending selected location correctly", () => { + const { dispatch, getState } = createStore(mockCommandClient); + const url = "testURL"; + const options = { line: "testLine", column: "testColumn" }; + + // set value + dispatch(actions.setPendingSelectedLocation(url, options)); + const setResult = getState().sources.pendingSelectedLocation; + expect(setResult).toEqual({ + url, + line: options.line, + column: options.column, + }); + + // clear value + dispatch(actions.clearSelectedLocation()); + const clearResult = getState().sources.pendingSelectedLocation; + expect(clearResult).toEqual({ url: "" }); + }); + + it("should keep the generated the viewing context", async () => { + const store = createStore(mockCommandClient); + const { dispatch, getState } = store; + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + baseSource.id + ); + + const location = createLocation({ + source: baseSource, + line: 1, + sourceActor, + }); + await dispatch(actions.selectLocation(location)); + + const selected = getSelectedSource(getState()); + expect(selected && selected.id).toBe(baseSource.id); + await waitForState(store, state => getSymbols(state, location)); + }); + + it("should change the original the viewing context", async () => { + const { dispatch, getState } = createStore( + mockCommandClient, + {}, + { + getOriginalLocation: async location => ({ ...location, line: 12 }), + getOriginalLocations: async items => items, + getGeneratedRangesForOriginal: async () => [], + getOriginalSourceText: async () => ({ text: "" }), + } + ); + + const baseGenSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const baseSources = await dispatch( + actions.newOriginalSources([makeOriginalSource(baseGenSource)]) + ); + await dispatch(actions.selectSource(baseSources[0])); + + await dispatch( + actions.selectSpecificLocation( + createLocation({ + source: baseSources[0], + line: 1, + }) + ) + ); + + const selected = getSelectedLocation(getState()); + expect(selected && selected.line).toBe(1); + }); + + describe("selectSourceURL", () => { + it("should automatically select a pending source", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const baseSourceURL = makeSourceURL("base.js"); + await dispatch(actions.selectSourceURL(baseSourceURL)); + + expect(getSelectedSource(getState())).toBe(undefined); + const baseSource = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + + const selected = getSelectedSource(getState()); + expect(selected && selected.url).toBe(baseSource.url); + }); + }); +}); diff --git a/devtools/client/debugger/src/actions/tabs.js b/devtools/client/debugger/src/actions/tabs.js new file mode 100644 index 0000000000..c397b30919 --- /dev/null +++ b/devtools/client/debugger/src/actions/tabs.js @@ -0,0 +1,104 @@ +/* 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/>. */ + +/** + * Redux actions for the editor tabs + */ + +import { removeDocument } from "../utils/editor/index"; +import { selectSource } from "./sources/index"; + +import { getSelectedLocation, getSourcesForTabs } from "../selectors/index"; + +export function addTab(source, sourceActor) { + return { + type: "ADD_TAB", + source, + sourceActor, + }; +} + +export function moveTab(url, tabIndex) { + return { + type: "MOVE_TAB", + url, + tabIndex, + }; +} + +export function moveTabBySourceId(sourceId, tabIndex) { + return { + type: "MOVE_TAB_BY_SOURCE_ID", + sourceId, + tabIndex, + }; +} + +export function closeTab(source) { + return closeTabs([source]); +} + +export function closeTabs(sources) { + return ({ dispatch, getState }) => { + if (!sources.length) { + return; + } + + for (const source of sources) { + removeDocument(source.id); + } + + // If we are removing the tabs for the selected location, + // we need to select another source + const newSourceToSelect = getNewSourceToSelect(getState(), sources); + + dispatch({ type: "CLOSE_TABS", sources }); + + dispatch(selectSource(newSourceToSelect)); + }; +} + +/** + * Compute the potential new source to select while closing tabs for a given set of sources. + * + * @param {Object} state + * Redux state object. + * @param {Array<Source>} closedTabsSources + * Ordered list of source object for which tabs should be closed. + * Should be a consecutive list of source matching the order of tabs reducer. + */ +function getNewSourceToSelect(state, closedTabsSources) { + const selectedLocation = getSelectedLocation(state); + // Do not try to select any source if none was selected before + if (!selectedLocation) { + return null; + } + // Keep selecting the same source if we aren't removing the currently selected source + if (!closedTabsSources.includes(selectedLocation.source)) { + return selectedLocation.source; + } + const tabsSources = getSourcesForTabs(state); + // Assume that `sources` is a consecutive list of tab's sources + // ordered in the same way as `tabsSources`. + const lastRemovedTabSource = closedTabsSources.at(-1); + const lastRemovedTabIndex = tabsSources.indexOf(lastRemovedTabSource); + if (lastRemovedTabIndex == -1) { + // This is unexpected, do not try to select any source. + return null; + } + // If there is some tabs after the last removed tab, select the first one. + if (lastRemovedTabIndex + 1 < tabsSources.length) { + return tabsSources[lastRemovedTabIndex + 1]; + } + + // If there is some tabs before the first removed tab, select the last one. + const firstRemovedTabIndex = + lastRemovedTabIndex - (closedTabsSources.length - 1); + if (firstRemovedTabIndex > 0) { + return tabsSources[firstRemovedTabIndex - 1]; + } + + // It looks like we removed all the tabs + return null; +} diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap new file mode 100644 index 0000000000..f27eb26f50 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`expressions should get the autocomplete matches for the input 1`] = ` +Array [ + "toLocaleString", + "toSource", + "toString", + "toolbar", + "top", +] +`; diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap new file mode 100644 index 0000000000..4119cbc875 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`initializing when pending breakpoints exist in prefs syncs pending breakpoints 1`] = ` +Object { + "http://localhost:8000/examples/bar.js:5:2": Object { + "astLocation": Object { + "index": 0, + "name": undefined, + "offset": Object { + "line": 5, + }, + }, + "disabled": false, + "generatedLocation": Object { + "column": 2, + "line": 5, + "sourceUrl": "http://localhost:8000/examples/bar.js", + }, + "location": Object { + "column": 2, + "line": 5, + "sourceUrl": "http://localhost:8000/examples/bar.js", + }, + "options": Object { + "condition": null, + "hidden": false, + }, + }, +} +`; diff --git a/devtools/client/debugger/src/actions/tests/expressions.spec.js b/devtools/client/debugger/src/actions/tests/expressions.spec.js new file mode 100644 index 0000000000..c69276fa40 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/expressions.spec.js @@ -0,0 +1,136 @@ +/* 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/>. */ + +import { actions, selectors, createStore } from "../../utils/test-head"; + +const mockThreadFront = { + evaluate: (script, { frameId }) => + new Promise((resolve, reject) => { + if (!frameId) { + resolve("bla"); + } else { + resolve("boo"); + } + }), + evaluateExpressions: (inputs, { frameId }) => + Promise.all( + inputs.map( + input => + new Promise((resolve, reject) => { + if (!frameId) { + resolve("bla"); + } else { + resolve("boo"); + } + }) + ) + ), + getFrameScopes: async () => {}, + getFrames: async () => [], + sourceContents: () => ({ source: "", contentType: "text/javascript" }), + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], + autocomplete: () => { + return new Promise(resolve => { + resolve({ + from: "foo", + matches: ["toLocaleString", "toSource", "toString", "toolbar", "top"], + matchProp: "to", + }); + }); + }, +}; + +describe("expressions", () => { + it("should add an expression", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + + await dispatch(actions.addExpression("foo")); + expect(selectors.getExpressions(getState())).toHaveLength(1); + }); + + it("should not add empty expressions", () => { + const { dispatch, getState } = createStore(mockThreadFront); + + dispatch(actions.addExpression(undefined)); + dispatch(actions.addExpression("")); + expect(selectors.getExpressions(getState())).toHaveLength(0); + }); + + it("should add invalid expressions", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + await dispatch(actions.addExpression("foo#")); + const state = getState(); + expect(selectors.getExpressions(state)).toHaveLength(1); + }); + + it("should update an expression", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + + await dispatch(actions.addExpression("foo")); + const expression = selectors.getExpression(getState(), "foo"); + if (!expression) { + throw new Error("expression must exist"); + } + + await dispatch(actions.updateExpression("bar", expression)); + const bar = selectors.getExpression(getState(), "bar"); + + expect(bar && bar.input).toBe("bar"); + }); + + it("should not update an expression w/ invalid code", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + + await dispatch(actions.addExpression("foo")); + const expression = selectors.getExpression(getState(), "foo"); + if (!expression) { + throw new Error("expression must exist"); + } + await dispatch(actions.updateExpression("#bar", expression)); + expect(selectors.getExpression(getState(), "bar")).toBeUndefined(); + }); + + it("should delete an expression", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + + await dispatch(actions.addExpression("foo")); + await dispatch(actions.addExpression("bar")); + expect(selectors.getExpressions(getState())).toHaveLength(2); + + const expression = selectors.getExpression(getState(), "foo"); + + if (!expression) { + throw new Error("expression must exist"); + } + + const bar = selectors.getExpression(getState(), "bar"); + dispatch(actions.deleteExpression(expression)); + expect(selectors.getExpressions(getState())).toHaveLength(1); + expect(bar && bar.input).toBe("bar"); + }); + + it("should evaluate expressions global scope", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + await dispatch(actions.addExpression("foo")); + await dispatch(actions.addExpression("bar")); + + let foo = selectors.getExpression(getState(), "foo"); + let bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("bla"); + expect(bar && bar.value).toBe("bla"); + + await dispatch(actions.evaluateExpressions(null)); + foo = selectors.getExpression(getState(), "foo"); + bar = selectors.getExpression(getState(), "bar"); + expect(foo && foo.value).toBe("bla"); + expect(bar && bar.value).toBe("bla"); + }); + + it("should get the autocomplete matches for the input", async () => { + const { dispatch, getState } = createStore(mockThreadFront); + await dispatch(actions.autocomplete("to", 2)); + expect(selectors.getAutocompleteMatchset(getState())).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js b/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js new file mode 100644 index 0000000000..84eb83e23f --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/breakpoints.js @@ -0,0 +1,65 @@ +/* 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/>. */ + +import { createLocation } from "../../../utils/location"; + +export function mockPendingBreakpoint(overrides = {}) { + const { sourceUrl, line, column, condition, disabled, hidden } = overrides; + return { + location: { + sourceUrl: sourceUrl || "http://localhost:8000/examples/bar.js", + line: line || 5, + column: column || 1, + }, + generatedLocation: { + sourceUrl: sourceUrl || "http://localhost:8000/examples/bar.js", + line: line || 5, + column: column || 1, + }, + astLocation: { + name: undefined, + offset: { + line: line || 5, + }, + index: 0, + }, + options: { + condition: condition || null, + hidden: hidden || false, + }, + disabled: disabled || false, + }; +} + +export function generateBreakpoint(filename, line = 5, column = 0) { + return { + id: "breakpoint", + originalText: "", + text: "", + location: createLocation({ + source: { + url: `http://localhost:8000/examples/${filename}`, + id: filename, + }, + sourceId: filename, + line, + column, + }), + generatedLocation: createLocation({ + source: { + url: `http://localhost:8000/examples/${filename}`, + id: filename, + }, + sourceId: filename, + line, + column, + }), + astLocation: undefined, + options: { + condition: "", + hidden: false, + }, + disabled: false, + }; +} diff --git a/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js b/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js new file mode 100644 index 0000000000..38dd55c274 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.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/>. */ + +export function createSource(name, code) { + name = name.replace(/\..*$/, ""); + return { + source: code || `function ${name}() {\n return ${name} \n}`, + contentType: "text/javascript", + }; +} + +const sources = [ + "a", + "b", + "foo", + "bar", + "foo1", + "foo2", + "a.js", + "baz.js", + "foobar.js", + "barfoo.js", + "foo.js", + "bar.js", + "base.js", + "bazz.js", + "jquery.js", +]; + +export const mockCommandClient = { + sourceContents: function ({ source }) { + return new Promise((resolve, reject) => { + if (sources.includes(source)) { + resolve(createSource(source)); + } + + reject(`unknown source: ${source}`); + }); + }, + setBreakpoint: async () => {}, + removeBreakpoint: _id => Promise.resolve(), + threadFront: async () => {}, + getFrameScopes: async () => {}, + getFrames: async () => [], + evaluateExpressions: async () => {}, + getSourceActorBreakpointPositions: async () => ({}), + getSourceActorBreakableLines: async () => [], +}; diff --git a/devtools/client/debugger/src/actions/tests/helpers/readFixture.js b/devtools/client/debugger/src/actions/tests/helpers/readFixture.js new file mode 100644 index 0000000000..6c23641226 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/helpers/readFixture.js @@ -0,0 +1,14 @@ +/* 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/>. */ + +import fs from "fs"; +import path from "path"; + +export default function readFixture(name) { + const text = fs.readFileSync( + path.join(__dirname, `../fixtures/${name}`), + "utf8" + ); + return text; +} diff --git a/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js new file mode 100644 index 0000000000..c51ad7e6e5 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js @@ -0,0 +1,251 @@ +/* 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/>. */ + +// TODO: we would like to mock this in the local tests +import { + generateBreakpoint, + mockPendingBreakpoint, +} from "./helpers/breakpoints.js"; + +import { mockCommandClient } from "./helpers/mockCommandClient"; +import { asyncStore } from "../../utils/prefs"; + +function loadInitialState(opts = {}) { + const mockedPendingBreakpoint = mockPendingBreakpoint({ ...opts, column: 2 }); + const l = mockedPendingBreakpoint.location; + const id = `${l.sourceUrl}:${l.line}:${l.column}`; + asyncStore.pendingBreakpoints = { [id]: mockedPendingBreakpoint }; + + return { pendingBreakpoints: asyncStore.pendingBreakpoints }; +} + +jest.mock("../../utils/prefs", () => ({ + prefs: { + clientSourceMapsEnabled: true, + expressions: [], + }, + asyncStore: { + pendingBreakpoints: {}, + }, + clear: jest.fn(), + features: { + inlinePreview: true, + }, +})); + +import { + createStore, + selectors, + actions, + makeSource, + waitForState, +} from "../../utils/test-head"; + +import sourceMapLoader from "devtools/client/shared/source-map-loader/index"; + +function mockClient(bpPos = {}) { + return { + ...mockCommandClient, + setSkipPausing: jest.fn(), + getSourceActorBreakpointPositions: async () => bpPos, + getSourceActorBreakableLines: async () => [], + }; +} + +function mockSourceMaps() { + return { + ...sourceMapLoader, + getOriginalSourceText: async id => ({ + id, + text: "", + contentType: "text/javascript", + }), + getGeneratedRangesForOriginal: async () => [ + { start: { line: 0, column: 0 }, end: { line: 10, column: 10 } }, + ], + getOriginalLocations: async items => items, + }; +} + +describe("when adding breakpoints", () => { + it("a corresponding pending breakpoint should be added", async () => { + const { dispatch, getState } = createStore( + mockClient({ 5: [1] }), + loadInitialState(), + mockSourceMaps() + ); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + const bp = generateBreakpoint("foo.js", 5, 1); + + await dispatch(actions.addBreakpoint(bp.location)); + + expect(selectors.getPendingBreakpointList(getState())).toHaveLength(2); + }); +}); + +describe("initializing when pending breakpoints exist in prefs", () => { + it("syncs pending breakpoints", async () => { + const { getState } = createStore( + mockClient({ 5: [0] }), + loadInitialState(), + mockSourceMaps() + ); + const bps = selectors.getPendingBreakpoints(getState()); + expect(bps).toMatchSnapshot(); + }); + + it("re-adding breakpoints update existing pending breakpoints", async () => { + const { dispatch, getState } = createStore( + mockClient({ 5: [1, 2] }), + loadInitialState(), + mockSourceMaps() + ); + const bar = generateBreakpoint("bar.js", 5, 1); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + await dispatch(actions.addBreakpoint(bar.location)); + + const bps = selectors.getPendingBreakpointList(getState()); + expect(bps).toHaveLength(2); + }); + + it("adding bps doesn't remove existing pending breakpoints", async () => { + const { dispatch, getState } = createStore( + mockClient({ 5: [0] }), + loadInitialState(), + mockSourceMaps() + ); + const bp = generateBreakpoint("foo.js"); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("foo.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + await dispatch(actions.addBreakpoint(bp.location)); + + const bps = selectors.getPendingBreakpointList(getState()); + expect(bps).toHaveLength(2); + }); +}); + +describe("initializing with disabled pending breakpoints in prefs", () => { + it("syncs breakpoints with pending breakpoints", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + + const { getState, dispatch } = store; + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + await waitForState(store, state => { + const bps = selectors.getBreakpointsForSource(state, source); + return bps && !!Object.values(bps).length; + }); + + const bp = selectors.getBreakpointsList(getState()).find(({ location }) => { + return ( + location.line == 5 && + location.column == 2 && + location.source.id == source.id + ); + }); + + if (!bp) { + throw new Error("no bp"); + } + expect(bp.location.source.id).toEqual(source.id); + expect(bp.disabled).toEqual(true); + }); +}); + +describe("adding sources", () => { + it("corresponding breakpoints are added for a single source", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + const { getState, dispatch } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + const source = await dispatch( + actions.newGeneratedSource(makeSource("bar.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source.id + ); + + await dispatch(actions.loadGeneratedSourceText(sourceActor)); + + await waitForState(store, state => selectors.getBreakpointCount(state) > 0); + + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); + + it("add corresponding breakpoints for multiple sources", async () => { + const store = createStore( + mockClient({ 5: [2] }), + loadInitialState({ disabled: true }), + mockSourceMaps() + ); + const { getState, dispatch } = store; + + expect(selectors.getBreakpointCount(getState())).toEqual(0); + + const [source1, source2] = await dispatch( + actions.newGeneratedSources([makeSource("bar.js"), makeSource("foo.js")]) + ); + const sourceActor1 = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source1.id + ); + const sourceActor2 = selectors.getFirstSourceActorForGeneratedSource( + getState(), + source2.id + ); + + await dispatch(actions.loadGeneratedSourceText(sourceActor1)); + await dispatch(actions.loadGeneratedSourceText(sourceActor2)); + + await waitForState(store, state => selectors.getBreakpointCount(state) > 0); + expect(selectors.getBreakpointCount(getState())).toEqual(1); + }); +}); diff --git a/devtools/client/debugger/src/actions/tests/ui.spec.js b/devtools/client/debugger/src/actions/tests/ui.spec.js new file mode 100644 index 0000000000..712fac4996 --- /dev/null +++ b/devtools/client/debugger/src/actions/tests/ui.spec.js @@ -0,0 +1,88 @@ +/* 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/>. */ + +import { + createStore, + selectors, + actions, + makeSource, +} from "../../utils/test-head"; +import { createLocation } from "../../utils/location"; +import { mockCommandClient } from "./helpers/mockCommandClient"; + +const { + getActiveSearch, + getFrameworkGroupingState, + getPaneCollapse, + getHighlightedLineRangeForSelectedSource, +} = selectors; + +describe("ui", () => { + it("should toggle the visible state of file search", () => { + const { dispatch, getState } = createStore(); + expect(getActiveSearch(getState())).toBe(null); + dispatch(actions.setActiveSearch("file")); + expect(getActiveSearch(getState())).toBe("file"); + }); + + it("should close file search", () => { + const { dispatch, getState } = createStore(); + expect(getActiveSearch(getState())).toBe(null); + dispatch(actions.setActiveSearch("file")); + dispatch(actions.closeActiveSearch()); + expect(getActiveSearch(getState())).toBe(null); + }); + + it("should toggle the collapse state of a pane", () => { + const { dispatch, getState } = createStore(); + expect(getPaneCollapse(getState(), "start")).toBe(false); + dispatch(actions.togglePaneCollapse("start", true)); + expect(getPaneCollapse(getState(), "start")).toBe(true); + }); + + it("should toggle the collapsed state of frameworks in the callstack", () => { + const { dispatch, getState } = createStore(); + const currentState = getFrameworkGroupingState(getState()); + dispatch(actions.toggleFrameworkGrouping(!currentState)); + expect(getFrameworkGroupingState(getState())).toBe(!currentState); + }); + + it("should highlight lines", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + //await dispatch(actions.selectSource(base, sourceActor)); + const location = createLocation({ + source: base, + line: 1, + sourceActor, + }); + await dispatch(actions.selectLocation(location)); + + const range = { start: 3, end: 5, sourceId: base.id }; + dispatch(actions.highlightLineRange(range)); + expect(getHighlightedLineRangeForSelectedSource(getState())).toEqual(range); + }); + + it("should clear highlight lines", async () => { + const { dispatch, getState } = createStore(mockCommandClient); + const base = await dispatch( + actions.newGeneratedSource(makeSource("base.js")) + ); + const sourceActor = selectors.getFirstSourceActorForGeneratedSource( + getState(), + base.id + ); + await dispatch(actions.selectSource(base, sourceActor)); + const range = { start: 3, end: 5, sourceId: "2" }; + dispatch(actions.highlightLineRange(range)); + dispatch(actions.clearHighlightLineRange()); + expect(getHighlightedLineRangeForSelectedSource(getState())).toEqual(null); + }); +}); diff --git a/devtools/client/debugger/src/actions/threads.js b/devtools/client/debugger/src/actions/threads.js new file mode 100644 index 0000000000..82132a144c --- /dev/null +++ b/devtools/client/debugger/src/actions/threads.js @@ -0,0 +1,58 @@ +/* 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/>. */ + +import { createThread } from "../client/firefox/create"; +import { getSourcesToRemoveForThread } from "../selectors/index"; +import { clearDocumentsForSources } from "../utils/editor/source-documents"; + +export function addTarget(targetFront) { + return { type: "INSERT_THREAD", newThread: createThread(targetFront) }; +} + +export function removeTarget(targetFront) { + return ({ getState, dispatch, parserWorker }) => { + const threadActorID = targetFront.targetForm.threadActor; + + // Just before emitting the REMOVE_THREAD action, + // synchronously compute the list of source and source actor objects + // which should be removed as that one target get removed. + // + // The list of source objects isn't trivial to compute as these objects + // are shared across targets/threads. + const { actors, sources } = getSourcesToRemoveForThread( + getState(), + threadActorID + ); + + // CodeMirror documents aren't stored in redux reducer, + // so we need this manual function call in order to ensure clearing them. + clearDocumentsForSources(sources); + + // Notify the reducers that a target/thread is being removed + // and that all related resources should be cleared. + // This action receives the list of related source actors and source objects + // related to that to-be-removed target. + // This will be fired on navigation for all existing targets. + // That except the top target, when pausing on unload, where the top target may still hold longer. + // Also except for service worker targets, which may be kept alive. + dispatch({ + type: "REMOVE_THREAD", + threadActorID, + actors, + sources, + }); + + parserWorker.clearSources(sources.map(source => source.id)); + }; +} + +export function toggleJavaScriptEnabled(enabled) { + return async ({ dispatch, client }) => { + await client.toggleJavaScriptEnabled(enabled); + dispatch({ + type: "TOGGLE_JAVASCRIPT_ENABLED", + value: enabled, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/toolbox.js b/devtools/client/debugger/src/actions/toolbox.js new file mode 100644 index 0000000000..a343c92863 --- /dev/null +++ b/devtools/client/debugger/src/actions/toolbox.js @@ -0,0 +1,43 @@ +/* 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/>. */ + +/** + * @memberof actions/toolbox + * @static + */ +export function openLink(url) { + return async function ({ panel }) { + return panel.openLink(url); + }; +} + +export function evaluateInConsole(inputString) { + return async ({ panel }) => { + return panel.openConsoleAndEvaluate(inputString); + }; +} + +export function openElementInInspectorCommand(grip) { + return async ({ panel }) => { + return panel.openElementInInspector(grip); + }; +} + +export function openInspector(grip) { + return async ({ panel }) => { + return panel.openInspector(); + }; +} + +export function highlightDomElement(grip) { + return async ({ panel }) => { + return panel.highlightDomElement(grip); + }; +} + +export function unHighlightDomElement(grip) { + return async ({ panel }) => { + return panel.unHighlightDomElement(grip); + }; +} diff --git a/devtools/client/debugger/src/actions/tracing.js b/devtools/client/debugger/src/actions/tracing.js new file mode 100644 index 0000000000..1a90dcfa0a --- /dev/null +++ b/devtools/client/debugger/src/actions/tracing.js @@ -0,0 +1,44 @@ +/* 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/>. */ + +import { + getIsJavascriptTracingEnabled, + getJavascriptTracingLogMethod, +} from "../selectors/index"; +import { PROMISE } from "./utils/middleware/promise"; + +/** + * Toggle ON/OFF Javascript tracing for all targets. + */ +export function toggleTracing() { + return async ({ dispatch, getState, client, panel }) => { + // For now, the UI can only toggle all the targets all at once. + const isTracingEnabled = getIsJavascriptTracingEnabled(getState()); + const logMethod = getJavascriptTracingLogMethod(getState()); + + // Automatically open the split console when enabling tracing to the console + if (!isTracingEnabled && logMethod == "console") { + await panel.toolbox.openSplitConsole({ focusConsoleInput: false }); + } + + return dispatch({ + type: "TOGGLE_TRACING", + [PROMISE]: client.toggleTracing(), + enabled: !isTracingEnabled, + }); + }; +} + +/** + * Called when tracing is toggled ON/OFF on a particular thread. + */ +export function tracingToggled(thread, enabled) { + return ({ dispatch }) => { + dispatch({ + type: "TRACING_TOGGLED", + thread, + enabled, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/ui.js b/devtools/client/debugger/src/actions/ui.js new file mode 100644 index 0000000000..2424c658b8 --- /dev/null +++ b/devtools/client/debugger/src/actions/ui.js @@ -0,0 +1,282 @@ +/* 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/>. */ + +import { + getActiveSearch, + getPaneCollapse, + getQuickOpenEnabled, + getSource, + getSourceTextContent, + getIgnoreListSourceUrls, + getSourceByURL, + getBreakpointsForSource, +} from "../selectors/index"; +import { selectSource } from "../actions/sources/select"; +import { + getEditor, + getLocationsInViewport, + updateDocuments, +} from "../utils/editor/index"; +import { blackboxSourceActorsForSource } from "./sources/blackbox"; +import { toggleBreakpoints } from "./breakpoints/index"; +import { copyToTheClipboard } from "../utils/clipboard"; +import { isFulfilled } from "../utils/async-value"; +import { primaryPaneTabs } from "../constants"; + +export function setPrimaryPaneTab(tabName) { + return { type: "SET_PRIMARY_PANE_TAB", tabName }; +} + +export function closeActiveSearch() { + return { + type: "TOGGLE_ACTIVE_SEARCH", + value: null, + }; +} + +export function setActiveSearch(activeSearch) { + return ({ dispatch, getState }) => { + const activeSearchState = getActiveSearch(getState()); + if (activeSearchState === activeSearch) { + return; + } + + if (getQuickOpenEnabled(getState())) { + dispatch({ type: "CLOSE_QUICK_OPEN" }); + } + + // Open start panel if it was collapsed so the project search UI is visible + if ( + activeSearch === primaryPaneTabs.PROJECT_SEARCH && + getPaneCollapse(getState(), "start") + ) { + dispatch({ + type: "TOGGLE_PANE", + position: "start", + paneCollapsed: false, + }); + } + + dispatch({ + type: "TOGGLE_ACTIVE_SEARCH", + value: activeSearch, + }); + }; +} + +export function toggleFrameworkGrouping(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_FRAMEWORK_GROUPING", + value: toggleValue, + }); + }; +} + +export function toggleInlinePreview(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_INLINE_PREVIEW", + value: toggleValue, + }); + }; +} + +export function toggleEditorWrapping(toggleValue) { + return ({ dispatch, getState }) => { + updateDocuments(doc => doc.cm.setOption("lineWrapping", toggleValue)); + + dispatch({ + type: "TOGGLE_EDITOR_WRAPPING", + value: toggleValue, + }); + }; +} + +export function toggleSourceMapsEnabled(toggleValue) { + return ({ dispatch, getState }) => { + dispatch({ + type: "TOGGLE_SOURCE_MAPS_ENABLED", + value: toggleValue, + }); + }; +} + +export function showSource(sourceId) { + return ({ dispatch, getState }) => { + const source = getSource(getState(), sourceId); + if (!source) { + return; + } + + if (getPaneCollapse(getState(), "start")) { + dispatch({ + type: "TOGGLE_PANE", + position: "start", + paneCollapsed: false, + }); + } + + dispatch(setPrimaryPaneTab("sources")); + + dispatch(selectSource(source)); + }; +} + +export function togglePaneCollapse(position, paneCollapsed) { + return ({ dispatch, getState }) => { + const prevPaneCollapse = getPaneCollapse(getState(), position); + if (prevPaneCollapse === paneCollapsed) { + return; + } + + // Set active search to null when closing start panel if project search was active + if ( + position === "start" && + paneCollapsed && + getActiveSearch(getState()) === primaryPaneTabs.PROJECT_SEARCH + ) { + dispatch(closeActiveSearch()); + } + + dispatch({ + type: "TOGGLE_PANE", + position, + paneCollapsed, + }); + }; +} + +/** + * Highlight one or many lines in CodeMirror for a given source. + * + * @param {Object} location + * @param {String} location.sourceId + * The precise source to highlight. + * @param {Number} location.start + * The 1-based index of first line to highlight. + * @param {Number} location.end + * The 1-based index of last line to highlight. + */ +export function highlightLineRange(location) { + return { + type: "HIGHLIGHT_LINES", + location, + }; +} + +export function flashLineRange(location) { + return ({ dispatch }) => { + dispatch(highlightLineRange(location)); + setTimeout(() => dispatch(clearHighlightLineRange()), 200); + }; +} + +export function clearHighlightLineRange() { + return { + type: "CLEAR_HIGHLIGHT_LINES", + }; +} + +export function openConditionalPanel(location, log = false) { + if (!location) { + return null; + } + + return { + type: "OPEN_CONDITIONAL_PANEL", + location, + log, + }; +} + +export function closeConditionalPanel() { + return { + type: "CLOSE_CONDITIONAL_PANEL", + }; +} + +export function updateViewport() { + return { + type: "SET_VIEWPORT", + viewport: getLocationsInViewport(getEditor()), + }; +} + +export function updateCursorPosition(cursorPosition) { + return { type: "SET_CURSOR_POSITION", cursorPosition }; +} + +export function setOrientation(orientation) { + return { type: "SET_ORIENTATION", orientation }; +} + +export function setSearchOptions(searchKey, searchOptions) { + return { type: "SET_SEARCH_OPTIONS", searchKey, searchOptions }; +} + +export function copyToClipboard(location) { + return ({ dispatch, getState }) => { + const content = getSourceTextContent(getState(), location); + if (content && isFulfilled(content) && content.value.type === "text") { + copyToTheClipboard(content.value.value); + } + }; +} + +export function setJavascriptTracingLogMethod(value) { + return { + type: "SET_JAVASCRIPT_TRACING_LOG_METHOD", + value, + }; +} + +export function toggleJavascriptTracingValues() { + return { + type: "TOGGLE_JAVASCRIPT_TRACING_VALUES", + }; +} + +export function toggleJavascriptTracingOnNextInteraction() { + return { + type: "TOGGLE_JAVASCRIPT_TRACING_ON_NEXT_INTERACTION", + }; +} + +export function toggleJavascriptTracingFunctionReturn() { + return { + type: "TOGGLE_JAVASCRIPT_TRACING_FUNCTION_RETURN", + }; +} + +export function toggleJavascriptTracingOnNextLoad() { + return { + type: "TOGGLE_JAVASCRIPT_TRACING_ON_NEXT_LOAD", + }; +} + +export function setHideOrShowIgnoredSources(shouldHide) { + return ({ dispatch, getState }) => { + dispatch({ type: "HIDE_IGNORED_SOURCES", shouldHide }); + }; +} + +export function toggleSourceMapIgnoreList(shouldEnable) { + return async thunkArgs => { + const { dispatch, getState } = thunkArgs; + const ignoreListSourceUrls = getIgnoreListSourceUrls(getState()); + // Blackbox the source actors on the server + for (const url of ignoreListSourceUrls) { + const source = getSourceByURL(getState(), url); + await blackboxSourceActorsForSource(thunkArgs, source, shouldEnable); + // Disable breakpoints in sources on the ignore list + const breakpoints = getBreakpointsForSource(getState(), source); + await dispatch(toggleBreakpoints(shouldEnable, breakpoints)); + } + await dispatch({ + type: "ENABLE_SOURCEMAP_IGNORELIST", + shouldEnable, + }); + }; +} diff --git a/devtools/client/debugger/src/actions/utils/create-store.js b/devtools/client/debugger/src/actions/utils/create-store.js new file mode 100644 index 0000000000..5de4fa74d0 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/create-store.js @@ -0,0 +1,75 @@ +/* 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/>. */ + +/* global window */ + +/** + * Redux store utils + * @module utils/create-store + */ + +import { + createStore, + applyMiddleware, +} from "devtools/client/shared/vendor/redux"; +import { waitUntilService } from "./middleware/wait-service"; +import { log } from "./middleware/log"; +import { promise } from "./middleware/promise"; +import { thunk } from "./middleware/thunk"; +import { timing } from "./middleware/timing"; +import { context } from "./middleware/context"; + +/** + * @memberof utils/create-store + * @static + */ + +/** + * This creates a dispatcher with all the standard middleware in place + * that all code requires. It can also be optionally configured in + * various ways, such as logging and recording. + * + * @param {object} opts: + * - log: log all dispatched actions to console + * - history: an array to store every action in. Should only be + * used in tests. + * - middleware: array of middleware to be included in the redux store + * @memberof utils/create-store + * @static + */ +const configureStore = (opts = {}) => { + const middleware = [ + thunk(opts.makeThunkArgs), + context, + promise, + + // Order is important: services must go last as they always + // operate on "already transformed" actions. Actions going through + // them shouldn't have any special fields like promises, they + // should just be normal JSON objects. + waitUntilService, + ]; + + if (opts.middleware) { + opts.middleware.forEach(fn => middleware.push(fn)); + } + + if (opts.log) { + middleware.push(log); + } + + if (opts.timing) { + middleware.push(timing); + } + + // Hook in the redux devtools browser extension if it exists + const devtoolsExt = + typeof window === "object" && window.devToolsExtension + ? window.devToolsExtension() + : f => f; + + return applyMiddleware(...middleware)(devtoolsExt(createStore)); +}; + +export default configureStore; diff --git a/devtools/client/debugger/src/actions/utils/middleware/context.js b/devtools/client/debugger/src/actions/utils/middleware/context.js new file mode 100644 index 0000000000..00711a8c3f --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/context.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/>. */ + +import { + validateNavigateContext, + validateContext, + validateSelectedFrame, + validateBreakpoint, + validateSource, + validateSourceActor, + validateThreadFrames, + validateFrame, +} from "../../../utils/context"; + +function validateActionContext(getState, action) { + if (action.type == "COMMAND" && action.status == "done") { + // The thread will have resumed execution since the action was initiated, + // so just make sure we haven't navigated. + validateNavigateContext(getState(), action.cx); + return; + } + + // Validate using all available information in the context. + validateContext(getState(), action.cx); +} + +// Middleware which looks for actions that have a cx property and ignores +// them if the context is no longer valid. +function context({ dispatch, getState }) { + return next => action => { + if ("cx" in action) { + validateActionContext(getState, action); + } + + // Validate actions specific to a Source object. + // This will throw if the source has been removed, + // i.e. when the source has been removed from all the threads where it existed. + if ("source" in action) { + validateSource(getState(), action.source); + } + + // Validate actions specific to a Source Actor object. + // This will throw if the source actor has been removed, + // i.e. when the source actor's thread has been removed. + if ("sourceActor" in action) { + validateSourceActor(getState(), action.sourceActor); + } + + // Similar to sourceActor assertion, but with a distinct attribute name + if ("generatedSourceActor" in action) { + validateSourceActor(getState(), action.generatedSourceActor); + } + + // Validate actions specific to a given breakpoint. + // This will throw if the breakpoint's location is obsolete. + // i.e. when the related source has been removed. + if ("breakpoint" in action) { + validateBreakpoint(getState(), action.breakpoint); + } + + // Validate actions specific to the currently selected paused frame. + // It will throw if we resumed or moved to another frame in the call stack. + // + // Ignore falsy selectedFrame as sometimes it can be null + // for expression actions. + if (action.selectedFrame) { + validateSelectedFrame(getState(), action.selectedFrame); + } + + // Validate actions specific to a given pause location. + // This will throw if we resumed or paused in another location. + // Compared to selected frame, this would not throw if we moved to another frame in the call stack. + if ("thread" in action && "frames" in action) { + validateThreadFrames(getState(), action.thread, action.frames); + } + + // Validate actions specific to a given frame while being paused. + // This will throw if we resumed or paused in another location. + // But compared to selected frame, this would not throw if we moved to another frame in the call stack. + // This ends up being similar to "pause location" case, but with different arguments. + if ("frame" in action) { + validateFrame(getState(), action.frame); + } + + return next(action); + }; +} + +export { context }; diff --git a/devtools/client/debugger/src/actions/utils/middleware/log.js b/devtools/client/debugger/src/actions/utils/middleware/log.js new file mode 100644 index 0000000000..b9592ce22c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/log.js @@ -0,0 +1,111 @@ +/* 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/>. */ + +import flags from "devtools/shared/flags"; +import { prefs } from "../../../utils/prefs"; + +const ignoreList = [ + "ADD_BREAKPOINT_POSITIONS", + "SET_SYMBOLS", + "OUT_OF_SCOPE_LOCATIONS", + "MAP_SCOPES", + "MAP_FRAMES", + "ADD_SCOPES", + "IN_SCOPE_LINES", + "REMOVE_BREAKPOINT", + "NODE_PROPERTIES_LOADED", + "SET_FOCUSED_SOURCE_ITEM", + "NODE_EXPAND", + "IN_SCOPE_LINES", + "SET_PREVIEW", +]; + +function cloneAction(action) { + action = action || {}; + action = { ...action }; + + // ADD_TAB, ... + if (action.source?.text) { + const source = { ...action.source, text: "" }; + action.source = source; + } + + if (action.sources) { + const sources = action.sources.slice(0, 20).map(source => { + const url = !source.url || source.url.includes("data:") ? "" : source.url; + return { ...source, url }; + }); + action.sources = sources; + } + + // LOAD_SOURCE_TEXT + if (action.text) { + action.text = ""; + } + + if (action.value?.text) { + const value = { ...action.value, text: "" }; + action.value = value; + } + + return action; +} + +function formatPause(pause) { + return { + ...pause, + pauseInfo: { why: pause.why }, + scopes: [], + loadedObjects: [], + }; +} + +function serializeAction(action) { + try { + action = cloneAction(action); + if (ignoreList.includes(action.type)) { + action = {}; + } + + if (action.type === "PAUSED") { + action = formatPause(action); + } + + const serializer = function (key, value) { + // Serialize Object/LongString fronts + if (value?.getGrip) { + return value.getGrip(); + } + return value; + }; + + // dump(`> ${action.type}...\n ${JSON.stringify(action, serializer)}\n`); + return JSON.stringify(action, serializer); + } catch (e) { + console.error(e); + return ""; + } +} + +/** + * A middleware that logs all actions coming through the system + * to the console. + */ +export function log({ dispatch, getState }) { + return next => action => { + const asyncMsg = !action.status ? "" : `[${action.status}]`; + + if (prefs.logActions) { + if (flags.testing) { + dump( + `[ACTION] ${action.type} ${asyncMsg} - ${serializeAction(action)}\n` + ); + } else { + console.log(action, asyncMsg); + } + } + + next(action); + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/moz.build b/devtools/client/debugger/src/actions/utils/middleware/moz.build new file mode 100644 index 0000000000..f46a0bb725 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/moz.build @@ -0,0 +1,15 @@ +# 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/. + +DIRS += [] + +CompiledModules( + "context.js", + "log.js", + "promise.js", + "thunk.js", + "timing.js", + "wait-service.js", +) diff --git a/devtools/client/debugger/src/actions/utils/middleware/promise.js b/devtools/client/debugger/src/actions/utils/middleware/promise.js new file mode 100644 index 0000000000..52054a1fcc --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/promise.js @@ -0,0 +1,61 @@ +/* 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/>. */ + +import { executeSoon } from "../../../utils/DevToolsUtils"; + +import { pending, rejected, fulfilled } from "../../../utils/async-value"; +export function asyncActionAsValue(action) { + if (action.status === "start") { + return pending(); + } + if (action.status === "error") { + return rejected(action.error); + } + return fulfilled(action.value); +} + +let seqIdVal = 1; + +function seqIdGen() { + return seqIdVal++; +} + +function promiseMiddleware({ dispatch, getState }) { + return next => action => { + if (!(PROMISE in action)) { + return next(action); + } + + const seqId = seqIdGen().toString(); + const { [PROMISE]: promiseInst, ...originalActionProperties } = action; + + // Create a new action that doesn't have the promise field and has + // the `seqId` field that represents the sequence id + action = { ...originalActionProperties, seqId }; + + dispatch({ ...action, status: "start" }); + + // Return the promise so action creators can still compose if they + // want to. + return Promise.resolve(promiseInst) + .finally(() => new Promise(resolve => executeSoon(resolve))) + .then( + value => { + dispatch({ ...action, status: "done", value: value }); + return value; + }, + error => { + dispatch({ + ...action, + status: "error", + error: error.message || error, + }); + return Promise.reject(error); + } + ); + }; +} + +export const PROMISE = "@@dispatch/promise"; +export { promiseMiddleware as promise }; diff --git a/devtools/client/debugger/src/actions/utils/middleware/thunk.js b/devtools/client/debugger/src/actions/utils/middleware/thunk.js new file mode 100644 index 0000000000..fba17d516c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/thunk.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * A middleware that allows thunks (functions) to be dispatched. If + * it's a thunk, it is called with an argument that contains + * `dispatch`, `getState`, and any additional args passed in via the + * middleware constructure. This allows the action to create multiple + * actions (most likely asynchronously). + */ +export function thunk(makeArgs) { + return ({ dispatch, getState }) => { + const args = { dispatch, getState }; + + return next => action => { + return typeof action === "function" + ? action(makeArgs ? makeArgs(args, getState()) : args) + : next(action); + }; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/timing.js b/devtools/client/debugger/src/actions/utils/middleware/timing.js new file mode 100644 index 0000000000..d0bfa05977 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/timing.js @@ -0,0 +1,26 @@ +/* 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/>. */ + +/** + * Redux middleware that sets performance markers for all actions such that they + * will appear in performance tooling under the User Timing API + */ + +const mark = window.performance?.mark + ? window.performance.mark.bind(window.performance) + : a => {}; + +const measure = window.performance?.measure + ? window.performance.measure.bind(window.performance) + : (a, b, c) => {}; + +export function timing(store) { + return next => action => { + mark(`${action.type}_start`); + const result = next(action); + mark(`${action.type}_end`); + measure(`${action.type}`, `${action.type}_start`, `${action.type}_end`); + return result; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/middleware/wait-service.js b/devtools/client/debugger/src/actions/utils/middleware/wait-service.js new file mode 100644 index 0000000000..337df7e336 --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/middleware/wait-service.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * A middleware which acts like a service, because it is stateful + * and "long-running" in the background. It provides the ability + * for actions to install a function to be run once when a specific + * condition is met by an action coming through the system. Think of + * it as a thunk that blocks until the condition is met. Example: + * + * ```js + * const services = { WAIT_UNTIL: require('wait-service').NAME }; + * + * { type: services.WAIT_UNTIL, + * predicate: action => action.type === "ADD_ITEM", + * run: (dispatch, getState, action) => { + * // Do anything here. You only need to accept the arguments + * // if you need them. `action` is the action that satisfied + * // the predicate. + * } + * } + * ``` + */ +export const NAME = "@@service/waitUntil"; + +export function waitUntilService({ dispatch, getState }) { + let pending = []; + + function checkPending(action) { + const readyRequests = []; + const stillPending = []; + + // Find the pending requests whose predicates are satisfied with + // this action. Wait to run the requests until after we update the + // pending queue because the request handler may synchronously + // dispatch again and run this service (that use case is + // completely valid). + for (const request of pending) { + if (request.predicate(action)) { + readyRequests.push(request); + } else { + stillPending.push(request); + } + } + + pending = stillPending; + for (const request of readyRequests) { + request.run(dispatch, getState, action); + } + } + + return next => action => { + if (action.type === NAME) { + pending.push(action); + return null; + } + const result = next(action); + checkPending(action); + return result; + }; +} diff --git a/devtools/client/debugger/src/actions/utils/moz.build b/devtools/client/debugger/src/actions/utils/moz.build new file mode 100644 index 0000000000..08a43a218c --- /dev/null +++ b/devtools/client/debugger/src/actions/utils/moz.build @@ -0,0 +1,12 @@ +# 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/. + +DIRS += [ + "middleware", +] + +CompiledModules( + "create-store.js", +) |