summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/actions
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/client/debugger/src/actions
parentInitial commit. (diff)
downloadfirefox-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')
-rw-r--r--devtools/client/debugger/src/actions/README.md26
-rw-r--r--devtools/client/debugger/src/actions/ast/index.js5
-rw-r--r--devtools/client/debugger/src/actions/ast/moz.build11
-rw-r--r--devtools/client/debugger/src/actions/ast/setInScopeLines.js98
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js331
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/index.js395
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/modify.js368
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/moz.build13
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js126
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap165
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js476
-rw-r--r--devtools/client/debugger/src/actions/context-menus/breakpoint-heading.js78
-rw-r--r--devtools/client/debugger/src/actions/context-menus/breakpoint.js396
-rw-r--r--devtools/client/debugger/src/actions/context-menus/editor-breakpoint.js273
-rw-r--r--devtools/client/debugger/src/actions/context-menus/editor.js436
-rw-r--r--devtools/client/debugger/src/actions/context-menus/frame.js97
-rw-r--r--devtools/client/debugger/src/actions/context-menus/index.js12
-rw-r--r--devtools/client/debugger/src/actions/context-menus/moz.build16
-rw-r--r--devtools/client/debugger/src/actions/context-menus/outline.js54
-rw-r--r--devtools/client/debugger/src/actions/context-menus/source-tree-item.js281
-rw-r--r--devtools/client/debugger/src/actions/context-menus/tab.js128
-rw-r--r--devtools/client/debugger/src/actions/event-listeners.js77
-rw-r--r--devtools/client/debugger/src/actions/exceptions.js30
-rw-r--r--devtools/client/debugger/src/actions/expressions.js210
-rw-r--r--devtools/client/debugger/src/actions/file-search.js51
-rw-r--r--devtools/client/debugger/src/actions/index.js50
-rw-r--r--devtools/client/debugger/src/actions/moz.build32
-rw-r--r--devtools/client/debugger/src/actions/navigation.js57
-rw-r--r--devtools/client/debugger/src/actions/pause/breakOnNext.js20
-rw-r--r--devtools/client/debugger/src/actions/pause/commands.js147
-rw-r--r--devtools/client/debugger/src/actions/pause/continueToHere.js63
-rw-r--r--devtools/client/debugger/src/actions/pause/expandScopes.js16
-rw-r--r--devtools/client/debugger/src/actions/pause/fetchFrames.js22
-rw-r--r--devtools/client/debugger/src/actions/pause/fetchScopes.js37
-rw-r--r--devtools/client/debugger/src/actions/pause/index.js32
-rw-r--r--devtools/client/debugger/src/actions/pause/inlinePreview.js239
-rw-r--r--devtools/client/debugger/src/actions/pause/mapFrames.js161
-rw-r--r--devtools/client/debugger/src/actions/pause/mapScopes.js201
-rw-r--r--devtools/client/debugger/src/actions/pause/moz.build26
-rw-r--r--devtools/client/debugger/src/actions/pause/pauseOnDebuggerStatement.js17
-rw-r--r--devtools/client/debugger/src/actions/pause/pauseOnExceptions.js34
-rw-r--r--devtools/client/debugger/src/actions/pause/paused.js69
-rw-r--r--devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js18
-rw-r--r--devtools/client/debugger/src/actions/pause/resumed.js31
-rw-r--r--devtools/client/debugger/src/actions/pause/selectFrame.js37
-rw-r--r--devtools/client/debugger/src/actions/pause/skipPausing.js33
-rw-r--r--devtools/client/debugger/src/actions/pause/tests/__snapshots__/pauseOnExceptions.spec.js.snap10
-rw-r--r--devtools/client/debugger/src/actions/pause/tests/pause.spec.js290
-rw-r--r--devtools/client/debugger/src/actions/pause/tests/pauseOnExceptions.spec.js24
-rw-r--r--devtools/client/debugger/src/actions/pause/tests/skipPausing.spec.js18
-rw-r--r--devtools/client/debugger/src/actions/preview.js159
-rw-r--r--devtools/client/debugger/src/actions/project-text-search.js142
-rw-r--r--devtools/client/debugger/src/actions/quick-open.js21
-rw-r--r--devtools/client/debugger/src/actions/source-actors.js12
-rw-r--r--devtools/client/debugger/src/actions/sources-tree.js43
-rw-r--r--devtools/client/debugger/src/actions/sources/blackbox.js211
-rw-r--r--devtools/client/debugger/src/actions/sources/breakableLines.js68
-rw-r--r--devtools/client/debugger/src/actions/sources/index.js40
-rw-r--r--devtools/client/debugger/src/actions/sources/loadSourceText.js252
-rw-r--r--devtools/client/debugger/src/actions/sources/moz.build17
-rw-r--r--devtools/client/debugger/src/actions/sources/newSources.js382
-rw-r--r--devtools/client/debugger/src/actions/sources/prettyPrint.js358
-rw-r--r--devtools/client/debugger/src/actions/sources/select.js368
-rw-r--r--devtools/client/debugger/src/actions/sources/symbols.js62
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js247
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js216
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/newSources.spec.js103
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/select.spec.js195
-rw-r--r--devtools/client/debugger/src/actions/tabs.js104
-rw-r--r--devtools/client/debugger/src/actions/tests/__snapshots__/expressions.spec.js.snap11
-rw-r--r--devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap30
-rw-r--r--devtools/client/debugger/src/actions/tests/expressions.spec.js136
-rw-r--r--devtools/client/debugger/src/actions/tests/helpers/breakpoints.js65
-rw-r--r--devtools/client/debugger/src/actions/tests/helpers/mockCommandClient.js49
-rw-r--r--devtools/client/debugger/src/actions/tests/helpers/readFixture.js14
-rw-r--r--devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js251
-rw-r--r--devtools/client/debugger/src/actions/tests/ui.spec.js88
-rw-r--r--devtools/client/debugger/src/actions/threads.js58
-rw-r--r--devtools/client/debugger/src/actions/toolbox.js43
-rw-r--r--devtools/client/debugger/src/actions/tracing.js44
-rw-r--r--devtools/client/debugger/src/actions/ui.js282
-rw-r--r--devtools/client/debugger/src/actions/utils/create-store.js75
-rw-r--r--devtools/client/debugger/src/actions/utils/middleware/context.js90
-rw-r--r--devtools/client/debugger/src/actions/utils/middleware/log.js111
-rw-r--r--devtools/client/debugger/src/actions/utils/middleware/moz.build15
-rw-r--r--devtools/client/debugger/src/actions/utils/middleware/promise.js61
-rw-r--r--devtools/client/debugger/src/actions/utils/middleware/thunk.js22
-rw-r--r--devtools/client/debugger/src/actions/utils/middleware/timing.js26
-rw-r--r--devtools/client/debugger/src/actions/utils/middleware/wait-service.js62
-rw-r--r--devtools/client/debugger/src/actions/utils/moz.build12
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",
+)