summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/actions
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/debugger/src/actions
parentInitial commit. (diff)
downloadfirefox-esr-upstream.tar.xz
firefox-esr-upstream.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
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.js94
-rw-r--r--devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap16
-rw-r--r--devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js79
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js273
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/index.js426
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/modify.js382
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/moz.build13
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js138
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap173
-rw-r--r--devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js521
-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.js195
-rw-r--r--devtools/client/debugger/src/actions/file-search.js48
-rw-r--r--devtools/client/debugger/src/actions/index.js48
-rw-r--r--devtools/client/debugger/src/actions/moz.build31
-rw-r--r--devtools/client/debugger/src/actions/navigation.js61
-rw-r--r--devtools/client/debugger/src/actions/pause/breakOnNext.js18
-rw-r--r--devtools/client/debugger/src/actions/pause/commands.js157
-rw-r--r--devtools/client/debugger/src/actions/pause/continueToHere.js62
-rw-r--r--devtools/client/debugger/src/actions/pause/expandScopes.js17
-rw-r--r--devtools/client/debugger/src/actions/pause/fetchFrames.js23
-rw-r--r--devtools/client/debugger/src/actions/pause/fetchScopes.js30
-rw-r--r--devtools/client/debugger/src/actions/pause/highlightCalls.js89
-rw-r--r--devtools/client/debugger/src/actions/pause/index.js33
-rw-r--r--devtools/client/debugger/src/actions/pause/inlinePreview.js244
-rw-r--r--devtools/client/debugger/src/actions/pause/mapDisplayNames.js49
-rw-r--r--devtools/client/debugger/src/actions/pause/mapFrames.js157
-rw-r--r--devtools/client/debugger/src/actions/pause/mapScopes.js194
-rw-r--r--devtools/client/debugger/src/actions/pause/moz.build27
-rw-r--r--devtools/client/debugger/src/actions/pause/pauseOnExceptions.js34
-rw-r--r--devtools/client/debugger/src/actions/pause/paused.js73
-rw-r--r--devtools/client/debugger/src/actions/pause/resetBreakpointsPaneState.js18
-rw-r--r--devtools/client/debugger/src/actions/pause/resumed.js28
-rw-r--r--devtools/client/debugger/src/actions/pause/selectFrame.js39
-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.js413
-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.js211
-rw-r--r--devtools/client/debugger/src/actions/project-text-search.js171
-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.js11
-rw-r--r--devtools/client/debugger/src/actions/sources/blackbox.js223
-rw-r--r--devtools/client/debugger/src/actions/sources/breakableLines.js73
-rw-r--r--devtools/client/debugger/src/actions/sources/index.js42
-rw-r--r--devtools/client/debugger/src/actions/sources/loadSourceText.js256
-rw-r--r--devtools/client/debugger/src/actions/sources/moz.build17
-rw-r--r--devtools/client/debugger/src/actions/sources/newSources.js367
-rw-r--r--devtools/client/debugger/src/actions/sources/prettyPrint.js339
-rw-r--r--devtools/client/debugger/src/actions/sources/select.js264
-rw-r--r--devtools/client/debugger/src/actions/sources/symbols.js44
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js249
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js363
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/newSources.spec.js172
-rw-r--r--devtools/client/debugger/src/actions/sources/tests/select.spec.js288
-rw-r--r--devtools/client/debugger/src/actions/tabs.js76
-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.snap44
-rw-r--r--devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap3
-rw-r--r--devtools/client/debugger/src/actions/tests/expressions.spec.js184
-rw-r--r--devtools/client/debugger/src/actions/tests/fixtures/immutable.js2
-rw-r--r--devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js7
-rw-r--r--devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js5
-rw-r--r--devtools/client/debugger/src/actions/tests/fixtures/scopes.js11
-rw-r--r--devtools/client/debugger/src/actions/tests/helpers/breakpoints.js77
-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/navigation.spec.js29
-rw-r--r--devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js294
-rw-r--r--devtools/client/debugger/src/actions/tests/preview.spec.js217
-rw-r--r--devtools/client/debugger/src/actions/tests/sources-tree.spec.js17
-rw-r--r--devtools/client/debugger/src/actions/tests/tabs.spec.js187
-rw-r--r--devtools/client/debugger/src/actions/tests/ui.spec.js90
-rw-r--r--devtools/client/debugger/src/actions/threads.js44
-rw-r--r--devtools/client/debugger/src/actions/toolbox.js43
-rw-r--r--devtools/client/debugger/src/actions/tracing.js49
-rw-r--r--devtools/client/debugger/src/actions/ui.js290
-rw-r--r--devtools/client/debugger/src/actions/utils/create-store.js72
-rw-r--r--devtools/client/debugger/src/actions/utils/middleware/context.js33
-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
92 files changed, 9717 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..a17510a507
--- /dev/null
+++ b/devtools/client/debugger/src/actions/ast/setInScopeLines.js
@@ -0,0 +1,94 @@
+/* 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";
+
+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(
+ cx,
+ location,
+ { dispatch, getState, parserWorker }
+) {
+ const sourceTextContent = getSourceTextContent(getState(), location);
+
+ 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(cx) {
+ return async thunkArgs => {
+ const { getState, dispatch } = thunkArgs;
+ const visibleFrame = getVisibleSelectedFrame(getState());
+
+ if (!visibleFrame) {
+ return;
+ }
+
+ const { location } = visibleFrame;
+ const sourceTextContent = getSourceTextContent(getState(), location);
+
+ if (hasInScopeLines(getState(), location) || !sourceTextContent) {
+ return;
+ }
+
+ const lines = await getInScopeLines(cx, location, thunkArgs);
+
+ dispatch({
+ type: "IN_SCOPE_LINES",
+ cx,
+ location,
+ lines,
+ });
+ };
+}
diff --git a/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap b/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap
new file mode 100644
index 0000000000..1b9befc31b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/ast/tests/__snapshots__/setInScopeLines.spec.js.snap
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`getInScopeLine with selected line 1`] = `
+Array [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ 10,
+ 11,
+ 12,
+]
+`;
diff --git a/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js b/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js
new file mode 100644
index 0000000000..571dd84d6d
--- /dev/null
+++ b/devtools/client/debugger/src/actions/ast/tests/setInScopeLines.spec.js
@@ -0,0 +1,79 @@
+/* eslint max-nested-callbacks: ["error", 6] */
+/* 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 readFixture from "../../tests/helpers/readFixture";
+
+import { makeMockFrame, makeMockSource } from "../../../utils/test-mockup";
+import {
+ createStore,
+ selectors,
+ actions,
+ makeSource,
+ waitForState,
+} from "../../../utils/test-head";
+import { createLocation } from "../../../utils/location";
+
+const { getInScopeLines } = selectors;
+
+const sourceTexts = {
+ "scopes.js": readFixture("scopes.js"),
+};
+
+const mockCommandClient = {
+ sourceContents: async ({ source }) => ({
+ source: sourceTexts[source],
+ contentType: "text/javascript",
+ }),
+ evaluateExpressions: async () => {},
+ getFrameScopes: async () => {},
+ getFrames: async () => [],
+ getSourceActorBreakpointPositions: async () => ({}),
+ getSourceActorBreakableLines: async () => [],
+};
+
+describe("getInScopeLine", () => {
+ it("with selected line", async () => {
+ const client = { ...mockCommandClient };
+ const store = createStore(client);
+ const { dispatch, getState } = store;
+ const source = makeMockSource("scopes.js", "scopes.js");
+ const frame = makeMockFrame("scopes-4", source);
+ client.getFrames = async () => [frame];
+
+ const baseSource = await dispatch(
+ actions.newGeneratedSource(makeSource("scopes.js"))
+ );
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ baseSource.id
+ );
+
+ await dispatch(
+ actions.selectLocation(
+ selectors.getContext(getState()),
+ createLocation({
+ source: baseSource,
+ sourceActor,
+ line: 5,
+ })
+ )
+ );
+
+ await dispatch(
+ actions.paused({
+ thread: "FakeThread",
+ why: { type: "debuggerStatement" },
+ frame,
+ })
+ );
+ await dispatch(actions.setInScopeLines(selectors.getContext(getState())));
+
+ await waitForState(store, state => getInScopeLines(state, frame.location));
+
+ const lines = getInScopeLines(getState(), frame.location);
+
+ expect(lines).toMatchSnapshot();
+ });
+});
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..263b476364
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.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 {
+ isOriginalId,
+ isGeneratedId,
+ originalToGeneratedId,
+} from "devtools/client/shared/source-map-loader/index";
+
+import {
+ getSource,
+ getSourceFromId,
+ getBreakpointPositionsForSource,
+ getSourceActorsForSource,
+} from "../../selectors";
+
+import { makeBreakpointId } from "../../utils/breakpoint";
+import { memoizeableAction } from "../../utils/memoizableAction";
+import { fulfilled } from "../../utils/async-value";
+import {
+ debuggerToSourceMapLocation,
+ sourceMapToDebuggerLocation,
+ createLocation,
+} from "../../utils/location";
+
+async function mapLocations(generatedLocations, { getState, sourceMapLoader }) {
+ if (!generatedLocations.length) {
+ return [];
+ }
+
+ const originalLocations = await sourceMapLoader.getOriginalLocations(
+ generatedLocations.map(debuggerToSourceMapLocation)
+ );
+ return originalLocations.map((location, index) => ({
+ // If location is null, this particular location doesn't map to any original source.
+ location: location
+ ? sourceMapToDebuggerLocation(getState(), location)
+ : generatedLocations[index],
+ generatedLocation: generatedLocations[index],
+ }));
+}
+
+// Filter out positions, that are not in the original source Id
+function filterBySource(positions, sourceId) {
+ if (!isOriginalId(sourceId)) {
+ return positions;
+ }
+ return positions.filter(position => position.location.sourceId == sourceId);
+}
+
+/**
+ * Merge positions that refer to duplicated positions.
+ * Some sourcemaped positions might refer to the exact same source/line/column triple.
+ *
+ * @param {Array<{location, generatedLocation}>} positions: List of possible breakable positions
+ * @returns {Array<{location, generatedLocation}>} A new, filtered array.
+ */
+function filterByUniqLocation(positions) {
+ const handledBreakpointIds = new Set();
+ return positions.filter(({ location }) => {
+ const breakpointId = makeBreakpointId(location);
+ if (handledBreakpointIds.has(breakpointId)) {
+ return false;
+ }
+
+ handledBreakpointIds.add(breakpointId);
+ return true;
+ });
+}
+
+function convertToList(results, source) {
+ const positions = [];
+
+ for (const line in results) {
+ for (const column of results[line]) {
+ positions.push(
+ createLocation({
+ line: Number(line),
+ column,
+ source,
+ sourceUrl: source.url,
+ })
+ );
+ }
+ }
+
+ return positions;
+}
+
+function groupByLine(results, sourceId, line) {
+ const isOriginal = isOriginalId(sourceId);
+ const positions = {};
+
+ // Ensure that we have an entry for the line fetched
+ if (typeof line === "number") {
+ positions[line] = [];
+ }
+
+ for (const result of results) {
+ const location = isOriginal ? result.location : result.generatedLocation;
+
+ if (!positions[location.line]) {
+ positions[location.line] = [];
+ }
+
+ positions[location.line].push(result);
+ }
+
+ return positions;
+}
+
+async function _setBreakpointPositions(cx, location, thunkArgs) {
+ const { client, dispatch, getState, sourceMapLoader } = thunkArgs;
+ const results = {};
+ let generatedSource = location.source;
+ if (isOriginalId(location.sourceId)) {
+ const ranges = await sourceMapLoader.getGeneratedRangesForOriginal(
+ location.sourceId,
+ true
+ );
+ const generatedSourceId = originalToGeneratedId(location.sourceId);
+ 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,
+ };
+ }
+
+ const actorBps = await Promise.all(
+ getSourceActorsForSource(getState(), generatedSourceId).map(actor =>
+ client.getSourceActorBreakpointPositions(actor, range)
+ )
+ );
+
+ for (const actorPositions of actorBps) {
+ for (const rangeLine of Object.keys(actorPositions)) {
+ let columns = actorPositions[parseInt(rangeLine, 10)];
+ const existing = results[rangeLine];
+ if (existing) {
+ columns = [...new Set([...existing, ...columns])];
+ }
+
+ results[rangeLine] = columns;
+ }
+ }
+ }
+ } else {
+ const { line } = location;
+ if (typeof line !== "number") {
+ throw new Error("Line is required for generated sources");
+ }
+
+ const actorColumns = await Promise.all(
+ getSourceActorsForSource(getState(), location.sourceId).map(
+ async actor => {
+ const positions = await client.getSourceActorBreakpointPositions(
+ actor,
+ {
+ start: { line: line, column: 0 },
+ end: { line: line + 1, column: 0 },
+ }
+ );
+ return positions[line] || [];
+ }
+ )
+ );
+
+ for (const columns of actorColumns) {
+ results[line] = (results[line] || []).concat(columns);
+ }
+ }
+
+ let positions = convertToList(results, generatedSource);
+ positions = await mapLocations(positions, thunkArgs);
+
+ positions = filterBySource(positions, location.sourceId);
+ positions = filterByUniqLocation(positions);
+ positions = groupByLine(positions, location.sourceId, location.line);
+
+ const source = getSource(getState(), location.sourceId);
+ // NOTE: it's possible that the source was removed during a navigation
+ if (!source) {
+ return;
+ }
+
+ dispatch({
+ type: "ADD_BREAKPOINT_POSITIONS",
+ cx,
+ source,
+ positions,
+ });
+}
+
+function generatedSourceActorKey(state, sourceId) {
+ const generatedSource = getSource(
+ state,
+ isOriginalId(sourceId) ? originalToGeneratedId(sourceId) : sourceId
+ );
+ const actors = generatedSource
+ ? getSourceActorsForSource(state, generatedSource.id).map(
+ ({ actor }) => actor
+ )
+ : [];
+ return [sourceId, ...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
+ * sourceId: String
+ * sourceUrl: String
+ * }
+ */
+export const setBreakpointPositions = memoizeableAction(
+ "setBreakpointPositions",
+ {
+ getValue: ({ location }, { getState }) => {
+ const positions = getBreakpointPositionsForSource(
+ getState(),
+ location.sourceId
+ );
+ if (!positions) {
+ return null;
+ }
+
+ if (
+ isGeneratedId(location.sourceId) &&
+ 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.sourceId);
+ return isGeneratedId(location.sourceId) && location.line
+ ? `${key}-${location.line}`
+ : key;
+ },
+ action: async ({ cx, location }, thunkArgs) =>
+ _setBreakpointPositions(cx, 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..d188af05dc
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/index.js
@@ -0,0 +1,426 @@
+/* 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";
+import { createXHRBreakpoint } from "../../utils/breakpoint";
+import {
+ addBreakpoint,
+ removeBreakpoint,
+ enableBreakpoint,
+ disableBreakpoint,
+} from "./modify";
+import { getOriginalLocation } from "../../utils/source-maps";
+
+import { isOriginalId } from "devtools/client/shared/source-map-loader/index";
+// this will need to be changed so that addCLientBreakpoint is removed
+
+export * from "./breakpointPositions";
+export * from "./modify";
+export * from "./syncBreakpoint";
+
+export function addHiddenBreakpoint(cx, location) {
+ return ({ dispatch }) => {
+ return dispatch(addBreakpoint(cx, location, { hidden: true }));
+ };
+}
+
+/**
+ * Disable all breakpoints in a source
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function disableBreakpointsInSource(cx, source) {
+ return async ({ dispatch, getState, client }) => {
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ for (const breakpoint of breakpoints) {
+ if (!breakpoint.disabled) {
+ dispatch(disableBreakpoint(cx, breakpoint));
+ }
+ }
+ };
+}
+
+/**
+ * Enable all breakpoints in a source
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function enableBreakpointsInSource(cx, source) {
+ return async ({ dispatch, getState, client }) => {
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ for (const breakpoint of breakpoints) {
+ if (breakpoint.disabled) {
+ dispatch(enableBreakpoint(cx, breakpoint));
+ }
+ }
+ };
+}
+
+/**
+ * Toggle All Breakpoints
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function toggleAllBreakpoints(cx, shouldDisableBreakpoints) {
+ return async ({ dispatch, getState, client }) => {
+ const breakpoints = getBreakpointsList(getState());
+
+ for (const breakpoint of breakpoints) {
+ if (shouldDisableBreakpoints) {
+ dispatch(disableBreakpoint(cx, breakpoint));
+ } else {
+ dispatch(enableBreakpoint(cx, breakpoint));
+ }
+ }
+ };
+}
+
+/**
+ * Toggle Breakpoints
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function toggleBreakpoints(cx, shouldDisableBreakpoints, breakpoints) {
+ return async ({ dispatch }) => {
+ const promises = breakpoints.map(breakpoint =>
+ shouldDisableBreakpoints
+ ? dispatch(disableBreakpoint(cx, breakpoint))
+ : dispatch(enableBreakpoint(cx, breakpoint))
+ );
+
+ await Promise.all(promises);
+ };
+}
+
+export function toggleBreakpointsAtLine(cx, shouldDisableBreakpoints, line) {
+ return async ({ dispatch, getState }) => {
+ const breakpoints = getBreakpointsAtLine(getState(), line);
+ return dispatch(
+ toggleBreakpoints(cx, shouldDisableBreakpoints, breakpoints)
+ );
+ };
+}
+
+/**
+ * Removes all breakpoints
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeAllBreakpoints(cx) {
+ return async ({ dispatch, getState }) => {
+ const breakpointList = getBreakpointsList(getState());
+
+ await Promise.all(
+ breakpointList.map(bp => dispatch(removeBreakpoint(cx, bp)))
+ );
+ dispatch({ type: "CLEAR_BREAKPOINTS" });
+ };
+}
+
+/**
+ * Removes breakpoints
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeBreakpoints(cx, breakpoints) {
+ return async ({ dispatch }) => {
+ return Promise.all(
+ breakpoints.map(bp => dispatch(removeBreakpoint(cx, bp)))
+ );
+ };
+}
+
+/**
+ * Removes all breakpoints in a source
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeBreakpointsInSource(cx, source) {
+ return async ({ dispatch, getState, client }) => {
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ for (const breakpoint of breakpoints) {
+ dispatch(removeBreakpoint(cx, 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 {Objeect} cx
+ * @param {String} sourceId - the generated source id
+ */
+export function updateBreakpointsForNewPrettyPrintedSource(cx, sourceId) {
+ return async thunkArgs => {
+ const { dispatch, getState } = thunkArgs;
+ if (isOriginalId(sourceId)) {
+ console.error("Can't update breakpoints on original sources");
+ return;
+ }
+ const breakpoints = getBreakpointsForSource(getState(), sourceId);
+ // 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(cx, bp));
+ }
+
+ for (const bp of newBreakpoints) {
+ await dispatch(addBreakpoint(cx, bp.location, bp.options, bp.disabled));
+ }
+ };
+}
+
+export function toggleBreakpointAtLine(cx, 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(cx, bp));
+ }
+ return dispatch(
+ addBreakpoint(
+ cx,
+ createLocation({
+ source: selectedSource,
+ sourceUrl: selectedSource.url,
+ line,
+ })
+ )
+ );
+ };
+}
+
+export function addBreakpointAtLine(
+ cx,
+ line,
+ shouldLog = false,
+ disabled = false
+) {
+ return ({ dispatch, getState }) => {
+ const state = getState();
+ const source = getSelectedSource(state);
+
+ if (!source) {
+ return null;
+ }
+ const breakpointLocation = createLocation({
+ source,
+ sourceUrl: source.url,
+ column: undefined,
+ line,
+ });
+
+ const options = {};
+ if (shouldLog) {
+ options.logValue = "displayName";
+ }
+
+ return dispatch(addBreakpoint(cx, breakpointLocation, options, disabled));
+ };
+}
+
+export function removeBreakpointsAtLine(cx, sourceId, line) {
+ return ({ dispatch, getState }) => {
+ const breakpointsAtLine = getBreakpointsForSource(
+ getState(),
+ sourceId,
+ line
+ );
+ return dispatch(removeBreakpoints(cx, breakpointsAtLine));
+ };
+}
+
+export function disableBreakpointsAtLine(cx, sourceId, line) {
+ return ({ dispatch, getState }) => {
+ const breakpointsAtLine = getBreakpointsForSource(
+ getState(),
+ sourceId,
+ line
+ );
+ return dispatch(toggleBreakpoints(cx, true, breakpointsAtLine));
+ };
+}
+
+export function enableBreakpointsAtLine(cx, sourceId, line) {
+ return ({ dispatch, getState }) => {
+ const breakpointsAtLine = getBreakpointsForSource(
+ getState(),
+ sourceId,
+ line
+ );
+ return dispatch(toggleBreakpoints(cx, false, breakpointsAtLine));
+ };
+}
+
+export function toggleDisabledBreakpoint(cx, breakpoint) {
+ return ({ dispatch, getState }) => {
+ if (!breakpoint.disabled) {
+ return dispatch(disableBreakpoint(cx, breakpoint));
+ }
+ return dispatch(enableBreakpoint(cx, 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..4576a61e27
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/modify.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/>. */
+
+import { createBreakpoint } from "../../client/firefox/create";
+import {
+ makeBreakpointServerLocation,
+ makeBreakpointId,
+} from "../../utils/breakpoint";
+import {
+ getBreakpoint,
+ getBreakpointPositionsForLocation,
+ getFirstBreakpointPosition,
+ getSettledSourceTextContent,
+ getBreakpointsList,
+ getPendingBreakpointList,
+ isMapScopesEnabled,
+ getBlackBoxRanges,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+
+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 { validateNavigateContext } 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,
+ cx,
+ { 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(cx, breakpoint));
+ }
+ return client.setBreakpoint(breakpointServerLocation, breakpoint.options);
+}
+
+function clientRemoveBreakpoint(client, state, generatedLocation) {
+ const breakpointServerLocation = makeBreakpointServerLocation(
+ state,
+ generatedLocation
+ );
+ return client.removeBreakpoint(breakpointServerLocation);
+}
+
+export function enableBreakpoint(cx, 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",
+ cx,
+ breakpoint: createBreakpoint({ ...breakpoint, disabled: false }),
+ [PROMISE]: clientSetBreakpoint(client, cx, thunkArgs, breakpoint),
+ });
+ };
+}
+
+export function addBreakpoint(
+ cx,
+ initialLocation,
+ options = {},
+ disabled,
+ shouldCancel = () => false
+) {
+ return async thunkArgs => {
+ const { dispatch, getState, client } = thunkArgs;
+ recordEvent("add_breakpoint");
+
+ await dispatch(
+ setBreakpointPositions({
+ cx,
+ location: 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",
+ cx,
+ 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, cx, thunkArgs, breakpoint),
+ });
+ };
+}
+
+/**
+ * Remove a single breakpoint
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function removeBreakpoint(cx, 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",
+ cx,
+ 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(cx, 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.sourceId == target.sourceId &&
+ comparePosition(generatedLocation, target)
+ ) {
+ dispatch({
+ type: "REMOVE_BREAKPOINT",
+ cx,
+ 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.sourceUrl &&
+ comparePosition(generatedLocation, target)
+ ) {
+ dispatch({
+ type: "REMOVE_PENDING_BREAKPOINT",
+ cx,
+ pendingBreakpoint,
+ });
+ }
+ }
+ return onBreakpointRemoved;
+ };
+}
+
+/**
+ * Disable a single breakpoint
+ *
+ * @memberof actions/breakpoints
+ * @static
+ */
+export function disableBreakpoint(cx, 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",
+ cx,
+ 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(cx, location, options = {}) {
+ return thunkArgs => {
+ const { dispatch, getState, client } = thunkArgs;
+ let breakpoint = getBreakpoint(getState(), location);
+ if (!breakpoint) {
+ return dispatch(addBreakpoint(cx, location, options));
+ }
+
+ // Note: setting a breakpoint's options implicitly enables it.
+ breakpoint = createBreakpoint({ ...breakpoint, disabled: false, options });
+
+ return dispatch({
+ type: "SET_BREAKPOINT",
+ cx,
+ breakpoint,
+ [PROMISE]: clientSetBreakpoint(client, cx, 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(cx, 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
+ );
+ }
+
+ validateNavigateContext(getState(), cx);
+ 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..b52c0ddfb1
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/syncBreakpoint.js
@@ -0,0 +1,138 @@
+/* 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";
+
+import { comparePosition, createLocation } from "../../utils/location";
+
+import {
+ originalToGeneratedId,
+ isOriginalId,
+} from "devtools/client/shared/source-map-loader/index";
+import { getSource } from "../../selectors";
+import { addBreakpoint, removeBreakpointAtGeneratedLocation } from ".";
+
+async function findBreakpointPosition(cx, { getState, dispatch }, location) {
+ const positions = await dispatch(setBreakpointPositions({ cx, 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(cx, sourceId, pendingBreakpoint) {
+ return async thunkArgs => {
+ const { getState, client, dispatch } = thunkArgs;
+
+ const source = getSource(getState(), sourceId);
+
+ const generatedSourceId = isOriginalId(sourceId)
+ ? originalToGeneratedId(sourceId)
+ : sourceId;
+
+ 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(
+ cx,
+ sourceGeneratedLocation,
+ pendingBreakpoint.options,
+ pendingBreakpoint.disabled,
+ () => !client.hasBreakpoint(breakpointServerLocation)
+ )
+ );
+ }
+
+ const originalLocation = createLocation({
+ ...location,
+ source,
+ });
+
+ const newPosition = await findBreakpointPosition(
+ cx,
+ 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(cx, 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(cx, sourceGeneratedLocation)
+ );
+ }
+
+ return dispatch(
+ addBreakpoint(
+ cx,
+ 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..c18c3593d9
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/tests/__snapshots__/breakpoints.spec.js.snap
@@ -0,0 +1,173 @@
+// 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,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ "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,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ "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,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ "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,
+ "sourceId": "a",
+ "sourceUrl": "http://localhost:8000/examples/a",
+ },
+ "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..558d2400a8
--- /dev/null
+++ b/devtools/client/debugger/src/actions/breakpoints/tests/breakpoints.spec.js
@@ -0,0 +1,521 @@
+/* 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, cx } = createStore(mockClient({ 2: [1] }));
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const loc1 = createLocation({
+ source,
+ line: 2,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({
+ source,
+ line: 1,
+ column: 1,
+ })
+ )
+ );
+
+ await dispatch(actions.addBreakpoint(cx, 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, cx } = createStore(mockClient({ 5: [1] }));
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const loc1 = createLocation({
+ source,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({
+ source,
+ line: 1,
+ column: 1,
+ })
+ )
+ );
+
+ await dispatch(actions.addBreakpoint(cx, 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, cx } = createStore(mockClient({ 5: [1] }));
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const loc1 = createLocation({
+ source,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({
+ source,
+ line: 1,
+ column: 1,
+ })
+ )
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ const breakpoint = selectors.getBreakpoint(getState(), loc1);
+ if (!breakpoint) {
+ throw new Error("no breakpoint");
+ }
+
+ await dispatch(actions.disableBreakpoint(cx, 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, cx } = createStore(mockClient({ 5: [1] }));
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const loc1 = createLocation({
+ source,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({
+ source,
+ line: 1,
+ column: 1,
+ })
+ )
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ expect(bp && bp.location).toEqual(loc1);
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ });
+
+ it("should remove a breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ 5: [1], 6: [2] })
+ );
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+
+ const bSource = await dispatch(actions.newGeneratedSource(makeSource("b")));
+
+ const loc1 = createLocation({
+ source: aSource,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+
+ const loc2 = createLocation({
+ source: bSource,
+ line: 6,
+ column: 2,
+ sourceUrl: "http://localhost:8000/examples/b",
+ });
+ const bSourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ bSource.id
+ );
+
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: bSourceActor,
+ })
+ );
+
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({
+ source: aSource,
+ line: 1,
+ column: 1,
+ })
+ )
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ await dispatch(actions.addBreakpoint(cx, loc2));
+
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ if (!bp) {
+ throw new Error("no bp");
+ }
+ await dispatch(actions.removeBreakpoint(cx, bp));
+
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ });
+
+ it("should disable a breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ 5: [1], 6: [2] })
+ );
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const aSourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ aSource.id
+ );
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: aSourceActor,
+ })
+ );
+
+ const bSource = await dispatch(actions.newGeneratedSource(makeSource("b")));
+ const bSourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ bSource.id
+ );
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: bSourceActor,
+ })
+ );
+
+ const loc1 = createLocation({
+ source: aSource,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+
+ const loc2 = createLocation({
+ source: bSource,
+ line: 6,
+ column: 2,
+ sourceUrl: "http://localhost:8000/examples/b",
+ });
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ await dispatch(actions.addBreakpoint(cx, loc2));
+
+ const breakpoint = selectors.getBreakpoint(getState(), loc1);
+ if (!breakpoint) {
+ throw new Error("no breakpoint");
+ }
+
+ await dispatch(actions.disableBreakpoint(cx, breakpoint));
+
+ const bp = selectors.getBreakpoint(getState(), loc1);
+ expect(bp && bp.disabled).toBe(true);
+ });
+
+ it("should enable breakpoint", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ 5: [1], 6: [2] })
+ );
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const loc = createLocation({
+ source: aSource,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+ const aSourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ aSource.id
+ );
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: aSourceActor,
+ })
+ );
+
+ await dispatch(actions.addBreakpoint(cx, loc));
+ let bp = selectors.getBreakpoint(getState(), loc);
+ if (!bp) {
+ throw new Error("no breakpoint");
+ }
+
+ await dispatch(actions.disableBreakpoint(cx, bp));
+
+ bp = selectors.getBreakpoint(getState(), loc);
+ if (!bp) {
+ throw new Error("no breakpoint");
+ }
+
+ expect(bp && bp.disabled).toBe(true);
+
+ await dispatch(actions.enableBreakpoint(cx, bp));
+
+ bp = selectors.getBreakpoint(getState(), loc);
+ expect(bp && !bp.disabled).toBe(true);
+ });
+
+ it("should toggle all the breakpoints", async () => {
+ const { dispatch, getState, cx } = createStore(
+ mockClient({ 5: [1], 6: [2] })
+ );
+
+ const aSource = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const aSourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ aSource.id
+ );
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: aSourceActor,
+ })
+ );
+
+ const bSource = await dispatch(actions.newGeneratedSource(makeSource("b")));
+ const bSourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ bSource.id
+ );
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: bSourceActor,
+ })
+ );
+
+ const loc1 = createLocation({
+ source: aSource,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+
+ const loc2 = createLocation({
+ source: bSource,
+ line: 6,
+ column: 2,
+ sourceUrl: "http://localhost:8000/examples/b",
+ });
+
+ await dispatch(actions.addBreakpoint(cx, loc1));
+ await dispatch(actions.addBreakpoint(cx, loc2));
+
+ await dispatch(actions.toggleAllBreakpoints(cx, 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(cx, 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, cx } = 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(cx, loc));
+
+ await dispatch(actions.toggleBreakpointAtLine(cx, 5));
+ const bp = getBp();
+ expect(bp && !bp.disabled).toBe(true);
+
+ await dispatch(actions.toggleBreakpointAtLine(cx, 5));
+ expect(getBp()).toBe(undefined);
+ });
+
+ it("should disable/enable a breakpoint at a location", async () => {
+ const { dispatch, getState, cx } = 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(cx, createLocation({ source, line: 1 }))
+ );
+
+ await dispatch(actions.toggleBreakpointAtLine(cx, 5));
+ let bp = getBp();
+ expect(bp && !bp.disabled).toBe(true);
+ bp = getBp();
+ if (!bp) {
+ throw new Error("no bp");
+ }
+ await dispatch(actions.toggleDisabledBreakpoint(cx, bp));
+ bp = getBp();
+ expect(bp && bp.disabled).toBe(true);
+ });
+
+ it("should set the breakpoint condition", async () => {
+ const { dispatch, getState, cx } = createStore(mockClient({ 5: [1] }));
+
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const loc = createLocation({
+ source,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor }));
+
+ await dispatch(actions.addBreakpoint(cx, loc));
+
+ let bp = selectors.getBreakpoint(getState(), loc);
+ expect(bp && bp.options.condition).toBe(undefined);
+
+ await dispatch(
+ actions.setBreakpointOptions(cx, 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, cx } = createStore(mockClient({ 5: [1] }));
+
+ const source = await dispatch(actions.newGeneratedSource(makeSource("a")));
+ const loc = createLocation({
+ source,
+ line: 5,
+ column: 1,
+ sourceUrl: "http://localhost:8000/examples/a",
+ });
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor }));
+
+ await dispatch(actions.addBreakpoint(cx, loc));
+ let bp = selectors.getBreakpoint(getState(), loc);
+ if (!bp) {
+ throw new Error("no breakpoint");
+ }
+
+ await dispatch(actions.disableBreakpoint(cx, bp));
+
+ bp = selectors.getBreakpoint(getState(), loc);
+ expect(bp && bp.options.condition).toBe(undefined);
+
+ await dispatch(
+ actions.setBreakpointOptions(cx, 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, cx } = createStore(mockClient({ 1: [0] }));
+
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("a.js"))
+ );
+ const loc = createLocation({
+ source,
+ line: 1,
+ column: 0,
+ sourceUrl: "http://localhost:8000/examples/a.js",
+ });
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor }));
+
+ await dispatch(actions.addBreakpoint(cx, loc));
+ await dispatch(actions.togglePrettyPrint(cx, "a.js"));
+
+ const breakpoint = selectors.getBreakpointsList(getState())[0];
+
+ await dispatch(actions.removeBreakpoint(cx, breakpoint));
+
+ const breakpointList = selectors.getPendingBreakpointList(getState());
+ expect(breakpointList.length).toBe(0);
+ });
+});
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..9c59e930a7
--- /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";
+
+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..e324038bfb
--- /dev/null
+++ b/devtools/client/debugger/src/actions/expressions.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 {
+ getExpression,
+ getExpressions,
+ getSelectedFrame,
+ getSelectedFrameId,
+ getSelectedSource,
+ getSelectedScopeMappings,
+ getSelectedFrameBindings,
+ getCurrentThread,
+ getIsPaused,
+ isMapScopesEnabled,
+} from "../selectors";
+import { PROMISE } from "./utils/middleware/promise";
+import { wrapExpression } from "../utils/expressions";
+import { features } from "../utils/prefs";
+
+/**
+ * Add expression for debugger to watch
+ *
+ * @param {object} expression
+ * @param {number} expression.id
+ * @memberof actions/pause
+ * @static
+ */
+export function addExpression(cx, input) {
+ return async ({ dispatch, getState, parserWorker }) => {
+ if (!input) {
+ return null;
+ }
+
+ const expressionError = await parserWorker.hasSyntaxError(input);
+
+ const expression = getExpression(getState(), input);
+ if (expression) {
+ return dispatch(evaluateExpression(cx, expression));
+ }
+
+ dispatch({ type: "ADD_EXPRESSION", cx, input, expressionError });
+
+ const newExpression = getExpression(getState(), input);
+ if (newExpression) {
+ return dispatch(evaluateExpression(cx, newExpression));
+ }
+
+ return null;
+ };
+}
+
+export function autocomplete(cx, input, cursor) {
+ return async ({ dispatch, getState, client }) => {
+ if (!input) {
+ return;
+ }
+ const frameId = getSelectedFrameId(getState(), cx.thread);
+ const result = await client.autocomplete(input, cursor, frameId);
+ dispatch({ type: "AUTOCOMPLETE", cx, input, result });
+ };
+}
+
+export function clearAutocomplete() {
+ return { type: "CLEAR_AUTOCOMPLETE" };
+}
+
+export function clearExpressionError() {
+ return { type: "CLEAR_EXPRESSION_ERROR" };
+}
+
+export function updateExpression(cx, input, expression) {
+ return async ({ dispatch, getState, parserWorker }) => {
+ if (!input) {
+ return;
+ }
+
+ const expressionError = await parserWorker.hasSyntaxError(input);
+ dispatch({
+ type: "UPDATE_EXPRESSION",
+ cx,
+ expression,
+ input: expressionError ? expression.input : input,
+ expressionError,
+ });
+
+ dispatch(evaluateExpressions(cx));
+ };
+}
+
+/**
+ *
+ * @param {object} expression
+ * @param {number} expression.id
+ * @memberof actions/pause
+ * @static
+ */
+export function deleteExpression(expression) {
+ return ({ dispatch }) => {
+ dispatch({
+ type: "DELETE_EXPRESSION",
+ input: expression.input,
+ });
+ };
+}
+
+/**
+ *
+ * @memberof actions/pause
+ * @param {number} selectedFrameId
+ * @static
+ */
+export function evaluateExpressions(cx) {
+ return async function ({ dispatch, getState, client }) {
+ const expressions = getExpressions(getState());
+ const inputs = expressions.map(({ input }) => input);
+ const frameId = getSelectedFrameId(getState(), cx.thread);
+ const results = await client.evaluateExpressions(inputs, {
+ frameId,
+ threadId: cx.thread,
+ });
+ dispatch({ type: "EVALUATE_EXPRESSIONS", cx, inputs, results });
+ };
+}
+
+function evaluateExpression(cx, expression) {
+ return async function ({ dispatch, getState, client }) {
+ if (!expression.input) {
+ console.warn("Expressions should not be empty");
+ return null;
+ }
+
+ let { input } = expression;
+ const frame = getSelectedFrame(getState(), cx.thread);
+
+ if (frame) {
+ const selectedSource = getSelectedSource(getState());
+
+ if (
+ selectedSource &&
+ frame.location.source.isOriginal &&
+ selectedSource.isOriginal
+ ) {
+ const mapResult = await dispatch(getMappedExpression(input));
+ if (mapResult) {
+ input = mapResult.expression;
+ }
+ }
+ }
+
+ const frameId = getSelectedFrameId(getState(), cx.thread);
+
+ return dispatch({
+ type: "EVALUATE_EXPRESSION",
+ cx,
+ thread: cx.thread,
+ input: expression.input,
+ [PROMISE]: client.evaluate(wrapExpression(input), {
+ frameId,
+ }),
+ });
+ };
+}
+
+/**
+ * Gets information about original variable names from the source map
+ * and replaces all posible generated names.
+ */
+export function getMappedExpression(expression) {
+ return async function ({ dispatch, getState, parserWorker }) {
+ const thread = getCurrentThread(getState());
+ 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..4ea2ea01bb
--- /dev/null
+++ b/devtools/client/debugger/src/actions/file-search.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { searchSourceForHighlight } from "../utils/editor";
+
+import { getSelectedSourceTextContent, getSearchOptions } from "../selectors";
+
+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(cx, editor) {
+ return ({ getState, 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..ab6eec75f1
--- /dev/null
+++ b/devtools/client/debugger/src/actions/index.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import * as ast from "./ast";
+import * as breakpoints from "./breakpoints";
+import * as exceptions from "./exceptions";
+import * as expressions from "./expressions";
+import * as eventListeners from "./event-listeners";
+import * as pause from "./pause";
+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";
+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 { 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,
+};
diff --git a/devtools/client/debugger/src/actions/moz.build b/devtools/client/debugger/src/actions/moz.build
new file mode 100644
index 0000000000..770fc61139
--- /dev/null
+++ b/devtools/client/debugger/src/actions/moz.build
@@ -0,0 +1,31 @@
+# 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",
+ "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..03d06a2baa
--- /dev/null
+++ b/devtools/client/debugger/src/actions/navigation.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 { clearDocuments } from "../utils/editor";
+import sourceQueue from "../utils/source-queue";
+
+import { clearWasmStates } from "../utils/wasm";
+import { getMainThread, getThreadContext } from "../selectors";
+import { evaluateExpressions } 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();
+ clearDocuments();
+ parserWorker.clear();
+ 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
+ const threadcx = getThreadContext(getState());
+ await dispatch(evaluateExpressions(threadcx));
+ } 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..02df827cb1
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/breakOnNext.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/>. */
+
+/**
+ * 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(cx) {
+ return async ({ dispatch, getState, client }) => {
+ await client.breakOnNext(cx.thread);
+ return dispatch({ type: "BREAK_ON_NEXT", thread: cx.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..27478d6ad2
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/commands.js
@@ -0,0 +1,157 @@
+/* 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,
+ getThreadContext,
+ getCurrentThread,
+ getIsCurrentThreadPaused,
+} from "../../selectors";
+import { PROMISE } from "../utils/middleware/promise";
+import { evaluateExpressions } from "../expressions";
+import { selectLocation } from "../sources";
+import { fetchScopes } from "./fetchScopes";
+import { fetchFrames } from "./fetchFrames";
+import { recordEvent } from "../../utils/telemetry";
+import assert from "../../utils/assert";
+
+export function selectThread(cx, thread) {
+ return async ({ dispatch, getState, client }) => {
+ if (getCurrentThread(getState()) === thread) {
+ return;
+ }
+
+ dispatch({ cx, type: "SELECT_THREAD", thread });
+
+ // Get a new context now that the current thread has changed.
+ const threadcx = getThreadContext(getState());
+ // Note that this is a rethorical assertion as threadcx.thread is updated by SELECT_THREAD action
+ assert(threadcx.thread == thread, "Thread mismatch");
+
+ const serverRequests = [];
+ // Update the watched expressions as we may never have evaluated them against this thread
+ serverRequests.push(dispatch(evaluateExpressions(threadcx)));
+
+ // 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)
+ const frame = getSelectedFrame(getState(), thread);
+ if (frame) {
+ serverRequests.push(dispatch(selectLocation(threadcx, frame.location)));
+ serverRequests.push(dispatch(fetchFrames(threadcx)));
+ serverRequests.push(dispatch(fetchScopes(threadcx)));
+ }
+
+ await Promise.all(serverRequests);
+ };
+}
+
+/**
+ * Debugger commands like stepOver, stepIn, stepUp
+ *
+ * @param string $0.type
+ * @memberof actions/pause
+ * @static
+ */
+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
+ * @memberof actions/pause
+ * @static
+ * @returns {Function} {@link command}
+ */
+export function stepIn() {
+ return ({ dispatch, getState }) => {
+ if (!getIsCurrentThreadPaused(getState())) {
+ return null;
+ }
+ return dispatch(command("stepIn"));
+ };
+}
+
+/**
+ * stepOver
+ * @memberof actions/pause
+ * @static
+ * @returns {Function} {@link command}
+ */
+export function stepOver() {
+ return ({ dispatch, getState }) => {
+ if (!getIsCurrentThreadPaused(getState())) {
+ return null;
+ }
+ return dispatch(command("stepOver"));
+ };
+}
+
+/**
+ * stepOut
+ * @memberof actions/pause
+ * @static
+ * @returns {Function} {@link command}
+ */
+export function stepOut() {
+ return ({ dispatch, getState }) => {
+ if (!getIsCurrentThreadPaused(getState())) {
+ return null;
+ }
+ return dispatch(command("stepOut"));
+ };
+}
+
+/**
+ * resume
+ * @memberof actions/pause
+ * @static
+ * @returns {Function} {@link command}
+ */
+export function resume() {
+ return ({ dispatch, getState }) => {
+ if (!getIsCurrentThreadPaused(getState())) {
+ return null;
+ }
+ recordEvent("continue");
+ return dispatch(command("resume"));
+ };
+}
+
+/**
+ * restart frame
+ * @memberof actions/pause
+ * @static
+ */
+export function restart(cx, frame) {
+ return async ({ dispatch, getState, client }) => {
+ if (!getIsCurrentThreadPaused(getState())) {
+ return null;
+ }
+ return dispatch({
+ type: "COMMAND",
+ command: "restart",
+ thread: cx.thread,
+ [PROMISE]: client.restart(cx.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..56aa117eab
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/continueToHere.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 {
+ getSelectedSource,
+ getSelectedFrame,
+ getClosestBreakpointPosition,
+ getBreakpoint,
+} from "../../selectors";
+import { createLocation } from "../../utils/location";
+import { addHiddenBreakpoint } from "../breakpoints";
+import { setBreakpointPositions } from "../breakpoints/breakpointPositions";
+
+import { resume } from "./commands";
+
+export function continueToHere(cx, location) {
+ return async function ({ dispatch, getState }) {
+ const { line, column } = location;
+ const selectedSource = getSelectedSource(getState());
+ const selectedFrame = getSelectedFrame(getState(), cx.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({ cx, 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(
+ cx,
+ createLocation({
+ source: selectedSource,
+ line: pauseLocation.line,
+ column: pauseLocation.column,
+ })
+ )
+ );
+ }
+
+ dispatch(resume(cx));
+ };
+}
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..fa431ee0b9
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/expandScopes.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 { getScopeItemPath } from "../../utils/pause/scopes/utils";
+
+export function setExpandedScope(cx, item, expanded) {
+ return function ({ dispatch, getState }) {
+ return dispatch({
+ type: "SET_EXPANDED_SCOPE",
+ cx,
+ thread: cx.thread,
+ 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..42295ae026
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/fetchFrames.js
@@ -0,0 +1,23 @@
+/* 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 { isValidThreadContext } from "../../utils/context";
+
+export function fetchFrames(cx) {
+ return async function ({ dispatch, client, getState }) {
+ const { thread } = cx;
+ 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 (isValidThreadContext(getState(), cx)) {
+ throw e;
+ }
+ }
+ dispatch({ type: "FETCHED_FRAMES", thread, frames, cx });
+ };
+}
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..691b3ce006
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/fetchScopes.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/>. */
+
+import { getSelectedFrame, getGeneratedFrameScope } from "../../selectors";
+import { mapScopes } from "./mapScopes";
+import { generateInlinePreview } from "./inlinePreview";
+import { PROMISE } from "../utils/middleware/promise";
+
+export function fetchScopes(cx) {
+ return async function ({ dispatch, getState, client }) {
+ const frame = getSelectedFrame(getState(), cx.thread);
+ if (!frame || getGeneratedFrameScope(getState(), frame.id)) {
+ return;
+ }
+
+ const scopes = dispatch({
+ type: "ADD_SCOPES",
+ cx,
+ thread: cx.thread,
+ frame,
+ [PROMISE]: client.getFrameScopes(frame),
+ });
+
+ scopes.then(() => {
+ dispatch(generateInlinePreview(cx, frame));
+ });
+ await dispatch(mapScopes(cx, scopes, frame));
+ };
+}
diff --git a/devtools/client/debugger/src/actions/pause/highlightCalls.js b/devtools/client/debugger/src/actions/pause/highlightCalls.js
new file mode 100644
index 0000000000..aec82fe35b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/highlightCalls.js
@@ -0,0 +1,89 @@
+/* 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,
+ getSelectedFrame,
+ getCurrentThread,
+} from "../../selectors";
+
+// a is an ast location with start and end positions (line and column).
+// b is a single position (line and column).
+// This function tests to see if the b position
+// falls within the range given in a.
+function inHouseContainsPosition(a, b) {
+ const bColumn = b.column || 0;
+ const startsBefore =
+ a.start.line < b.line ||
+ (a.start.line === b.line && a.start.column <= bColumn);
+ const endsAfter =
+ a.end.line > b.line || (a.end.line === b.line && a.end.column >= bColumn);
+
+ return startsBefore && endsAfter;
+}
+
+export function highlightCalls(cx) {
+ return async function ({ dispatch, getState, parserWorker }) {
+ if (!cx) {
+ return null;
+ }
+
+ const frame = await getSelectedFrame(
+ getState(),
+ getCurrentThread(getState())
+ );
+
+ if (!frame || !parserWorker.isLocationSupported(frame.location)) {
+ return null;
+ }
+
+ const { thread } = cx;
+
+ const originalAstScopes = await parserWorker.getScopes(frame.location);
+ if (!originalAstScopes) {
+ return null;
+ }
+
+ const symbols = getSymbols(getState(), frame.location);
+
+ if (!symbols) {
+ return null;
+ }
+
+ if (!symbols.callExpressions) {
+ return null;
+ }
+
+ const localAstScope = originalAstScopes[0];
+ const allFunctionCalls = symbols.callExpressions;
+
+ const highlightedCalls = allFunctionCalls.filter(function (call) {
+ const containsStart = inHouseContainsPosition(
+ localAstScope,
+ call.location.start
+ );
+ const containsEnd = inHouseContainsPosition(
+ localAstScope,
+ call.location.end
+ );
+ return containsStart && containsEnd;
+ });
+
+ return dispatch({
+ type: "HIGHLIGHT_CALLS",
+ thread,
+ highlightedCalls,
+ });
+ };
+}
+
+export function unhighlightCalls(cx) {
+ return async function ({ dispatch, getState }) {
+ const { thread } = cx;
+ return dispatch({
+ type: "UNHIGHLIGHT_CALLS",
+ thread,
+ });
+ };
+}
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..be31894019
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/index.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/>. */
+
+/**
+ * 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 { mapDisplayNames } from "./mapDisplayNames";
+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";
+export { highlightCalls, unhighlightCalls } from "./highlightCalls";
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..e3a4e614c0
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/inlinePreview.js
@@ -0,0 +1,244 @@
+/* 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";
+import { features } from "../../utils/prefs";
+import { validateThreadContext } 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(cx, frame) {
+ return async function ({ dispatch, getState, parserWorker, client }) {
+ if (!frame || !features.inlinePreview) {
+ return null;
+ }
+
+ const { thread } = cx;
+
+ // Avoid regenerating inline previews when we already have preview data
+ if (getInlinePreviews(getState(), thread, frame.id)) {
+ return null;
+ }
+
+ const originalFrameScopes = getOriginalFrameScope(
+ getState(),
+ thread,
+ frame.location.sourceId,
+ frame.id
+ );
+
+ const generatedFrameScopes = getGeneratedFrameScope(
+ getState(),
+ thread,
+ frame.id
+ );
+
+ 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);
+ validateThreadContext(getState(), cx);
+ 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 },
+ },
+ cx.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",
+ thread,
+ frame,
+ 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/mapDisplayNames.js b/devtools/client/debugger/src/actions/pause/mapDisplayNames.js
new file mode 100644
index 0000000000..a7abbc36bd
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/mapDisplayNames.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/>. */
+
+import { getFrames, getSymbols } from "../../selectors";
+
+import { findClosestFunction } from "../../utils/ast";
+
+function mapDisplayName(frame, { getState }) {
+ if (frame.isOriginal) {
+ return frame;
+ }
+
+ const symbols = getSymbols(getState(), frame.location);
+
+ if (!symbols || !symbols.functions) {
+ return frame;
+ }
+
+ const originalFunction = findClosestFunction(symbols, frame.location);
+
+ if (!originalFunction) {
+ return frame;
+ }
+
+ const originalDisplayName = originalFunction.name;
+ return { ...frame, originalDisplayName };
+}
+
+export function mapDisplayNames(cx) {
+ return function ({ dispatch, getState }) {
+ const frames = getFrames(getState(), cx.thread);
+
+ if (!frames) {
+ return;
+ }
+
+ const mappedFrames = frames.map(frame =>
+ mapDisplayName(frame, { getState })
+ );
+
+ dispatch({
+ type: "MAP_FRAME_DISPLAY_NAMES",
+ cx,
+ thread: cx.thread,
+ frames: mappedFrames,
+ });
+ };
+}
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..d677677505
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/mapFrames.js
@@ -0,0 +1,157 @@
+/* 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";
+
+import { isFrameBlackBoxed } from "../../utils/source";
+
+import assert from "../../utils/assert";
+import { getOriginalLocation } from "../../utils/source-maps";
+import {
+ debuggerToSourceMapLocation,
+ sourceMapToDebuggerLocation,
+} from "../../utils/location";
+import { isGeneratedId } from "devtools/client/shared/source-map-loader/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 updateFrameLocation(frame, thunkArgs) {
+ if (frame.isOriginal) {
+ return Promise.resolve(frame);
+ }
+ const location = await getOriginalLocation(frame.location, thunkArgs, true);
+ return {
+ ...frame,
+ location,
+ generatedLocation: frame.generatedLocation || frame.location,
+ };
+}
+
+function updateFrameLocations(frames, thunkArgs) {
+ if (!frames || !frames.length) {
+ return Promise.resolve(frames);
+ }
+
+ return Promise.all(
+ frames.map(frame => updateFrameLocation(frame, thunkArgs))
+ );
+}
+
+function isWasmOriginalSourceFrame(frame, getState) {
+ if (isGeneratedId(frame.location.sourceId)) {
+ return false;
+ }
+
+ return Boolean(frame.generatedLocation?.source.isWasm);
+}
+
+async function expandFrames(frames, { getState, sourceMapLoader }) {
+ const result = [];
+ for (let i = 0; i < frames.length; ++i) {
+ const frame = frames[i];
+ if (frame.isOriginal || !isWasmOriginalSourceFrame(frame, getState)) {
+ 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 not specific location -- use one from original frame.
+ originalFrames[0] = {
+ ...originalFrames[0],
+ location: frame.location,
+ };
+
+ originalFrames.forEach((originalFrame, j) => {
+ if (!originalFrame.location) {
+ return;
+ }
+
+ // Keep outer most frame with true actor ID, and generate uniquie
+ // one for the nested frames.
+ const id = j == 0 ? frame.id : `${frame.id}-originalFrame${j}`;
+ result.push({
+ id,
+ displayName: originalFrame.displayName,
+ location: sourceMapToDebuggerLocation(
+ getState(),
+ originalFrame.location
+ ),
+ index: frame.index,
+ source: null,
+ thread: frame.thread,
+ scope: frame.scope,
+ this: frame.this,
+ isOriginal: true,
+ // More fields that will be added by the mapDisplayNames and
+ // updateFrameLocation.
+ generatedLocation: frame.generatedLocation,
+ originalDisplayName: originalFrame.displayName,
+ originalVariables: originalFrame.variables,
+ asyncCause: frame.asyncCause,
+ state: frame.state,
+ });
+ });
+ }
+ 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(cx) {
+ return async function (thunkArgs) {
+ const { dispatch, getState } = thunkArgs;
+ const frames = getFrames(getState(), cx.thread);
+ if (!frames) {
+ return;
+ }
+
+ let mappedFrames = await updateFrameLocations(frames, thunkArgs);
+
+ mappedFrames = await expandFrames(mappedFrames, thunkArgs);
+
+ const selectedFrameId = getSelectedFrameId(
+ getState(),
+ cx.thread,
+ mappedFrames
+ );
+
+ dispatch({
+ type: "MAP_FRAMES",
+ cx,
+ thread: cx.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..2a352dc578
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/mapScopes.js
@@ -0,0 +1,194 @@
+/* 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 {
+ getSelectedFrameId,
+ getSettledSourceTextContent,
+ isMapScopesEnabled,
+ getSelectedFrame,
+ getSelectedGeneratedScope,
+ getSelectedOriginalScope,
+ getThreadContext,
+ getFirstSourceActorForGeneratedSource,
+} from "../../selectors";
+import {
+ loadOriginalSourceText,
+ loadGeneratedSourceText,
+} from "../sources/loadSourceText";
+import { PROMISE } from "../utils/middleware/promise";
+import assert from "../../utils/assert";
+
+import { log } from "../../utils/log";
+import { isGenerated } from "../../utils/source";
+
+import { buildMappedScopes } from "../../utils/pause/mapScopes";
+import { isFulfilled } from "../../utils/async-value";
+
+import { getMappedLocation } from "../../utils/source-maps";
+
+const expressionRegex = /\bfp\(\)/g;
+
+export async function buildOriginalScopes(
+ frame,
+ client,
+ cx,
+ frameId,
+ generatedScopes
+) {
+ if (!frame.originalVariables) {
+ throw new TypeError("(frame.originalVariables: XScopeVariables)");
+ }
+ const originalVariables = frame.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,
+ });
+
+ 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 });
+
+ const cx = getThreadContext(getState());
+
+ if (getSelectedOriginalScope(getState(), cx.thread)) {
+ return;
+ }
+
+ const scopes = getSelectedGeneratedScope(getState(), cx.thread);
+ const frame = getSelectedFrame(getState(), cx.thread);
+ if (!scopes || !frame) {
+ return;
+ }
+
+ dispatch(mapScopes(cx, Promise.resolve(scopes.scope), frame));
+ };
+}
+
+export function mapScopes(cx, scopes, frame) {
+ return async function (thunkArgs) {
+ const { dispatch, client, getState } = thunkArgs;
+ assert(cx.thread == frame.thread, "Thread mismatch");
+
+ await dispatch({
+ type: "MAP_SCOPES",
+ cx,
+ thread: cx.thread,
+ frame,
+ [PROMISE]: (async function () {
+ if (frame.isOriginal && frame.originalVariables) {
+ const frameId = getSelectedFrameId(getState(), cx.thread);
+ return buildOriginalScopes(frame, client, cx, frameId, scopes);
+ }
+
+ return dispatch(getMappedScopes(cx, scopes, frame));
+ })(),
+ });
+ };
+}
+
+export function getMappedScopes(cx, scopes, frame) {
+ return async function (thunkArgs) {
+ const { getState, dispatch } = thunkArgs;
+ const generatedSource = frame.generatedLocation.source;
+
+ const source = frame.location.source;
+
+ if (
+ !isMapScopesEnabled(getState()) ||
+ !source ||
+ !generatedSource ||
+ generatedSource.isWasm ||
+ source.isPrettyPrinted ||
+ isGenerated(source)
+ ) {
+ return null;
+ }
+
+ // Load source text for the original source
+ await dispatch(loadOriginalSourceText({ cx, source }));
+
+ const generatedSourceActor = getFirstSourceActorForGeneratedSource(
+ getState(),
+ generatedSource.id
+ );
+
+ // Also load source text for its corresponding generated source
+ await dispatch(
+ loadGeneratedSourceText({
+ cx,
+ sourceActor: generatedSourceActor,
+ })
+ );
+
+ try {
+ // load original source text content
+ const content = getSettledSourceTextContent(getState(), frame.location);
+
+ return await buildMappedScopes(
+ source,
+ content && isFulfilled(content)
+ ? content.value
+ : { type: "text", value: "", contentType: undefined },
+ frame,
+ await scopes,
+ thunkArgs
+ );
+ } catch (e) {
+ log(e);
+ return null;
+ }
+ };
+}
+
+export function getMappedScopesForLocation(location) {
+ return async function (thunkArgs) {
+ const { dispatch, getState } = thunkArgs;
+ const cx = getThreadContext(getState());
+ const mappedLocation = await getMappedLocation(location, thunkArgs);
+ return dispatch(getMappedScopes(cx, 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..54cf792166
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/moz.build
@@ -0,0 +1,27 @@
+# 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",
+ "highlightCalls.js",
+ "index.js",
+ "inlinePreview.js",
+ "mapDisplayNames.js",
+ "mapFrames.js",
+ "mapScopes.js",
+ "paused.js",
+ "pauseOnExceptions.js",
+ "resetBreakpointsPaneState.js",
+ "resumed.js",
+ "selectFrame.js",
+ "skipPausing.js",
+)
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..0e797035a5
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/paused.js
@@ -0,0 +1,73 @@
+/* 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,
+ getThreadContext,
+} from "../../selectors";
+
+import { mapFrames, fetchFrames } from ".";
+import { removeBreakpoint } from "../breakpoints";
+import { evaluateExpressions } from "../expressions";
+import { selectLocation } from "../sources";
+import assert from "../../utils/assert";
+
+import { fetchScopes } from "./fetchScopes";
+
+/**
+ * Debugger has just paused
+ *
+ * @param {object} pauseInfo
+ * @memberof actions/pause
+ * @static
+ */
+export function paused(pauseInfo) {
+ return async function ({ dispatch, getState }) {
+ const { thread, frame, why } = pauseInfo;
+
+ dispatch({ type: "PAUSED", thread, why, frame });
+
+ // Get a context capturing the newly paused and selected thread.
+ const cx = getThreadContext(getState());
+ // Note that this is a rethorical assertion as threadcx.thread is updated by PAUSED action
+ assert(cx.thread == thread, "Thread mismatch");
+
+ // 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(cx, 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(cx));
+ // 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(cx));
+
+ // 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(cx, selectedFrame.location));
+ }
+
+ // Fetch the previews for variables visible in the currently selected paused stackframe
+ await dispatch(fetchScopes(cx));
+
+ // 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(cx));
+ }
+ };
+}
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..323e9f0ff8
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/resumed.js
@@ -0,0 +1,28 @@
+/* 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, getThreadContext } from "../../selectors";
+import { evaluateExpressions } from "../expressions";
+import { inDebuggerEval } from "../../utils/pause";
+
+/**
+ * Debugger has just resumed
+ *
+ * @memberof actions/pause
+ * @static
+ */
+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 });
+
+ const cx = getThreadContext(getState());
+ if (!wasStepping && !wasPausedInEval && cx.thread == thread) {
+ await dispatch(evaluateExpressions(cx));
+ }
+ };
+}
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..f97be42787
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/selectFrame.js
@@ -0,0 +1,39 @@
+/* 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";
+import { evaluateExpressions } from "../expressions";
+import { fetchScopes } from "./fetchScopes";
+import assert from "../../utils/assert";
+
+/**
+ * @memberof actions/pause
+ * @static
+ */
+export function selectFrame(cx, frame) {
+ return async ({ dispatch, getState }) => {
+ assert(cx.thread == frame.thread, "Thread mismatch");
+
+ // 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(cx, frame.location));
+ return;
+ }
+
+ dispatch({
+ type: "SELECT_FRAME",
+ cx,
+ thread: cx.thread,
+ 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(cx, frame.location));
+
+ dispatch(evaluateExpressions(cx));
+ dispatch(fetchScopes(cx));
+ };
+}
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..1ecdf33b76
--- /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";
+
+/**
+ * @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..3a562ccfdd
--- /dev/null
+++ b/devtools/client/debugger/src/actions/pause/tests/pause.spec.js
@@ -0,0 +1,413 @@
+/* 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,
+ waitForState,
+ makeSource,
+ makeOriginalSource,
+ makeFrame,
+} from "../../../utils/test-head";
+
+import { makeWhyNormal } from "../../../utils/test-mockup";
+import { createLocation } from "../../../utils/location";
+
+const { isStepping } = selectors;
+
+let stepInResolve = null;
+const mockCommandClient = {
+ stepIn: () =>
+ new Promise(_resolve => {
+ stepInResolve = _resolve;
+ }),
+ 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.sourceId },
+ {
+ location: frameLocation,
+ generatedLocation: frameLocation,
+ ...frameOpts,
+ }
+ ),
+ ];
+ return {
+ thread: "FakeThread",
+ frame: frames[0],
+ frames,
+ loadedObjects: [],
+ why: makeWhyNormal(),
+ };
+}
+
+describe("pause", () => {
+ describe("stepping", () => {
+ it("should set and clear the command", async () => {
+ const { dispatch, getState } = createStore(mockCommandClient);
+ const mockPauseInfo = createPauseInfo();
+
+ await dispatch(actions.newGeneratedSource(makeSource("foo1")));
+ await dispatch(actions.paused(mockPauseInfo));
+ const cx = selectors.getThreadContext(getState());
+ const stepped = dispatch(actions.stepIn(cx));
+ expect(isStepping(getState(), "FakeThread")).toBeTruthy();
+ if (!stepInResolve) {
+ throw new Error("no stepInResolve");
+ }
+ await stepInResolve();
+ await stepped;
+ expect(isStepping(getState(), "FakeThread")).toBeFalsy();
+ });
+
+ it("should only step when paused", async () => {
+ const client = { stepIn: jest.fn() };
+ const { dispatch, cx } = createStore(client);
+
+ dispatch(actions.stepIn(cx));
+ expect(client.stepIn.mock.calls).toHaveLength(0);
+ });
+
+ it("should step when paused", async () => {
+ const { dispatch, getState } = createStore(mockCommandClient);
+ const mockPauseInfo = createPauseInfo();
+
+ await dispatch(actions.newGeneratedSource(makeSource("foo1")));
+ await dispatch(actions.paused(mockPauseInfo));
+ const cx = selectors.getThreadContext(getState());
+ dispatch(actions.stepIn(cx));
+ expect(isStepping(getState(), "FakeThread")).toBeTruthy();
+ });
+
+ 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,
+ originalDisplayName: "foo",
+ 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 locations and names to original source", async () => {
+ const sourceMapLoaderMock = {
+ getOriginalLocation: () => Promise.resolve(originalLocation),
+ getOriginalLocations: async items => items,
+ getOriginalSourceText: async () => ({
+ text: "\n\nfunction fooOriginal() {\n return -5;\n}",
+ contentType: "text/javascript",
+ }),
+ getGeneratedLocation: async location => location,
+ };
+
+ const client = { ...mockCommandClient };
+ const store = createStore(client, {}, sourceMapLoaderMock);
+ const { dispatch, getState } = store;
+
+ const originalSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo-original"))
+ );
+
+ const originalLocation = createLocation({
+ source: originalSource,
+ line: 3,
+ column: 0,
+ sourceActor: selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ originalSource.id
+ ),
+ });
+
+ const generatedSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+ 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;
+
+ await dispatch(actions.paused(mockPauseInfo));
+ expect(selectors.getFrames(getState(), "FakeThread")).toEqual([
+ {
+ id: mockFrameId,
+ generatedLocation,
+ location: originalLocation,
+ originalDisplayName: "fooOriginal",
+ scope: { bindings: { arguments: [], variables: {} } },
+ thread: "FakeThread",
+ },
+ ]);
+ });
+
+ it("maps frame to original frames", async () => {
+ const sourceMapLoaderMock = {
+ getOriginalStackFrames: loc => Promise.resolve(originStackFrames),
+ getOriginalLocation: () => Promise.resolve(originalLocation),
+ getOriginalLocations: async items => items,
+ 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,
+ sourceActor: selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ originalSource.id
+ ),
+ });
+ 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,
+ location: originalLocation,
+ originalDisplayName: "fooBar",
+ originalVariables: undefined,
+ scope: { bindings: { arguments: [], variables: {} } },
+ source: null,
+ state: undefined,
+ this: undefined,
+ thread: "FakeThread",
+ },
+ {
+ asyncCause: undefined,
+ displayName: "barZoo",
+ generatedLocation,
+ id: "1-originalFrame1",
+ index: undefined,
+ isOriginal: true,
+ location: originalLocation2,
+ originalDisplayName: "barZoo",
+ originalVariables: undefined,
+ scope: { bindings: { arguments: [], variables: {} } },
+ source: null,
+ state: undefined,
+ this: undefined,
+ thread: "FakeThread",
+ },
+ ]);
+ });
+ });
+
+ describe("resumed", () => {
+ it("should not evaluate expression while stepping", async () => {
+ const client = { ...mockCommandClient, evaluateExpressions: jest.fn() };
+ const { dispatch, getState } = createStore(client);
+ const mockPauseInfo = createPauseInfo();
+
+ await dispatch(actions.newGeneratedSource(makeSource("foo1")));
+ await dispatch(actions.paused(mockPauseInfo));
+
+ const cx = selectors.getThreadContext(getState());
+ dispatch(actions.stepIn(cx));
+ await dispatch(actions.resumed(mockCommandClient.actorID));
+ expect(client.evaluateExpressions.mock.calls).toHaveLength(1);
+ });
+
+ it("resuming - will re-evaluate watch expressions", async () => {
+ const client = { ...mockCommandClient, evaluateExpressions: jest.fn() };
+ const store = createStore(client);
+ const { dispatch, getState, cx } = store;
+ const mockPauseInfo = createPauseInfo();
+
+ await dispatch(actions.newGeneratedSource(makeSource("foo1")));
+ await dispatch(actions.newGeneratedSource(makeSource("foo")));
+ await dispatch(actions.addExpression(cx, "foo"));
+ await waitForState(store, state => selectors.getExpression(state, "foo"));
+
+ client.evaluateExpressions.mockReturnValue(Promise.resolve(["YAY"]));
+ await dispatch(actions.paused(mockPauseInfo));
+
+ await dispatch(actions.resumed(mockCommandClient.actorID));
+ const expression = selectors.getExpression(getState(), "foo");
+ expect(expression && expression.value).toEqual("YAY");
+ });
+ });
+});
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..992737e2d1
--- /dev/null
+++ b/devtools/client/debugger/src/actions/preview.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/>. */
+
+import { isConsole } from "../utils/preview";
+import { findBestMatchExpression } from "../utils/ast";
+import { getGrip, getFront } from "../utils/evaluation-result";
+import { getExpressionFromCoords } from "../utils/editor/get-expression";
+import { isNodeTest } from "../utils/environment";
+
+import {
+ getPreview,
+ isLineInScope,
+ isSelectedFrameVisible,
+ getSelectedSource,
+ getSelectedLocation,
+ getSelectedFrame,
+ getSymbols,
+ getCurrentThread,
+ getPreviewCount,
+ getSelectedException,
+} from "../selectors";
+
+import { getMappedExpression } from "./expressions";
+
+function findExpressionMatch(state, codeMirror, tokenPos) {
+ const location = getSelectedLocation(state);
+ if (!location) {
+ return null;
+ }
+
+ const symbols = getSymbols(state, location);
+
+ let match;
+ if (!symbols) {
+ match = getExpressionFromCoords(codeMirror, tokenPos);
+ } else {
+ match = findBestMatchExpression(symbols, tokenPos);
+ }
+ return match;
+}
+
+export function updatePreview(cx, target, tokenPos, codeMirror) {
+ return ({ dispatch, getState }) => {
+ const cursorPos = target.getBoundingClientRect();
+
+ if (
+ !isSelectedFrameVisible(getState()) ||
+ !isLineInScope(getState(), tokenPos.line)
+ ) {
+ return;
+ }
+
+ const match = findExpressionMatch(getState(), codeMirror, tokenPos);
+ if (!match) {
+ return;
+ }
+
+ const { expression, location } = match;
+
+ if (isConsole(expression)) {
+ return;
+ }
+
+ dispatch(setPreview(cx, expression, location, tokenPos, cursorPos, target));
+ };
+}
+
+export function setPreview(
+ cx,
+ expression,
+ location,
+ tokenPos,
+ cursorPos,
+ target
+) {
+ return async ({ dispatch, getState, client }) => {
+ dispatch({ type: "START_PREVIEW" });
+ const previewCount = getPreviewCount(getState());
+ if (getPreview(getState())) {
+ dispatch(clearPreview(cx));
+ }
+
+ const source = getSelectedSource(getState());
+ if (!source) {
+ return;
+ }
+
+ const thread = getCurrentThread(getState());
+ const selectedFrame = getSelectedFrame(getState(), thread);
+
+ if (location && source.isOriginal) {
+ const mapResult = await dispatch(getMappedExpression(expression));
+ if (mapResult) {
+ expression = mapResult.expression;
+ }
+ }
+
+ if (!selectedFrame) {
+ return;
+ }
+
+ 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;
+ }
+
+ // 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;
+ }
+
+ const root = {
+ path: expression,
+ contents: {
+ value: resultGrip,
+ front: getFront(result),
+ },
+ };
+ const properties = await client.loadObjectProperties(root, thread);
+
+ // The first time a popup is rendered, the mouse should be hovered
+ // on the token. If it happens to be hovered on whitespace, it should
+ // not render anything
+ if (!target.matches(":hover") && !isNodeTest()) {
+ return;
+ }
+
+ // Don't finish dispatching if another setPreview was started
+ if (previewCount != getPreviewCount(getState())) {
+ return;
+ }
+
+ dispatch({
+ type: "SET_PREVIEW",
+ cx,
+ value: {
+ expression,
+ resultGrip,
+ properties,
+ root,
+ location,
+ tokenPos,
+ cursorPos,
+ target,
+ },
+ });
+ };
+}
+
+export function clearPreview(cx) {
+ return ({ dispatch, getState, client }) => {
+ const currentSelection = getPreview(getState());
+ if (!currentSelection) {
+ return null;
+ }
+
+ return dispatch({
+ type: "CLEAR_PREVIEW",
+ cx,
+ });
+ };
+}
+
+export function setExceptionPreview(cx, target, tokenPos, codeMirror) {
+ return async ({ dispatch, getState }) => {
+ const cursorPos = target.getBoundingClientRect();
+
+ const match = findExpressionMatch(getState(), codeMirror, tokenPos);
+ if (!match) {
+ return;
+ }
+
+ const tokenColumnStart = match.location.start.column + 1;
+ const exception = getSelectedException(
+ getState(),
+ tokenPos.line,
+ tokenColumnStart
+ );
+ if (!exception) {
+ return;
+ }
+
+ dispatch({
+ type: "SET_PREVIEW",
+ cx,
+ value: {
+ exception,
+ location: match.location,
+ tokenPos,
+ cursorPos,
+ target,
+ },
+ });
+ };
+}
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..26ea0df107
--- /dev/null
+++ b/devtools/client/debugger/src/actions/project-text-search.js
@@ -0,0 +1,171 @@
+/* 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";
+import { createLocation } from "../utils/location";
+import { matchesGlobPatterns } from "../utils/source";
+import { loadSourceText } from "./sources/loadSourceText";
+import {
+ getProjectSearchOperation,
+ getProjectSearchStatus,
+} from "../selectors/project-text-search";
+import { statusType } from "../reducers/project-text-search";
+import { searchKeys } from "../constants";
+
+export function addSearchQuery(cx, query) {
+ return { type: "ADD_QUERY", cx, query };
+}
+
+export function addOngoingSearch(cx, ongoingSearch) {
+ return { type: "ADD_ONGOING_SEARCH", cx, ongoingSearch };
+}
+
+export function addSearchResult(cx, location, matches) {
+ return {
+ type: "ADD_SEARCH_RESULT",
+ cx,
+ location,
+ matches,
+ };
+}
+
+export function clearSearchResults(cx) {
+ return { type: "CLEAR_SEARCH_RESULTS", cx };
+}
+
+export function clearSearch(cx) {
+ return { type: "CLEAR_SEARCH", cx };
+}
+
+export function updateSearchStatus(cx, status) {
+ return { type: "UPDATE_STATUS", cx, status };
+}
+
+export function closeProjectSearch(cx) {
+ return ({ dispatch, getState }) => {
+ dispatch(stopOngoingSearch(cx));
+ dispatch({ type: "CLOSE_PROJECT_SEARCH" });
+ };
+}
+
+export function stopOngoingSearch(cx) {
+ return ({ dispatch, getState }) => {
+ const state = getState();
+ const ongoingSearch = getProjectSearchOperation(state);
+ const status = getProjectSearchStatus(state);
+ if (ongoingSearch && status !== statusType.done) {
+ ongoingSearch.cancel();
+ dispatch(updateSearchStatus(cx, statusType.cancelled));
+ }
+ };
+}
+
+export function searchSources(cx, query) {
+ let cancelled = false;
+
+ const search = async ({ dispatch, getState }) => {
+ dispatch(stopOngoingSearch(cx));
+ await dispatch(addOngoingSearch(cx, search));
+ await dispatch(clearSearchResults(cx));
+ await dispatch(addSearchQuery(cx, query));
+ dispatch(updateSearchStatus(cx, statusType.fetching));
+ 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;
+ });
+
+ for (const source of validSources) {
+ if (cancelled) {
+ return;
+ }
+
+ const sourceActor = getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ await dispatch(loadSourceText(cx, source, sourceActor));
+ await dispatch(searchSource(cx, source, sourceActor, query));
+ }
+ dispatch(updateSearchStatus(cx, statusType.done));
+ };
+
+ search.cancel = () => {
+ cancelled = true;
+ };
+
+ return search;
+}
+
+export function searchSource(cx, source, sourceActor, query) {
+ return async ({ dispatch, getState, searchWorker }) => {
+ if (!source) {
+ return;
+ }
+ const state = getState();
+ const location = createLocation({
+ source,
+ sourceActor,
+ });
+
+ const options = getSearchOptions(state, searchKeys.PROJECT_SEARCH);
+ const content = getSettledSourceTextContent(state, location);
+ let matches = [];
+
+ if (content && isFulfilled(content) && content.value.type === "text") {
+ matches = await searchWorker.findSourceMatches(
+ content.value,
+ query,
+ options
+ );
+ }
+ if (!matches.length) {
+ return;
+ }
+ dispatch(addSearchResult(cx, location, matches));
+ };
+}
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..ae750a3df7
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources-tree.js
@@ -0,0 +1,11 @@
+/* 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 setExpandedState(expanded) {
+ return { type: "SET_EXPANDED_STATE", expanded };
+}
+
+export function focusItem(item) {
+ return { type: "SET_FOCUSED_SOURCE_ITEM", item };
+}
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..6821a0e140
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/blackbox.js
@@ -0,0 +1,223 @@
+/* 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 {
+ isOriginalId,
+ originalToGeneratedId,
+} from "devtools/client/shared/source-map-loader/index";
+import { recordEvent } from "../../utils/telemetry";
+import { toggleBreakpoints } from "../breakpoints";
+import {
+ getSourceActorsForSource,
+ isSourceBlackBoxed,
+ getBlackBoxRanges,
+ getBreakpointsForSource,
+} from "../../selectors";
+
+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 (isOriginalId(source.id)) {
+ 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} cx
+ * @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(cx, 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,
+ cx,
+ 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,
+ cx,
+ 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,
+ cx,
+ 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,
+ cx,
+ shouldDisable: false,
+ source,
+ ranges,
+ });
+ }
+ }
+ };
+}
+
+async function toggleBreakpointsInRangesForBlackboxedSource({
+ thunkArgs,
+ cx,
+ shouldDisable,
+ source,
+ ranges,
+}) {
+ const { dispatch, getState } = thunkArgs;
+ for (const range of ranges) {
+ const breakpoints = getBreakpointsForSource(getState(), source.id, range);
+ await dispatch(toggleBreakpoints(cx, shouldDisable, breakpoints));
+ }
+}
+
+async function toggleBreakpointsInBlackboxedSources({
+ thunkArgs,
+ cx,
+ shouldDisable,
+ sources,
+}) {
+ const { dispatch, getState } = thunkArgs;
+ for (const source of sources) {
+ const breakpoints = getBreakpointsForSource(getState(), source.id);
+ await dispatch(toggleBreakpoints(cx, shouldDisable, breakpoints));
+ }
+}
+
+/*
+ * Blackboxes a group of sources together
+ *
+ * @param {Object} cx
+ * @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(cx, 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,
+ cx,
+ 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..d028d480c0
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/breakableLines.js
@@ -0,0 +1,73 @@
+/* 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 { isOriginalId } from "devtools/client/shared/source-map-loader/index";
+import {
+ getBreakableLines,
+ getSourceActorBreakableLines,
+} from "../../selectors";
+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 cx
+ * @param Object location
+ */
+export function setBreakableLines(cx, location) {
+ return async ({ getState, dispatch, client }) => {
+ let breakableLines;
+ if (isOriginalId(location.source.id)) {
+ const positions = await dispatch(
+ setBreakpointPositions({ cx, location })
+ );
+ breakableLines = calculateBreakableLines(positions);
+
+ const existingBreakableLines = getBreakableLines(
+ getState(),
+ location.source.id
+ );
+ if (existingBreakableLines) {
+ breakableLines = [
+ ...new Set([...existingBreakableLines, ...breakableLines]),
+ ];
+ }
+
+ dispatch({
+ type: "SET_ORIGINAL_BREAKABLE_LINES",
+ cx,
+ sourceId: location.source.id,
+ 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",
+ sourceActorId: location.sourceActor.id,
+ 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..813f50262b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/index.js
@@ -0,0 +1,42 @@
+/* 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 { setSymbols } from "./symbols";
+
+export function setOverrideSource(cx, source, path) {
+ return ({ client, dispatch }) => {
+ if (!source || !source.url) {
+ return;
+ }
+ const { url } = source;
+ client.setOverride(url, path);
+ dispatch({
+ type: "SET_OVERRIDE",
+ cx,
+ url,
+ path,
+ });
+ };
+}
+
+export function removeOverrideSource(cx, source) {
+ return ({ client, dispatch }) => {
+ if (!source || !source.url) {
+ return;
+ }
+ const { url } = source;
+ client.removeOverride(url);
+ dispatch({
+ type: "REMOVE_OVERRIDE",
+ cx,
+ 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..8210b07a97
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/loadSourceText.js
@@ -0,0 +1,256 @@
+/* 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";
+import { addBreakpoint } from "../breakpoints";
+
+import { prettyPrintSource } 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 prettyPrintSource(
+ 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(cx, sourceActor, thunkArgs) {
+ const { dispatch, getState } = thunkArgs;
+ const epoch = getSourcesEpoch(getState());
+
+ await dispatch({
+ type: "LOAD_GENERATED_SOURCE_TEXT",
+ sourceActorId: sourceActor.actor,
+ epoch,
+ [PROMISE]: loadGeneratedSource(sourceActor, thunkArgs),
+ });
+
+ await onSourceTextContentAvailable(
+ cx,
+ sourceActor.sourceObject,
+ sourceActor,
+ thunkArgs
+ );
+}
+
+async function loadOriginalSourceTextPromise(cx, source, thunkArgs) {
+ const { dispatch, getState } = thunkArgs;
+ const epoch = getSourcesEpoch(getState());
+ await dispatch({
+ type: "LOAD_ORIGINAL_SOURCE_TEXT",
+ sourceId: source.id,
+ epoch,
+ [PROMISE]: loadOriginalSource(source, thunkArgs),
+ });
+
+ await onSourceTextContentAvailable(cx, 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} cx
+ * @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(
+ cx,
+ 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.id);
+ for (const breakpoint of breakpoints) {
+ await dispatch(
+ addBreakpoint(
+ cx,
+ 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: ({ cx, sourceActor }, thunkArgs) =>
+ loadGeneratedSourceTextPromise(cx, 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: ({ cx, source }, thunkArgs) =>
+ loadOriginalSourceTextPromise(cx, source, thunkArgs),
+ }
+);
+
+export function loadSourceText(cx, source, sourceActor) {
+ return async ({ dispatch, getState }) => {
+ if (!source) {
+ return null;
+ }
+ if (source.isOriginal) {
+ return dispatch(loadOriginalSourceText({ cx, source }));
+ }
+ if (!sourceActor) {
+ sourceActor = getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ }
+ return dispatch(loadGeneratedSourceText({ cx, 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..1e95c6d79d
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/newSources.js
@@ -0,0 +1,367 @@
+/* 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 { PROMISE } from "../utils/middleware/promise";
+import { insertSourceActors } from "../../actions/source-actors";
+import {
+ makeSourceId,
+ createGeneratedSource,
+ createSourceMapOriginalSource,
+ createSourceActor,
+} from "../../client/firefox/create";
+import { toggleBlackBox } from "./blackbox";
+import { syncPendingBreakpoint } from "../breakpoints";
+import { loadSourceText } from "./loadSourceText";
+import { togglePrettyPrint } from "./prettyPrint";
+import { toggleSourceMapIgnoreList } from "../ui";
+import { selectLocation, setBreakableLines } from "../sources";
+
+import { getRawSourceURL, isPrettyURL } from "../../utils/source";
+import { createLocation } from "../../utils/location";
+import {
+ getBlackBoxRanges,
+ getSource,
+ getSourceFromId,
+ hasSourceActor,
+ getSourceByActorId,
+ getPendingSelectedLocation,
+ getPendingBreakpointsForSource,
+ getContext,
+} from "../../selectors";
+
+import { prefs } from "../../utils/prefs";
+import sourceQueue from "../../utils/source-queue";
+import { validateNavigateContext, ContextError } from "../../utils/context";
+
+function loadSourceMaps(cx, sources) {
+ return async function ({ dispatch }) {
+ try {
+ const sourceList = await Promise.all(
+ sources.map(async sourceActor => {
+ const originalSourcesInfo = await dispatch(
+ loadSourceMap(cx, 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(cx, sourceActor) {
+ return async function ({ dispatch, getState, sourceMapLoader }) {
+ if (!prefs.clientSourceMapsEnabled || !sourceActor.sourceMapURL) {
+ return [];
+ }
+
+ let data = null;
+ 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) {
+ data = await sourceMapLoader.getOriginalURLs({
+ // 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",
+ });
+ dispatch({
+ type: "ADD_SOURCEMAP_IGNORE_LIST_SOURCES",
+ [PROMISE]: sourceMapLoader.getSourceMapIgnoreList(source.id),
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ if (!data || !data.length) {
+ // 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",
+ cx,
+ sourceActorId: sourceActor.id,
+ });
+ return [];
+ }
+
+ validateNavigateContext(getState(), cx);
+ return data;
+ };
+}
+
+// If a request has been made to show this source, go ahead and
+// select it.
+function checkSelectedSource(cx, 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(togglePrettyPrint(cx, source.id));
+ dispatch(checkPendingBreakpoints(cx, prettySource, null));
+ return;
+ }
+
+ await dispatch(
+ selectLocation(
+ cx,
+ createLocation({
+ source,
+ line:
+ typeof pendingLocation.line === "number"
+ ? pendingLocation.line
+ : 0,
+ column: pendingLocation.column,
+ })
+ )
+ );
+ }
+ };
+}
+
+function checkPendingBreakpoints(cx, 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(cx, source, sourceActor));
+ await dispatch(
+ setBreakableLines(cx, createLocation({ source, sourceActor }))
+ );
+
+ await Promise.all(
+ pendingBreakpoints.map(pendingBp => {
+ return dispatch(syncPendingBreakpoint(cx, source.id, pendingBp));
+ })
+ );
+ };
+}
+
+function restoreBlackBoxedSources(cx, 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(cx, source, true, ranges));
+ }
+ }
+
+ if (prefs.sourceMapIgnoreListEnabled) {
+ await dispatch(toggleSourceMapIgnoreList(cx, 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)
+ );
+ }
+
+ const cx = getContext(state);
+
+ // Add the original sources per the generated source actors that
+ // they are primarily from.
+ actors.forEach(sourceActor => {
+ dispatch({
+ type: "ADD_ORIGINAL_SOURCES",
+ cx,
+ 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(cx, sources));
+
+ for (const source of sources) {
+ dispatch(checkPendingBreakpoints(cx, 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);
+
+ const cx = getContext(getState());
+ dispatch(addSources(cx, newSources));
+ dispatch(insertSourceActors(newSourceActors));
+
+ await dispatch(checkNewSources(cx, newSources));
+
+ (async () => {
+ await dispatch(loadSourceMaps(cx, 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(
+ cx,
+ createLocation({ source: sourceActor.sourceObject, sourceActor })
+ )
+ );
+ }
+ dispatch(
+ checkPendingBreakpoints(cx, sourceActor.sourceObject, sourceActor)
+ );
+ }
+ })();
+
+ return resultIds.map(id => getSourceFromId(getState(), id));
+ };
+}
+
+function addSources(cx, sources) {
+ return ({ dispatch, getState }) => {
+ dispatch({ type: "ADD_SOURCES", cx, sources });
+ };
+}
+
+function checkNewSources(cx, sources) {
+ return async ({ dispatch, getState }) => {
+ for (const source of sources) {
+ dispatch(checkSelectedSource(cx, source.id));
+ }
+
+ await dispatch(restoreBlackBoxedSources(cx, 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..66e3f4129b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/prettyPrint.js
@@ -0,0 +1,339 @@
+/* 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,
+ originalToGeneratedId,
+} from "devtools/client/shared/source-map-loader/index";
+
+import assert from "../../utils/assert";
+import { recordEvent } from "../../utils/telemetry";
+import { updateBreakpointsForNewPrettyPrintedSource } from "../breakpoints";
+import { createLocation } from "../../utils/location";
+
+import {
+ getPrettySourceURL,
+ isGenerated,
+ isJavaScript,
+} from "../../utils/source";
+import { isFulfilled } from "../../utils/async-value";
+import { getOriginalLocation } from "../../utils/source-maps";
+import { prefs } from "../../utils/prefs";
+import {
+ loadGeneratedSourceText,
+ loadOriginalSourceText,
+} from "./loadSourceText";
+import { mapFrames } from "../pause";
+import { selectSpecificLocation } from "../sources";
+import { createPrettyPrintOriginalSource } from "../../client/firefox/create";
+
+import {
+ getSource,
+ getFirstSourceActorForGeneratedSource,
+ getSourceByURL,
+ getSelectedLocation,
+ getThreadContext,
+} from "../../selectors";
+
+import { selectSource } from "./select";
+
+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 prettyPrintSource(
+ 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(cx, source) {
+ 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",
+ cx,
+ originalSources: [prettySource],
+ });
+ return prettySource;
+ };
+}
+
+function selectPrettyLocation(cx, 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 &&
+ location.sourceId == originalToGeneratedId(prettySource.id)
+ ) {
+ location = await getOriginalLocation(location, thunkArgs);
+
+ return dispatch(
+ selectSpecificLocation(
+ cx,
+ createLocation({ ...location, source: prettySource })
+ )
+ );
+ }
+
+ return dispatch(selectSource(cx, prettySource));
+ };
+}
+
+/**
+ * Toggle the pretty printing of a source's text.
+ * Nothing will happen for non-javascript files.
+ *
+ * @param Object cx
+ * @param String sourceId
+ * The source ID for the minified/generated source object.
+ * @returns Promise
+ * A promise that resolves to the Pretty print/original source object.
+ */
+export function togglePrettyPrint(cx, sourceId) {
+ return async ({ dispatch, getState }) => {
+ const source = getSource(getState(), sourceId);
+ if (!source) {
+ return {};
+ }
+
+ if (!source.isPrettyPrinted) {
+ recordEvent("pretty_print");
+ }
+
+ assert(
+ isGenerated(source),
+ "Pretty-printing only allowed on generated sources"
+ );
+
+ const sourceActor = getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+
+ await dispatch(loadGeneratedSourceText({ cx, sourceActor }));
+
+ const url = getPrettySourceURL(source.url);
+ const prettySource = getSourceByURL(getState(), url);
+
+ if (prettySource) {
+ return dispatch(selectPrettyLocation(cx, prettySource));
+ }
+
+ const newPrettySource = await dispatch(createPrettySource(cx, source));
+
+ // Force loading the pretty source/original text.
+ // This will end up calling prettyPrintSource() of this module, and
+ // more importantly, will populate the sourceMapLoader, which is used by selectPrettyLocation.
+ await dispatch(loadOriginalSourceText({ cx, source: newPrettySource }));
+ // 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
+ await dispatch(selectPrettyLocation(cx, newPrettySource));
+
+ const threadcx = getThreadContext(getState());
+ // Update frames to the new pretty/original source (in case we were paused)
+ await dispatch(mapFrames(threadcx));
+ // Update breakpoints locations to the new pretty/original source
+ await dispatch(updateBreakpointsForNewPrettyPrintedSource(cx, sourceId));
+
+ return newPrettySource;
+ };
+}
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..c4443432a0
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/select.js
@@ -0,0 +1,264 @@
+/* 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 { isOriginalId } from "devtools/client/shared/source-map-loader/index";
+
+import { setSymbols } from "./symbols";
+import { setInScopeLines } from "../ast";
+import { togglePrettyPrint } from "./prettyPrint";
+import { addTab, closeTab } from "../tabs";
+import { loadSourceText } from "./loadSourceText";
+import { mapDisplayNames } from "../pause";
+import { setBreakableLines } from ".";
+
+import { prefs } from "../../utils/prefs";
+import { isMinified } from "../../utils/source";
+import { createLocation } from "../../utils/location";
+import { getRelatedMapLocation } from "../../utils/source-maps";
+
+import {
+ getSource,
+ getFirstSourceActorForGeneratedSource,
+ getSourceByURL,
+ getPrettySource,
+ getSelectedLocation,
+ getShouldSelectOriginalLocation,
+ canPrettyPrintSource,
+ getIsCurrentThreadPaused,
+ getSourceTextContent,
+ tabExists,
+} from "../../selectors";
+
+// This is only used by jest tests (and within this module)
+export const setSelectedLocation = (
+ cx,
+ location,
+ shouldSelectOriginalLocation
+) => ({
+ type: "SET_SELECTED_LOCATION",
+ cx,
+ location,
+ shouldSelectOriginalLocation,
+});
+
+// This is only used by jest tests (and within this module)
+export const setPendingSelectedLocation = (cx, url, options) => ({
+ type: "SET_PENDING_SELECTED_LOCATION",
+ cx,
+ url,
+ line: options?.line,
+ column: options?.column,
+});
+
+// This is only used by jest tests (and within this module)
+export const clearSelectedLocation = cx => ({
+ type: "CLEAR_SELECTED_LOCATION",
+ cx,
+});
+
+/**
+ * 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(cx, url, options) {
+ return async ({ dispatch, getState }) => {
+ const source = getSourceByURL(getState(), url);
+ if (!source) {
+ return dispatch(setPendingSelectedLocation(cx, url, options));
+ }
+
+ const location = createLocation({ ...options, source });
+ return dispatch(selectLocation(cx, 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 {Object} cx
+ * @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(cx, 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(cx, 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} cx
+ * @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.
+ */
+export function selectLocation(cx, location, { keepContext = 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(cx));
+ 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) {
+ if (shouldSelectOriginalLocation != isOriginalId(location.sourceId)) {
+ // 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 = isOriginalId(location.sourceId);
+ }
+
+ 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(cx, location, shouldSelectOriginalLocation));
+
+ await dispatch(loadSourceText(cx, source, sourceActor));
+
+ await dispatch(setBreakableLines(cx, location));
+
+ 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(togglePrettyPrint(cx, loadedSource.id));
+ dispatch(closeTab(cx, loadedSource));
+ }
+
+ await dispatch(setSymbols({ cx, location }));
+ dispatch(setInScopeLines(cx));
+
+ if (getIsCurrentThreadPaused(getState())) {
+ await dispatch(mapDisplayNames(cx));
+ }
+ };
+}
+
+/**
+ * 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} cx
+ * @param {Object} location
+ * The location to select, object which includes enough
+ * information to specify a precise source, line and column.
+ */
+export function selectSpecificLocation(cx, location) {
+ return selectLocation(cx, location, { keepContext: false });
+}
+
+/**
+ * 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(cx, 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(cx, pairedLocation));
+ };
+}
+
+// This is only used by tests
+export function jumpToMappedSelectedLocation(cx) {
+ return async function ({ dispatch, getState }) {
+ const location = getSelectedLocation(getState());
+ if (!location) {
+ return;
+ }
+
+ await dispatch(jumpToMappedLocation(cx, 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..5a1fb1f967
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/symbols.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 { getSymbols } from "../../selectors";
+
+import { PROMISE } from "../utils/middleware/promise";
+import { loadSourceText } from "./loadSourceText";
+
+import { memoizeableAction } from "../../utils/memoizableAction";
+import { fulfilled } from "../../utils/async-value";
+
+async function doSetSymbols(
+ cx,
+ location,
+ { dispatch, getState, parserWorker }
+) {
+ await dispatch(loadSourceText(cx, location.source, location.sourceActor));
+
+ await dispatch({
+ type: "SET_SYMBOLS",
+ cx,
+ location,
+ [PROMISE]: parserWorker.getSymbols(location.sourceId),
+ });
+}
+
+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.sourceId,
+ action: ({ cx, location }, thunkArgs) =>
+ doSetSymbols(cx, location, thunkArgs),
+});
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..2ff8420b23
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/tests/blackbox.spec.js
@@ -0,0 +1,249 @@
+/* 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, cx } = store;
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo"))
+ );
+ await dispatch(actions.toggleBlackBox(cx, fooSource));
+
+ expect(selectors.isSourceBlackBoxed(getState(), fooSource)).toEqual(true);
+
+ let blackboxRanges = selectors.getBlackBoxRanges(getState());
+ expect(blackboxRanges[fooSource.url]).toEqual([]);
+
+ await dispatch(actions.toggleBlackBox(cx, 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, cx } = 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(cx, 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(cx, 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, cx } = 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(cx, 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(cx, 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(cx, 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(cx, 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, cx } = 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(cx, 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(cx, 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, cx } = 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(cx, 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..f81fc856dd
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/tests/loadSource.spec.js
@@ -0,0 +1,363 @@
+/* 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,
+ makeOriginalSource,
+ makeSource,
+} from "../../../utils/test-head";
+import {
+ createSource,
+ mockCommandClient,
+} from "../../tests/helpers/mockCommandClient";
+import { getBreakpointsList } from "../../../selectors";
+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, cx } = store;
+
+ const foo1Source = await dispatch(
+ actions.newGeneratedSource(makeSource("foo1"))
+ );
+ const foo1SourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ foo1Source.id
+ );
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: 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({
+ cx,
+ sourceActor: 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("should update breakpoint text when a source loads", async () => {
+ const fooOrigContent = createSource("fooOrig", "var fooOrig = 42;");
+ const fooGenContent = createSource("fooGen", "var fooGen = 42;");
+
+ const store = createStore(
+ {
+ ...mockCommandClient,
+ sourceContents: async () => fooGenContent,
+ getSourceActorBreakpointPositions: async () => ({ 1: [0] }),
+ getSourceActorBreakableLines: async () => [],
+ },
+ {},
+ {
+ getGeneratedRangesForOriginal: async () => [
+ { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } },
+ ],
+ getOriginalLocations: async items =>
+ items.map(item => ({
+ ...item,
+ sourceId:
+ item.sourceId === fooGenSource1.id
+ ? fooOrigSources1[0].id
+ : fooOrigSources2[0].id,
+ })),
+ getOriginalSourceText: async s => ({
+ text: fooOrigContent.source,
+ contentType: fooOrigContent.contentType,
+ }),
+ }
+ );
+ const { cx, dispatch, getState } = store;
+
+ const fooGenSource1 = await dispatch(
+ actions.newGeneratedSource(makeSource("fooGen1"))
+ );
+
+ const fooOrigSources1 = await dispatch(
+ actions.newOriginalSources([makeOriginalSource(fooGenSource1)])
+ );
+ const fooGenSource2 = await dispatch(
+ actions.newGeneratedSource(makeSource("fooGen2"))
+ );
+
+ const fooOrigSources2 = await dispatch(
+ actions.newOriginalSources([makeOriginalSource(fooGenSource2)])
+ );
+
+ await dispatch(
+ actions.loadOriginalSourceText({
+ cx,
+ source: fooOrigSources1[0],
+ })
+ );
+
+ await dispatch(
+ actions.addBreakpoint(
+ cx,
+ createLocation({
+ source: fooOrigSources1[0],
+ line: 1,
+ column: 0,
+ }),
+ {}
+ )
+ );
+
+ const breakpoint1 = getBreakpointsList(getState())[0];
+ expect(breakpoint1.text).toBe("");
+ expect(breakpoint1.originalText).toBe("var fooOrig = 42;");
+
+ const fooGenSource1SourceActor =
+ selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ fooGenSource1.id
+ );
+
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: fooGenSource1SourceActor,
+ })
+ );
+
+ const breakpoint2 = getBreakpointsList(getState())[0];
+ expect(breakpoint2.text).toBe("var fooGen = 42;");
+ expect(breakpoint2.originalText).toBe("var fooOrig = 42;");
+
+ const fooGenSource2SourceActor =
+ selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ fooGenSource2.id
+ );
+
+ await dispatch(
+ actions.loadGeneratedSourceText({
+ cx,
+ sourceActor: fooGenSource2SourceActor,
+ })
+ );
+
+ await dispatch(
+ actions.addBreakpoint(
+ cx,
+ createLocation({
+ source: fooGenSource2,
+ line: 1,
+ column: 0,
+ }),
+ {}
+ )
+ );
+
+ const breakpoint3 = getBreakpointsList(getState())[1];
+ expect(breakpoint3.text).toBe("var fooGen = 42;");
+ expect(breakpoint3.originalText).toBe("");
+
+ await dispatch(
+ actions.loadOriginalSourceText({
+ cx,
+ source: fooOrigSources2[0],
+ })
+ );
+
+ const breakpoint4 = getBreakpointsList(getState())[1];
+ expect(breakpoint4.text).toBe("var fooGen = 42;");
+ expect(breakpoint4.originalText).toBe("var fooOrig = 42;");
+ });
+
+ it("loads two sources w/ one request", async () => {
+ let resolve;
+ let count = 0;
+ const { dispatch, getState, cx } = 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({ cx, sourceActor }));
+
+ const loading = dispatch(
+ actions.loadGeneratedSourceText({ cx, 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, cx } = 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({ cx, sourceActor })
+ );
+
+ if (!resolve) {
+ throw new Error("no resolve");
+ }
+ resolve({ source: "yay", contentType: "text/javascript" });
+ await loading;
+
+ await dispatch(actions.loadGeneratedSourceText({ cx, 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, cx, 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({ cx, sourceActor }));
+
+ expect(wasLoading()).toBe(true);
+ });
+
+ it("should indicate an errored source text", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("bad-id"))
+ );
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+ await dispatch(actions.loadGeneratedSourceText({ cx, 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..730c5b32eb
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/tests/newSources.spec.js
@@ -0,0 +1,172 @@
+/* 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,
+ waitForState,
+} from "../../../utils/test-head";
+const { getSource, getSourceCount, getSelectedSource, getSourceByURL } =
+ selectors;
+import sourceQueue from "../../../utils/source-queue";
+import { generatedToOriginalId } from "devtools/client/shared/source-map-loader/index";
+
+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, cx } = createStore(mockCommandClient);
+ const baseSourceURL = makeSourceURL("base.js");
+ await dispatch(actions.selectSourceURL(cx, 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);
+ });
+
+ it("should add original sources", async () => {
+ const { dispatch, getState } = createStore(
+ mockCommandClient,
+ {},
+ {
+ getOriginalURLs: async source => [
+ {
+ id: generatedToOriginalId(source.id, "magic.js"),
+ url: "magic.js",
+ },
+ ],
+ getOriginalLocations: async items => items,
+ getOriginalLocation: location => location,
+ }
+ );
+
+ await dispatch(
+ actions.newGeneratedSource(
+ makeSource("base.js", { sourceMapURL: "base.js.map" })
+ )
+ );
+ const magic = getSourceByURL(getState(), "magic.js");
+ expect(magic && magic.url).toEqual("magic.js");
+ });
+
+ // eslint-disable-next-line
+ it("should not attempt to fetch original sources if it's missing a source map url", async () => {
+ const getOriginalURLs = jest.fn();
+ const { dispatch } = createStore(
+ mockCommandClient,
+ {},
+ {
+ getOriginalURLs,
+ getOriginalLocations: async items => items,
+ getOriginalLocation: location => location,
+ }
+ );
+
+ await dispatch(actions.newGeneratedSource(makeSource("base.js")));
+ expect(getOriginalURLs).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,
+ {},
+ {
+ getOriginalURLs: 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");
+ });
+
+ // eslint-disable-next-line
+ it("shouldn't let one slow loading source map delay all the other source maps", async () => {
+ const dbg = createStore(
+ mockCommandClient,
+ {},
+ {
+ getOriginalURLs: async source => {
+ if (source.id == "foo.js") {
+ // simulate a hang loading foo.js.map
+ return new Promise(_ => {});
+ }
+ const url = source.id.replace(".js", ".cljs");
+ return [
+ {
+ id: generatedToOriginalId(source.id, url),
+ url,
+ },
+ ];
+ },
+ getOriginalLocations: async items => items,
+ getGeneratedLocation: location => location,
+ }
+ );
+ const { dispatch, getState } = dbg;
+ await dispatch(
+ actions.newGeneratedSources([
+ makeSource("foo.js", { sourceMapURL: "foo.js.map" }),
+ makeSource("bar.js", { sourceMapURL: "bar.js.map" }),
+ makeSource("bazz.js", { sourceMapURL: "bazz.js.map" }),
+ ])
+ );
+ await sourceQueue.flush();
+ await waitForState(dbg, state => getSourceCount(state) == 5);
+ expect(getSourceCount(getState())).toEqual(5);
+ const barCljs = getSourceByURL(getState(), "bar.cljs");
+ expect(barCljs && barCljs.url).toEqual("bar.cljs");
+ const bazzCljs = getSourceByURL(getState(), "bazz.cljs");
+ expect(bazzCljs && bazzCljs.url).toEqual("bazz.cljs");
+ });
+});
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..3fcf24f2b7
--- /dev/null
+++ b/devtools/client/debugger/src/actions/sources/tests/select.spec.js
@@ -0,0 +1,288 @@
+/* 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,
+ makeFrame,
+ makeSource,
+ makeSourceURL,
+ waitForState,
+ makeOriginalSource,
+} from "../../../utils/test-head";
+import {
+ getSource,
+ 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 select a source", async () => {
+ // Note that we pass an empty client in because the action checks
+ // if it exists.
+ const store = createStore(mockCommandClient);
+ const { dispatch, getState } = store;
+
+ const frame = makeFrame({ id: "1", sourceId: "foo1" });
+
+ const baseSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo1"))
+ );
+ await dispatch(
+ actions.paused({
+ thread: "FakeThread",
+ why: { type: "debuggerStatement" },
+ frame,
+ frames: [frame],
+ })
+ );
+
+ const cx = selectors.getThreadContext(getState());
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({ source: baseSource, line: 1, column: 5 })
+ )
+ );
+
+ const selectedSource = getSelectedSource(getState());
+ if (!selectedSource) {
+ throw new Error("bad selectedSource");
+ }
+ expect(selectedSource.id).toEqual("foo1");
+
+ const source = getSource(getState(), selectedSource.id);
+ if (!source) {
+ throw new Error("bad source");
+ }
+ expect(source.id).toEqual("foo1");
+ });
+
+ it("should select next tab on tab closed if no previous tab", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ await dispatch(actions.newGeneratedSource(makeSource("bar.js")));
+ await dispatch(actions.newGeneratedSource(makeSource("baz.js")));
+
+ // 3rd tab
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+
+ // 2nd tab
+ await dispatch(actions.selectLocation(cx, initialLocation("bar.js")));
+
+ // 1st tab
+ await dispatch(actions.selectLocation(cx, initialLocation("baz.js")));
+
+ // 3rd tab is reselected
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+
+ // closes the 1st tab, which should have no previous tab
+ await dispatch(actions.closeTab(cx, fooSource));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("bar.js");
+ expect(getSourceTabs(getState())).toHaveLength(2);
+ });
+
+ it("should open a tab for the source", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+ await dispatch(actions.newGeneratedSource(makeSource("foo.js")));
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+
+ const tabs = getSourceTabs(getState());
+ expect(tabs).toHaveLength(1);
+ expect(tabs[0].url).toEqual("http://localhost:8000/examples/foo.js");
+ });
+
+ it("should select previous tab on tab closed", async () => {
+ const { dispatch, getState, cx } = 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"))
+ );
+
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+ await dispatch(actions.selectLocation(cx, initialLocation("bar.js")));
+ await dispatch(actions.selectLocation(cx, initialLocation("baz.js")));
+ await dispatch(actions.closeTab(cx, bazSource));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("bar.js");
+ expect(getSourceTabs(getState())).toHaveLength(2);
+ });
+
+ it("should keep the selected source when other tab closed", async () => {
+ const { dispatch, getState, cx } = 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(cx, initialLocation("foo.js")));
+
+ // 2nd tab
+ await dispatch(actions.selectLocation(cx, initialLocation("bar.js")));
+
+ // 1st tab
+ await dispatch(actions.selectLocation(cx, initialLocation("baz.js")));
+
+ // 3rd tab is reselected
+ await dispatch(actions.selectLocation(cx, initialLocation("foo.js")));
+ await dispatch(actions.closeTab(cx, 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, cx } = createStore(mockCommandClient);
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("testSource"))
+ );
+ const location = createLocation({ source });
+
+ // set value
+ dispatch(actions.setSelectedLocation(cx, location));
+ expect(getSelectedLocation(getState())).toEqual({
+ sourceId: source.id,
+ ...location,
+ });
+
+ // clear value
+ dispatch(actions.clearSelectedLocation(cx));
+ expect(getSelectedLocation(getState())).toEqual(null);
+ });
+
+ it("sets and clears pending selected location correctly", () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+ const url = "testURL";
+ const options = { line: "testLine", column: "testColumn" };
+
+ // set value
+ dispatch(actions.setPendingSelectedLocation(cx, url, options));
+ const setResult = getState().sources.pendingSelectedLocation;
+ expect(setResult).toEqual({
+ url,
+ line: options.line,
+ column: options.column,
+ });
+
+ // clear value
+ dispatch(actions.clearSelectedLocation(cx));
+ 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, cx } = 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(cx, 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, cx } = 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(cx, baseSources[0]));
+
+ await dispatch(
+ actions.selectSpecificLocation(
+ cx,
+ 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, cx } = createStore(mockCommandClient);
+ const baseSourceURL = makeSourceURL("base.js");
+ await dispatch(actions.selectSourceURL(cx, 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..1b3c0d3f43
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tabs.js
@@ -0,0 +1,76 @@
+/* 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
+ * @module actions/tabs
+ */
+
+import { removeDocument } from "../utils/editor";
+import { selectSource } from "./sources";
+
+import {
+ getSourceByURL,
+ getSourceTabs,
+ getNewSelectedSource,
+} from "../selectors";
+
+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,
+ };
+}
+
+/**
+ * @memberof actions/tabs
+ * @static
+ */
+export function closeTab(cx, source, reason = "click") {
+ return ({ dispatch, getState, client }) => {
+ removeDocument(source.id);
+
+ const tabs = getSourceTabs(getState());
+ dispatch({ type: "CLOSE_TAB", source });
+
+ const newSource = getNewSelectedSource(getState(), tabs);
+ dispatch(selectSource(cx, newSource));
+ };
+}
+
+/**
+ * @memberof actions/tabs
+ * @static
+ */
+export function closeTabs(cx, urls) {
+ return ({ dispatch, getState, client }) => {
+ const sources = urls
+ .map(url => getSourceByURL(getState(), url))
+ .filter(Boolean);
+
+ const tabs = getSourceTabs(getState());
+ sources.map(source => removeDocument(source.id));
+ dispatch({ type: "CLOSE_TABS", sources });
+
+ const source = getNewSelectedSource(getState(), tabs);
+ dispatch(selectSource(cx, source));
+ };
+}
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..741e45d6d8
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/__snapshots__/pending-breakpoints.spec.js.snap
@@ -0,0 +1,44 @@
+// 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,
+ "source": Object {
+ "id": "",
+ "url": "http://localhost:8000/examples/bar.js",
+ },
+ "sourceActor": null,
+ "sourceActorId": undefined,
+ "sourceId": "",
+ "sourceUrl": "http://localhost:8000/examples/bar.js",
+ },
+ "location": Object {
+ "column": 2,
+ "line": 5,
+ "source": Object {
+ "id": "",
+ "url": "http://localhost:8000/examples/bar.js",
+ },
+ "sourceActor": null,
+ "sourceActorId": undefined,
+ "sourceId": "",
+ "sourceUrl": "http://localhost:8000/examples/bar.js",
+ },
+ "options": Object {
+ "condition": null,
+ "hidden": false,
+ },
+ },
+}
+`;
diff --git a/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap b/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap
new file mode 100644
index 0000000000..026bfe4a89
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/__snapshots__/preview.spec.js.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`preview should generate previews 1`] = `null`;
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..48b06ebd1a
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/expressions.spec.js
@@ -0,0 +1,184 @@
+/* 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 { makeMockFrame } from "../../utils/test-mockup";
+
+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, cx } = createStore(mockThreadFront);
+
+ await dispatch(actions.addExpression(cx, "foo"));
+ expect(selectors.getExpressions(getState())).toHaveLength(1);
+ });
+
+ it("should not add empty expressions", () => {
+ const { dispatch, getState, cx } = createStore(mockThreadFront);
+
+ dispatch(actions.addExpression(cx, undefined));
+ dispatch(actions.addExpression(cx, ""));
+ expect(selectors.getExpressions(getState())).toHaveLength(0);
+ });
+
+ it("should not add invalid expressions", async () => {
+ const { dispatch, getState, cx } = createStore(mockThreadFront);
+ await dispatch(actions.addExpression(cx, "foo#"));
+ const state = getState();
+ expect(selectors.getExpressions(state)).toHaveLength(0);
+ expect(selectors.getExpressionError(state)).toBe(true);
+ });
+
+ it("should update an expression", async () => {
+ const { dispatch, getState, cx } = createStore(mockThreadFront);
+
+ await dispatch(actions.addExpression(cx, "foo"));
+ const expression = selectors.getExpression(getState(), "foo");
+ if (!expression) {
+ throw new Error("expression must exist");
+ }
+
+ await dispatch(actions.updateExpression(cx, "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, cx } = createStore(mockThreadFront);
+
+ await dispatch(actions.addExpression(cx, "foo"));
+ const expression = selectors.getExpression(getState(), "foo");
+ if (!expression) {
+ throw new Error("expression must exist");
+ }
+ await dispatch(actions.updateExpression(cx, "#bar", expression));
+ expect(selectors.getExpression(getState(), "bar")).toBeUndefined();
+ });
+
+ it("should delete an expression", async () => {
+ const { dispatch, getState, cx } = createStore(mockThreadFront);
+
+ await dispatch(actions.addExpression(cx, "foo"));
+ await dispatch(actions.addExpression(cx, "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, cx } = createStore(mockThreadFront);
+ await dispatch(actions.addExpression(cx, "foo"));
+ await dispatch(actions.addExpression(cx, "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(cx));
+ foo = selectors.getExpression(getState(), "foo");
+ bar = selectors.getExpression(getState(), "bar");
+ expect(foo && foo.value).toBe("bla");
+ expect(bar && bar.value).toBe("bla");
+ });
+
+ it("should evaluate expressions in specific scope", async () => {
+ const { dispatch, getState } = createStore(mockThreadFront);
+ await createFrames(getState, dispatch);
+
+ const cx = selectors.getThreadContext(getState());
+ await dispatch(actions.newGeneratedSource(makeSource("source")));
+ await dispatch(actions.addExpression(cx, "foo"));
+ await dispatch(actions.addExpression(cx, "bar"));
+
+ let foo = selectors.getExpression(getState(), "foo");
+ let bar = selectors.getExpression(getState(), "bar");
+ expect(foo && foo.value).toBe("boo");
+ expect(bar && bar.value).toBe("boo");
+
+ await dispatch(actions.evaluateExpressions(cx));
+ foo = selectors.getExpression(getState(), "foo");
+ bar = selectors.getExpression(getState(), "bar");
+ expect(foo && foo.value).toBe("boo");
+ expect(bar && bar.value).toBe("boo");
+ });
+
+ it("should get the autocomplete matches for the input", async () => {
+ const { cx, dispatch, getState } = createStore(mockThreadFront);
+ await dispatch(actions.autocomplete(cx, "to", 2));
+ expect(selectors.getAutocompleteMatchset(getState())).toMatchSnapshot();
+ });
+});
+
+async function createFrames(getState, dispatch) {
+ const frame = makeMockFrame();
+ await dispatch(actions.newGeneratedSource(makeSource("example.js")));
+ await dispatch(actions.newGeneratedSource(makeSource("source")));
+
+ await dispatch(
+ actions.paused({
+ thread: "FakeThread",
+ frame,
+ frames: [frame],
+ why: { type: "just because" },
+ })
+ );
+
+ await dispatch(
+ actions.selectFrame(selectors.getThreadContext(getState()), frame)
+ );
+}
diff --git a/devtools/client/debugger/src/actions/tests/fixtures/immutable.js b/devtools/client/debugger/src/actions/tests/fixtures/immutable.js
new file mode 100644
index 0000000000..e8ac7fb233
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/fixtures/immutable.js
@@ -0,0 +1,2 @@
+
+const m = Immutable.Map({a: 2})
diff --git a/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js b/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js
new file mode 100644
index 0000000000..526c852d99
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/fixtures/reactComponent.js
@@ -0,0 +1,7 @@
+import React, { Component } from "react";
+
+class FixtureComponent extends Component {
+ render() {
+ return null;
+ }
+}
diff --git a/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js b/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js
new file mode 100644
index 0000000000..3103161fe0
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/fixtures/reactFuncComponent.js
@@ -0,0 +1,5 @@
+import React, { Component } from "react";
+
+export default FixtureComponent = (props) => {
+ return <div>props.a</div>;
+}
diff --git a/devtools/client/debugger/src/actions/tests/fixtures/scopes.js b/devtools/client/debugger/src/actions/tests/fixtures/scopes.js
new file mode 100644
index 0000000000..3a38097f5e
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/fixtures/scopes.js
@@ -0,0 +1,11 @@
+// Program Scope
+
+function outer() {
+ function inner() {
+ const x = 1;
+ }
+
+ const declaration = function() {
+ const x = 1;
+ };
+}
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..e0279e5fc1
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/helpers/breakpoints.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 { createLocation } from "../../../utils/location";
+
+export function mockPendingBreakpoint(overrides = {}) {
+ const { sourceUrl, line, column, condition, disabled, hidden } = overrides;
+ return {
+ location: createLocation({
+ source: {
+ id: "",
+ url: sourceUrl || "http://localhost:8000/examples/bar.js",
+ },
+ sourceId: "",
+ sourceUrl: sourceUrl || "http://localhost:8000/examples/bar.js",
+ line: line || 5,
+ column: column || 1,
+ }),
+ generatedLocation: createLocation({
+ source: {
+ id: "",
+ url: sourceUrl || "http://localhost:8000/examples/bar.js",
+ },
+ sourceId: "",
+ 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,
+ },
+ sourceUrl: `http://localhost:8000/examples/${filename}`,
+ sourceId: filename,
+ line,
+ column,
+ }),
+ generatedLocation: createLocation({
+ source: {
+ url: `http://localhost:8000/examples/${filename}`,
+ id: filename,
+ },
+ sourceUrl: `http://localhost:8000/examples/${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/navigation.spec.js b/devtools/client/debugger/src/actions/tests/navigation.spec.js
new file mode 100644
index 0000000000..e2572fde80
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/navigation.spec.js
@@ -0,0 +1,29 @@
+/* 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 } from "../../utils/test-head";
+
+jest.mock("../../utils/editor");
+
+const { getActiveSearch } = selectors;
+
+const threadFront = {
+ sourceContents: async () => ({
+ source: "function foo1() {\n const foo = 5; return foo;\n}",
+ contentType: "text/javascript",
+ }),
+ getSourceActorBreakpointPositions: async () => ({}),
+ getSourceActorBreakableLines: async () => [],
+};
+
+describe("navigation", () => {
+ it("navigation removes activeSearch 'file' value", async () => {
+ const { dispatch, getState } = createStore(threadFront);
+ dispatch(actions.setActiveSearch("file"));
+ expect(getActiveSearch(getState())).toBe("file");
+
+ await dispatch(actions.willNavigate("will-navigate"));
+ expect(getActiveSearch(getState())).toBe(null);
+ });
+});
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..2baa17a79f
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/pending-breakpoints.spec.js
@@ -0,0 +1,294 @@
+/* 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,
+ makeSourceURL,
+ 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, cx } = 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({ cx, sourceActor }));
+
+ const bp = generateBreakpoint("foo.js", 5, 1);
+
+ await dispatch(actions.addBreakpoint(cx, 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, cx } = 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({ cx, sourceActor }));
+ await dispatch(actions.addBreakpoint(cx, bar.location));
+
+ const bps = selectors.getPendingBreakpointList(getState());
+ expect(bps).toHaveLength(2);
+ });
+
+ it("adding bps doesn't remove existing pending breakpoints", async () => {
+ const { dispatch, getState, cx } = 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({ cx, sourceActor }));
+
+ await dispatch(actions.addBreakpoint(cx, 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, cx } = store;
+
+ const source = await dispatch(
+ actions.newGeneratedSource(makeSource("bar.js"))
+ );
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ source.id
+ );
+
+ await dispatch(actions.loadGeneratedSourceText({ cx, sourceActor }));
+
+ await waitForState(store, state => {
+ const bps = selectors.getBreakpointsForSource(state, source.id);
+ return bps && !!Object.values(bps).length;
+ });
+
+ const bp = selectors.getBreakpointsList(getState()).find(({ location }) => {
+ return (
+ location.line == 5 &&
+ location.column == 2 &&
+ location.sourceId == source.id
+ );
+ });
+
+ if (!bp) {
+ throw new Error("no bp");
+ }
+ expect(bp.location.sourceId).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, cx } = 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({ cx, sourceActor }));
+
+ await waitForState(store, state => selectors.getBreakpointCount(state) > 0);
+
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ });
+
+ it("corresponding breakpoints are added to the original source", async () => {
+ const sourceURL = makeSourceURL("bar.js");
+ const store = createStore(mockClient({ 5: [2] }), loadInitialState(), {
+ getOriginalURLs: async source => [
+ {
+ id: sourceMapLoader.generatedToOriginalId(source.id, sourceURL),
+ url: sourceURL,
+ },
+ ],
+ getOriginalSourceText: async () => ({ text: "" }),
+ getGeneratedLocation: async location => location,
+ getOriginalLocation: async location => location,
+ getGeneratedRangesForOriginal: async () => [
+ { start: { line: 0, column: 0 }, end: { line: 10, column: 10 } },
+ ],
+ getOriginalLocations: async items =>
+ items.map(item => ({
+ ...item,
+ sourceId: sourceMapLoader.generatedToOriginalId(
+ item.sourceId,
+ sourceURL
+ ),
+ })),
+ });
+
+ const { getState, dispatch } = store;
+
+ expect(selectors.getBreakpointCount(getState())).toEqual(0);
+
+ await dispatch(
+ actions.newGeneratedSource(makeSource("bar.js", { sourceMapURL: "foo" }))
+ );
+
+ 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, cx } = 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({ cx, sourceActor: sourceActor1 })
+ );
+ await dispatch(
+ actions.loadGeneratedSourceText({ cx, sourceActor: sourceActor2 })
+ );
+
+ await waitForState(store, state => selectors.getBreakpointCount(state) > 0);
+ expect(selectors.getBreakpointCount(getState())).toEqual(1);
+ });
+});
diff --git a/devtools/client/debugger/src/actions/tests/preview.spec.js b/devtools/client/debugger/src/actions/tests/preview.spec.js
new file mode 100644
index 0000000000..3b6c9c23ac
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/preview.spec.js
@@ -0,0 +1,217 @@
+/* eslint max-nested-callbacks: ["error", 6] */
+/* 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,
+ makeFrame,
+ waitForState,
+ waitATick,
+} from "../../utils/test-head";
+import { createLocation } from "../../utils/location";
+
+function waitForPreview(store, expression) {
+ return waitForState(store, state => {
+ const preview = selectors.getPreview(state);
+ return preview && preview.expression == expression;
+ });
+}
+
+function mockThreadFront(overrides) {
+ return {
+ evaluate: async () => ({ result: {} }),
+ getFrameScopes: async () => {},
+ getFrames: async () => [],
+ sourceContents: async () => ({
+ source: "",
+ contentType: "text/javascript",
+ }),
+ getSourceActorBreakpointPositions: async () => ({}),
+ getSourceActorBreakableLines: async () => [],
+ evaluateExpressions: async () => [],
+ loadObjectProperties: async () => ({}),
+ ...overrides,
+ };
+}
+
+function dispatchSetPreview(dispatch, context, expression, target) {
+ return dispatch(
+ actions.setPreview(
+ context,
+ expression,
+ {
+ start: { url: "foo.js", line: 1, column: 2 },
+ end: { url: "foo.js", line: 1, column: 5 },
+ },
+ { line: 2, column: 3 },
+ target.getBoundingClientRect(),
+ target
+ )
+ );
+}
+
+async function pause(store, client) {
+ const { dispatch, cx, getState } = store;
+ const source = makeSource("base.js");
+ const base = await dispatch(actions.newGeneratedSource(source));
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ base.id
+ );
+
+ await dispatch(actions.selectSource(cx, base, sourceActor));
+ const location = createLocation({ source: base, sourceActor });
+ await waitForState(store, state => selectors.getSymbols(state, location));
+
+ const { thread } = cx;
+ const frames = [makeFrame({ id: "frame1", sourceId: base.id, thread })];
+ client.getFrames = async () => frames;
+
+ await dispatch(
+ actions.paused({
+ thread,
+ frame: frames[0],
+ loadedObjects: [],
+ why: { type: "debuggerStatement" },
+ })
+ );
+}
+
+describe("preview", () => {
+ it("should generate previews", async () => {
+ const store = createStore(mockThreadFront());
+ const { dispatch, getState, cx } = store;
+ const source = makeSource("base.js");
+ const base = await dispatch(actions.newGeneratedSource(source));
+
+ await dispatch(actions.selectSource(cx, base));
+ const sourceActor = selectors.getFirstSourceActorForGeneratedSource(
+ getState(),
+ base.id
+ );
+ const location = createLocation({ source: base, sourceActor });
+
+ await waitForState(store, state => selectors.getSymbols(state, location));
+ const frames = [makeFrame({ id: "f1", sourceId: base.id })];
+
+ await dispatch(
+ actions.paused({
+ thread: store.cx.thread,
+ frame: frames[0],
+ frames,
+ loadedObjects: [],
+ why: { type: "debuggerStatement" },
+ })
+ );
+
+ const newCx = selectors.getContext(getState());
+ const firstTarget = document.createElement("div");
+
+ dispatchSetPreview(dispatch, newCx, "foo", firstTarget);
+
+ expect(selectors.getPreview(getState())).toMatchSnapshot();
+ });
+
+ // When a 2nd setPreview is called before a 1st setPreview dispatches
+ // and the 2nd setPreview has not dispatched yet,
+ // the first setPreview should not finish dispatching
+ it("queued previews (w/ the 1st finishing first)", async () => {
+ let resolveFirst, resolveSecond;
+ const promises = [
+ new Promise(resolve => {
+ resolveFirst = resolve;
+ }),
+ new Promise(resolve => {
+ resolveSecond = resolve;
+ }),
+ ];
+
+ const client = mockThreadFront({
+ loadObjectProperties: () => promises.shift(),
+ });
+ const store = createStore(client);
+
+ const { dispatch, getState } = store;
+ await pause(store, client);
+
+ const newCx = selectors.getContext(getState());
+ const firstTarget = document.createElement("div");
+ const secondTarget = document.createElement("div");
+
+ // Start the dispatch of the first setPreview. At this point, it will not
+ // finish execution until we resolve the firstSetPreview
+ dispatchSetPreview(dispatch, newCx, "firstSetPreview", firstTarget);
+
+ // Start the dispatch of the second setPreview. At this point, it will not
+ // finish execution until we resolve the secondSetPreview
+ dispatchSetPreview(dispatch, newCx, "secondSetPreview", secondTarget);
+
+ let fail = false;
+
+ resolveFirst();
+ waitForPreview(store, "firstSetPreview").then(() => {
+ fail = true;
+ });
+
+ resolveSecond();
+ await waitForPreview(store, "secondSetPreview");
+ expect(fail).toEqual(false);
+
+ const preview = selectors.getPreview(getState());
+ expect(preview && preview.expression).toEqual("secondSetPreview");
+ });
+
+ // When a 2nd setPreview is called before a 1st setPreview dispatches
+ // and the 2nd setPreview has dispatched,
+ // the first setPreview should not finish dispatching
+ it("queued previews (w/ the 2nd finishing first)", async () => {
+ let resolveFirst, resolveSecond;
+ const promises = [
+ new Promise(resolve => {
+ resolveFirst = resolve;
+ }),
+ new Promise(resolve => {
+ resolveSecond = resolve;
+ }),
+ ];
+
+ const client = mockThreadFront({
+ loadObjectProperties: () => promises.shift(),
+ });
+ const store = createStore(client);
+
+ const { dispatch, getState } = store;
+ await pause(store, client);
+
+ const cx = selectors.getThreadContext(getState());
+ const firstTarget = document.createElement("div");
+ const secondTarget = document.createElement("div");
+
+ // Start the dispatch of the first setPreview. At this point, it will not
+ // finish execution until we resolve the firstSetPreview
+ dispatchSetPreview(dispatch, cx, "firstSetPreview", firstTarget);
+
+ // Start the dispatch of the second setPreview. At this point, it will not
+ // finish execution until we resolve the secondSetPreview
+ dispatchSetPreview(dispatch, cx, "secondSetPreview", secondTarget);
+
+ let fail = false;
+
+ resolveSecond();
+ await waitForPreview(store, "secondSetPreview");
+
+ resolveFirst();
+ waitForPreview(store, "firstSetPreview").then(() => {
+ fail = true;
+ });
+
+ await waitATick(() => expect(fail).toEqual(false));
+
+ const preview = selectors.getPreview(getState());
+ expect(preview && preview.expression).toEqual("secondSetPreview");
+ });
+});
diff --git a/devtools/client/debugger/src/actions/tests/sources-tree.spec.js b/devtools/client/debugger/src/actions/tests/sources-tree.spec.js
new file mode 100644
index 0000000000..916b2d015b
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/sources-tree.spec.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 { actions, selectors, createStore } from "../../utils/test-head";
+const { getExpandedState } = selectors;
+
+describe("source tree", () => {
+ it("should set the expanded state", () => {
+ const { dispatch, getState } = createStore();
+ const expandedState = new Set(["foo", "bar"]);
+
+ expect(getExpandedState(getState())).toEqual(new Set([]));
+ dispatch(actions.setExpandedState(expandedState));
+ expect(getExpandedState(getState())).toEqual(expandedState);
+ });
+});
diff --git a/devtools/client/debugger/src/actions/tests/tabs.spec.js b/devtools/client/debugger/src/actions/tests/tabs.spec.js
new file mode 100644
index 0000000000..419e5b7e60
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/tabs.spec.js
@@ -0,0 +1,187 @@
+/* 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";
+const { getSelectedSource, getSourceTabs } = selectors;
+import { createLocation } from "../../utils/location";
+
+import { mockCommandClient } from "./helpers/mockCommandClient";
+
+describe("closing tabs", () => {
+ it("closing a tab", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 }))
+ );
+ dispatch(actions.closeTab(cx, fooSource));
+
+ expect(getSelectedSource(getState())).toBe(undefined);
+ expect(getSourceTabs(getState())).toHaveLength(0);
+ });
+
+ it("closing the inactive tab", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ const barSource = await dispatch(
+ actions.newGeneratedSource(makeSource("bar.js"))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 }))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: barSource, line: 1 }))
+ );
+ dispatch(actions.closeTab(cx, fooSource));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("bar.js");
+ expect(getSourceTabs(getState())).toHaveLength(1);
+ });
+
+ it("closing the only tab", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 }))
+ );
+ dispatch(actions.closeTab(cx, fooSource));
+
+ expect(getSelectedSource(getState())).toBe(undefined);
+ expect(getSourceTabs(getState())).toHaveLength(0);
+ });
+
+ it("closing the active tab", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ const barSource = await dispatch(
+ actions.newGeneratedSource(makeSource("bar.js"))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 }))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: barSource, line: 1 }))
+ );
+ await dispatch(actions.closeTab(cx, barSource));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("foo.js");
+ expect(getSourceTabs(getState())).toHaveLength(1);
+ });
+
+ it("closing many inactive tabs", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ const barSource = await dispatch(
+ actions.newGeneratedSource(makeSource("bar.js"))
+ );
+ const bazzSource = await dispatch(
+ actions.newGeneratedSource(makeSource("bazz.js"))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 }))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: barSource, line: 1 }))
+ );
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({ source: bazzSource, line: 1 })
+ )
+ );
+
+ const tabs = [
+ "http://localhost:8000/examples/foo.js",
+ "http://localhost:8000/examples/bar.js",
+ ];
+ dispatch(actions.closeTabs(cx, tabs));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("bazz.js");
+ expect(getSourceTabs(getState())).toHaveLength(1);
+ });
+
+ it("closing many tabs including the active tab", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ const barSource = await dispatch(
+ actions.newGeneratedSource(makeSource("bar.js"))
+ );
+ const bazzSource = await dispatch(
+ actions.newGeneratedSource(makeSource("bazz.js"))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 }))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: barSource, line: 1 }))
+ );
+ await dispatch(
+ actions.selectLocation(
+ cx,
+ createLocation({ source: bazzSource, line: 1 })
+ )
+ );
+ const tabs = [
+ "http://localhost:8000/examples/bar.js",
+ "http://localhost:8000/examples/bazz.js",
+ ];
+ await dispatch(actions.closeTabs(cx, tabs));
+
+ const selected = getSelectedSource(getState());
+ expect(selected && selected.id).toBe("foo.js");
+ expect(getSourceTabs(getState())).toHaveLength(1);
+ });
+
+ it("closing all the tabs", async () => {
+ const { dispatch, getState, cx } = createStore(mockCommandClient);
+
+ const fooSource = await dispatch(
+ actions.newGeneratedSource(makeSource("foo.js"))
+ );
+ const barSource = await dispatch(
+ actions.newGeneratedSource(makeSource("bar.js"))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: fooSource, line: 1 }))
+ );
+ await dispatch(
+ actions.selectLocation(cx, createLocation({ source: barSource, line: 1 }))
+ );
+ await dispatch(
+ actions.closeTabs(cx, [
+ "http://localhost:8000/examples/foo.js",
+ "http://localhost:8000/examples/bar.js",
+ ])
+ );
+
+ expect(getSelectedSource(getState())).toBe(undefined);
+ expect(getSourceTabs(getState())).toHaveLength(0);
+ });
+});
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..0e13681a12
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tests/ui.spec.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 {
+ 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
+ );
+ const cx = selectors.getThreadContext(getState());
+ //await dispatch(actions.selectSource(cx, base, sourceActor));
+ const location = createLocation({
+ source: base,
+ line: 1,
+ sourceActor,
+ });
+ await dispatch(actions.selectLocation(cx, 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
+ );
+ const cx = selectors.getThreadContext(getState());
+ await dispatch(actions.selectSource(cx, 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..13f53e7c67
--- /dev/null
+++ b/devtools/client/debugger/src/actions/threads.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 { createThread } from "../client/firefox/create";
+import { getSourcesToRemoveForThread } from "../selectors";
+
+export function addTarget(targetFront) {
+ return { type: "INSERT_THREAD", newThread: createThread(targetFront) };
+}
+
+export function removeTarget(targetFront) {
+ return ({ getState, dispatch }) => {
+ 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
+ );
+
+ dispatch({
+ type: "REMOVE_THREAD",
+ threadActorID,
+ actors,
+ sources,
+ });
+ };
+}
+
+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..9cbe7bc20e
--- /dev/null
+++ b/devtools/client/debugger/src/actions/tracing.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/>. */
+
+import { getIsThreadCurrentlyTracing, getAllThreads } from "../selectors";
+import { PROMISE } from "./utils/middleware/promise";
+
+/**
+ * Toggle ON/OFF Javascript tracing for all targets,
+ * using the specified log method.
+ *
+ * @param {string} logMethod
+ * Can be "stdout" or "console". See TracerActor.
+ */
+export function toggleTracing(logMethod) {
+ return async ({ dispatch, getState, client, panel }) => {
+ // Check if any of the thread is currently tracing.
+ // For now, the UI can only toggle all the targets all at once.
+ const threads = getAllThreads(getState());
+ const isTracingEnabled = threads.some(thread =>
+ getIsThreadCurrentlyTracing(getState(), thread.actor)
+ );
+
+ // 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]: isTracingEnabled
+ ? client.stopTracing()
+ : client.startTracing(logMethod),
+ });
+ };
+}
+
+/**
+ * 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..67b2629135
--- /dev/null
+++ b/devtools/client/debugger/src/actions/ui.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 {
+ getActiveSearch,
+ getPaneCollapse,
+ getQuickOpenEnabled,
+ getSource,
+ getSourceContent,
+ getMainThread,
+ getIgnoreListSourceUrls,
+ getSourceByURL,
+ getBreakpointsForSource,
+} from "../selectors";
+import { selectSource } from "../actions/sources/select";
+import {
+ getEditor,
+ getLocationsInViewport,
+ updateDocuments,
+} from "../utils/editor";
+import { blackboxSourceActorsForSource } from "./sources/blackbox";
+import { toggleBreakpoints } from "./breakpoints";
+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(cx, 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(cx, 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 clearProjectDirectoryRoot(cx) {
+ return {
+ type: "SET_PROJECT_DIRECTORY_ROOT",
+ cx,
+ url: "",
+ name: "",
+ };
+}
+
+export function setProjectDirectoryRoot(cx, newRoot, 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 && newRoot.startsWith(mainThread.actor)) {
+ newRoot = newRoot.replace(mainThread.actor, "top-level");
+ }
+ dispatch({
+ type: "SET_PROJECT_DIRECTORY_ROOT",
+ cx,
+ url: newRoot,
+ name: newName,
+ });
+ };
+}
+
+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 = getSourceContent(getState(), location);
+ if (content && isFulfilled(content) && content.value.type === "text") {
+ copyToTheClipboard(content.value.value);
+ }
+ };
+}
+
+export function setJavascriptTracingLogMethod(value) {
+ return ({ dispatch, getState }) => {
+ dispatch({
+ type: "SET_JAVASCRIPT_TRACING_LOG_METHOD",
+ value,
+ });
+ };
+}
+
+export function setHideOrShowIgnoredSources(shouldHide) {
+ return ({ dispatch, getState }) => {
+ dispatch({ type: "HIDE_IGNORED_SOURCES", shouldHide });
+ };
+}
+
+export function toggleSourceMapIgnoreList(cx, 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.id);
+ await dispatch(toggleBreakpoints(cx, 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..9527c67afc
--- /dev/null
+++ b/devtools/client/debugger/src/actions/utils/create-store.js
@@ -0,0 +1,72 @@
+/* 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 "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..ebadaa4eff
--- /dev/null
+++ b/devtools/client/debugger/src/actions/utils/middleware/context.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 {
+ validateNavigateContext,
+ validateContext,
+} 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);
+ }
+ 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",
+)