summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/reducers
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/client/debugger/src/reducers
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/reducers')
-rw-r--r--devtools/client/debugger/src/reducers/ast.js92
-rw-r--r--devtools/client/debugger/src/reducers/breakpoints.js149
-rw-r--r--devtools/client/debugger/src/reducers/event-listeners.js38
-rw-r--r--devtools/client/debugger/src/reducers/exceptions.js78
-rw-r--r--devtools/client/debugger/src/reducers/expressions.js124
-rw-r--r--devtools/client/debugger/src/reducers/index.js76
-rw-r--r--devtools/client/debugger/src/reducers/moz.build26
-rw-r--r--devtools/client/debugger/src/reducers/pause.js427
-rw-r--r--devtools/client/debugger/src/reducers/pending-breakpoints.js135
-rw-r--r--devtools/client/debugger/src/reducers/quick-open.js41
-rw-r--r--devtools/client/debugger/src/reducers/source-actors.js109
-rw-r--r--devtools/client/debugger/src/reducers/source-blackbox.js145
-rw-r--r--devtools/client/debugger/src/reducers/sources-content.js139
-rw-r--r--devtools/client/debugger/src/reducers/sources-tree.js704
-rw-r--r--devtools/client/debugger/src/reducers/sources.js395
-rw-r--r--devtools/client/debugger/src/reducers/tabs.js200
-rw-r--r--devtools/client/debugger/src/reducers/tests/breakpoints.spec.js73
-rw-r--r--devtools/client/debugger/src/reducers/tests/quick-open.spec.js59
-rw-r--r--devtools/client/debugger/src/reducers/tests/ui.spec.js30
-rw-r--r--devtools/client/debugger/src/reducers/threads.js69
-rw-r--r--devtools/client/debugger/src/reducers/ui.js253
21 files changed, 3362 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/reducers/ast.js b/devtools/client/debugger/src/reducers/ast.js
new file mode 100644
index 0000000000..22ae3e3d23
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/ast.js
@@ -0,0 +1,92 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Ast reducer
+ * @module reducers/ast
+ */
+
+import { makeBreakpointId } from "../utils/breakpoint/index";
+
+export function initialASTState() {
+ return {
+ // We are using mutable objects as we never return the dictionary as-is from the selectors
+ // but only their values.
+ // Note that all these dictionaries are storing objects as values
+ // which all will have a threadActorId attribute.
+
+ // We have two maps, a first one for original sources.
+ // This is keyed by source id.
+ mutableOriginalSourcesSymbols: {},
+
+ // And another one, for generated sources.
+ // This is keyed by source actor id.
+ mutableSourceActorSymbols: {},
+
+ mutableInScopeLines: {},
+ };
+}
+
+function update(state = initialASTState(), action) {
+ switch (action.type) {
+ case "SET_SYMBOLS": {
+ const { location } = action;
+ if (action.status === "start") {
+ return state;
+ }
+
+ const entry = {
+ value: action.value,
+ threadActorId: location.sourceActor?.thread,
+ };
+ if (location.source.isOriginal) {
+ state.mutableOriginalSourcesSymbols[location.source.id] = entry;
+ } else {
+ if (!location.sourceActor) {
+ throw new Error(
+ "Expects a location with a source actor when adding symbols for non-original sources"
+ );
+ }
+ state.mutableSourceActorSymbols[location.sourceActor.id] = entry;
+ }
+ return {
+ ...state,
+ };
+ }
+
+ case "IN_SCOPE_LINES": {
+ state.mutableInScopeLines[makeBreakpointId(action.location)] = {
+ lines: action.lines,
+ threadActorId: action.location.sourceActor?.thread,
+ };
+ return {
+ ...state,
+ };
+ }
+
+ case "RESUME": {
+ return { ...state, mutableInScopeLines: {} };
+ }
+
+ case "REMOVE_THREAD": {
+ function clearDict(dict, threadId) {
+ for (const key in dict) {
+ if (dict[key].threadActorId == threadId) {
+ delete dict[key];
+ }
+ }
+ }
+ clearDict(state.mutableSourceActorSymbols, action.threadActorID);
+ clearDict(state.mutableOriginalSourcesSymbols, action.threadActorID);
+ clearDict(state.mutableInScopeLines, action.threadActorID);
+ return { ...state };
+ }
+
+ default: {
+ return state;
+ }
+ }
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/breakpoints.js b/devtools/client/debugger/src/reducers/breakpoints.js
new file mode 100644
index 0000000000..2a35cda9ef
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/breakpoints.js
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Breakpoints reducer
+ * @module reducers/breakpoints
+ */
+
+import { makeBreakpointId } from "../utils/breakpoint/index";
+
+export function initialBreakpointsState(xhrBreakpoints = []) {
+ return {
+ breakpoints: {},
+ xhrBreakpoints,
+ };
+}
+
+function update(state = initialBreakpointsState(), action) {
+ switch (action.type) {
+ case "SET_BREAKPOINT": {
+ if (action.status === "start") {
+ return setBreakpoint(state, action);
+ }
+ return state;
+ }
+
+ case "REMOVE_BREAKPOINT": {
+ if (action.status === "start") {
+ return removeBreakpoint(state, action);
+ }
+ return state;
+ }
+
+ case "CLEAR_BREAKPOINTS": {
+ return { ...state, breakpoints: {} };
+ }
+
+ case "REMOVE_THREAD": {
+ return removeBreakpointsForSources(state, action.sources);
+ }
+
+ case "SET_XHR_BREAKPOINT": {
+ return addXHRBreakpoint(state, action);
+ }
+
+ case "REMOVE_XHR_BREAKPOINT": {
+ return removeXHRBreakpoint(state, action);
+ }
+
+ case "UPDATE_XHR_BREAKPOINT": {
+ return updateXHRBreakpoint(state, action);
+ }
+
+ case "ENABLE_XHR_BREAKPOINT": {
+ return updateXHRBreakpoint(state, action);
+ }
+
+ case "DISABLE_XHR_BREAKPOINT": {
+ return updateXHRBreakpoint(state, action);
+ }
+ case "CLEAR_XHR_BREAKPOINTS": {
+ if (action.status == "start") {
+ return state;
+ }
+ return { ...state, xhrBreakpoints: [] };
+ }
+ }
+
+ return state;
+}
+
+function addXHRBreakpoint(state, action) {
+ const { xhrBreakpoints } = state;
+ const { breakpoint } = action;
+ const { path, method } = breakpoint;
+
+ const existingBreakpointIndex = state.xhrBreakpoints.findIndex(
+ bp => bp.path === path && bp.method === method
+ );
+
+ if (existingBreakpointIndex === -1) {
+ return {
+ ...state,
+ xhrBreakpoints: [...xhrBreakpoints, breakpoint],
+ };
+ } else if (xhrBreakpoints[existingBreakpointIndex] !== breakpoint) {
+ const newXhrBreakpoints = [...xhrBreakpoints];
+ newXhrBreakpoints[existingBreakpointIndex] = breakpoint;
+ return {
+ ...state,
+ xhrBreakpoints: newXhrBreakpoints,
+ };
+ }
+
+ return state;
+}
+
+function removeXHRBreakpoint(state, action) {
+ const { breakpoint } = action;
+ const { xhrBreakpoints } = state;
+
+ if (action.status === "start") {
+ return state;
+ }
+
+ return {
+ ...state,
+ xhrBreakpoints: xhrBreakpoints.filter(
+ bp => bp.path !== breakpoint.path || bp.method !== breakpoint.method
+ ),
+ };
+}
+
+function updateXHRBreakpoint(state, action) {
+ const { breakpoint, index } = action;
+ const { xhrBreakpoints } = state;
+ const newXhrBreakpoints = [...xhrBreakpoints];
+ newXhrBreakpoints[index] = breakpoint;
+ return {
+ ...state,
+ xhrBreakpoints: newXhrBreakpoints,
+ };
+}
+
+function setBreakpoint(state, { breakpoint }) {
+ const id = makeBreakpointId(breakpoint.location);
+ const breakpoints = { ...state.breakpoints, [id]: breakpoint };
+ return { ...state, breakpoints };
+}
+
+function removeBreakpoint(state, { breakpoint }) {
+ const id = makeBreakpointId(breakpoint.location);
+ const breakpoints = { ...state.breakpoints };
+ delete breakpoints[id];
+ return { ...state, breakpoints };
+}
+
+function removeBreakpointsForSources(state, sources) {
+ const remainingBreakpoints = {};
+ for (const [id, breakpoint] of Object.entries(state.breakpoints)) {
+ if (!sources.includes(breakpoint.location.source)) {
+ remainingBreakpoints[id] = breakpoint;
+ }
+ }
+ return { ...state, breakpoints: remainingBreakpoints };
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/event-listeners.js b/devtools/client/debugger/src/reducers/event-listeners.js
new file mode 100644
index 0000000000..d0418b9ece
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/event-listeners.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { prefs } from "../utils/prefs";
+
+export function initialEventListenerState() {
+ return {
+ active: [],
+ categories: [],
+ expanded: [],
+ logEventBreakpoints: prefs.logEventBreakpoints,
+ };
+}
+
+function update(state = initialEventListenerState(), action) {
+ switch (action.type) {
+ case "UPDATE_EVENT_LISTENERS":
+ return { ...state, active: action.active };
+
+ case "RECEIVE_EVENT_LISTENER_TYPES":
+ return { ...state, categories: action.categories };
+
+ case "UPDATE_EVENT_LISTENER_EXPANDED":
+ return { ...state, expanded: action.expanded };
+
+ case "TOGGLE_EVENT_LISTENERS": {
+ const { logEventBreakpoints } = action;
+ prefs.logEventBreakpoints = logEventBreakpoints;
+ return { ...state, logEventBreakpoints };
+ }
+
+ default:
+ return state;
+ }
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/exceptions.js b/devtools/client/debugger/src/reducers/exceptions.js
new file mode 100644
index 0000000000..a4a26de5df
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/exceptions.js
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Exceptions reducer
+ * @module reducers/exceptionss
+ */
+
+export function initialExceptionsState() {
+ return {
+ // Store exception objects created by actions/exceptions.js's addExceptionFromResources()
+ // This is keyed by source actor id, and values are arrays of exception objects.
+ mutableExceptionsMap: new Map(),
+ };
+}
+
+function update(state = initialExceptionsState(), action) {
+ switch (action.type) {
+ case "ADD_EXCEPTION":
+ return updateExceptions(state, action);
+ case "REMOVE_THREAD": {
+ return removeExceptionsFromThread(state, action);
+ }
+ }
+ return state;
+}
+
+function updateExceptions(state, action) {
+ const { mutableExceptionsMap } = state;
+ const { exception } = action;
+ const { sourceActorId } = exception;
+
+ let exceptions = mutableExceptionsMap.get(sourceActorId);
+ if (!exceptions) {
+ exceptions = [];
+ mutableExceptionsMap.set(sourceActorId, exceptions);
+ } else if (
+ exceptions.some(({ lineNumber, columnNumber }) => {
+ return (
+ lineNumber == exception.lineNumber &&
+ columnNumber == exception.columnNumber
+ );
+ })
+ ) {
+ // Avoid adding duplicated exceptions for the same line/column
+ return state;
+ }
+
+ // As these arrays are only used by getSelectedSourceExceptions selector method,
+ // which coalesce multiple arrays and always return new array instance,
+ // it isn't important to clone these array in case of modification.
+ exceptions.push(exception);
+
+ return {
+ ...state,
+ };
+}
+
+function removeExceptionsFromThread(state, action) {
+ const { mutableExceptionsMap } = state;
+ const { threadActorID } = action;
+ const sizeBefore = mutableExceptionsMap.size;
+ for (const [sourceActorId, exceptions] of mutableExceptionsMap) {
+ // All exceptions relates to the same source actor, and so, the same thread actor.
+ if (exceptions[0].threadActorId == threadActorID) {
+ mutableExceptionsMap.delete(sourceActorId);
+ }
+ }
+ if (sizeBefore != mutableExceptionsMap.size) {
+ return {
+ ...state,
+ };
+ }
+ return state;
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/expressions.js b/devtools/client/debugger/src/reducers/expressions.js
new file mode 100644
index 0000000000..9af13523e4
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/expressions.js
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Expressions reducer
+ * @module reducers/expressions
+ */
+
+import { prefs } from "../utils/prefs";
+
+export const initialExpressionState = () => ({
+ expressions: restoreExpressions(),
+ autocompleteMatches: {},
+ currentAutocompleteInput: null,
+});
+
+function update(state = initialExpressionState(), action) {
+ switch (action.type) {
+ case "ADD_EXPRESSION":
+ return appendExpressionToList(state, {
+ input: action.input,
+ value: null,
+ updating: true,
+ });
+
+ case "UPDATE_EXPRESSION":
+ const key = action.expression.input;
+ return updateExpressionInList(state, key, {
+ input: action.input,
+ value: null,
+ updating: true,
+ });
+
+ case "EVALUATE_EXPRESSION":
+ return updateExpressionInList(state, action.input, {
+ input: action.input,
+ value: action.value,
+ updating: false,
+ });
+
+ case "EVALUATE_EXPRESSIONS":
+ const { inputs, results } = action;
+
+ return inputs.reduce(
+ (_state, input, index) =>
+ updateExpressionInList(_state, input, {
+ input,
+ value: results[index],
+ updating: false,
+ }),
+ state
+ );
+
+ case "DELETE_EXPRESSION":
+ return deleteExpression(state, action.input);
+
+ case "AUTOCOMPLETE":
+ const { matchProp, matches } = action.result;
+
+ return {
+ ...state,
+ currentAutocompleteInput: matchProp,
+ autocompleteMatches: {
+ ...state.autocompleteMatches,
+ [matchProp]: matches,
+ },
+ };
+
+ case "CLEAR_AUTOCOMPLETE":
+ return {
+ ...state,
+ autocompleteMatches: {},
+ currentAutocompleteInput: "",
+ };
+ }
+
+ return state;
+}
+
+function restoreExpressions() {
+ const exprs = prefs.expressions;
+ if (!exprs.length) {
+ return [];
+ }
+
+ return exprs;
+}
+
+function storeExpressions({ expressions }) {
+ // Return the expressions without the `value` property
+ prefs.expressions = expressions.map(({ input, updating }) => ({
+ input,
+ updating,
+ }));
+}
+
+function appendExpressionToList(state, value) {
+ const newState = { ...state, expressions: [...state.expressions, value] };
+
+ storeExpressions(newState);
+ return newState;
+}
+
+function updateExpressionInList(state, key, value) {
+ const list = [...state.expressions];
+ const index = list.findIndex(e => e.input == key);
+ list[index] = value;
+
+ const newState = { ...state, expressions: list };
+ storeExpressions(newState);
+ return newState;
+}
+
+function deleteExpression(state, input) {
+ const list = [...state.expressions];
+ const index = list.findIndex(e => e.input == input);
+ list.splice(index, 1);
+ const newState = { ...state, expressions: list };
+ storeExpressions(newState);
+ return newState;
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/index.js b/devtools/client/debugger/src/reducers/index.js
new file mode 100644
index 0000000000..b254a5b176
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/index.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/>. */
+
+/**
+ * Reducer index
+ * @module reducers/index
+ */
+
+import expressions, { initialExpressionState } from "./expressions";
+import sourceActors, { initialSourceActorsState } from "./source-actors";
+import sources, { initialSourcesState } from "./sources";
+import sourceBlackBox, { initialSourceBlackBoxState } from "./source-blackbox";
+import sourcesContent, { initialSourcesContentState } from "./sources-content";
+import tabs, { initialTabState } from "./tabs";
+import breakpoints, { initialBreakpointsState } from "./breakpoints";
+import pendingBreakpoints from "./pending-breakpoints";
+import pause, { initialPauseState } from "./pause";
+import ui, { initialUIState } from "./ui";
+import ast, { initialASTState } from "./ast";
+import quickOpen, { initialQuickOpenState } from "./quick-open";
+import sourcesTree, { initialSourcesTreeState } from "./sources-tree";
+import threads, { initialThreadsState } from "./threads";
+import eventListenerBreakpoints, {
+ initialEventListenerState,
+} from "./event-listeners";
+import exceptions, { initialExceptionsState } from "./exceptions";
+
+import { objectInspector } from "devtools/client/shared/components/reps/index";
+
+/**
+ * Note that this is only used by jest tests.
+ *
+ * Production is using loadInitialState() in main.js
+ */
+export function initialState() {
+ return {
+ sources: initialSourcesState(),
+ sourcesContent: initialSourcesContentState(),
+ expressions: initialExpressionState(),
+ sourceActors: initialSourceActorsState(),
+ sourceBlackBox: initialSourceBlackBoxState(),
+ tabs: initialTabState(),
+ breakpoints: initialBreakpointsState(),
+ pendingBreakpoints: {},
+ pause: initialPauseState(),
+ ui: initialUIState(),
+ ast: initialASTState(),
+ quickOpen: initialQuickOpenState(),
+ sourcesTree: initialSourcesTreeState(),
+ threads: initialThreadsState(),
+ objectInspector: objectInspector.reducer.initialOIState(),
+ eventListenerBreakpoints: initialEventListenerState(),
+ exceptions: initialExceptionsState(),
+ };
+}
+
+export default {
+ expressions,
+ sourceActors,
+ sourceBlackBox,
+ sourcesContent,
+ sources,
+ tabs,
+ breakpoints,
+ pendingBreakpoints,
+ pause,
+ ui,
+ ast,
+ quickOpen,
+ sourcesTree,
+ threads,
+ objectInspector: objectInspector.reducer.default,
+ eventListenerBreakpoints,
+ exceptions,
+};
diff --git a/devtools/client/debugger/src/reducers/moz.build b/devtools/client/debugger/src/reducers/moz.build
new file mode 100644
index 0000000000..4e2d2a1045
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/moz.build
@@ -0,0 +1,26 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "ast.js",
+ "breakpoints.js",
+ "event-listeners.js",
+ "exceptions.js",
+ "expressions.js",
+ "index.js",
+ "pause.js",
+ "pending-breakpoints.js",
+ "quick-open.js",
+ "source-actors.js",
+ "source-blackbox.js",
+ "sources.js",
+ "sources-content.js",
+ "sources-tree.js",
+ "tabs.js",
+ "threads.js",
+ "ui.js",
+)
diff --git a/devtools/client/debugger/src/reducers/pause.js b/devtools/client/debugger/src/reducers/pause.js
new file mode 100644
index 0000000000..8cb5eefe98
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/pause.js
@@ -0,0 +1,427 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/* eslint complexity: ["error", 36]*/
+
+/**
+ * Pause reducer
+ * @module reducers/pause
+ */
+
+import { prefs } from "../utils/prefs";
+
+// Pause state associated with an individual thread.
+
+// Pause state describing all threads.
+
+export function initialPauseState(thread = "UnknownThread") {
+ return {
+ cx: {
+ navigateCounter: 0,
+ },
+ // This `threadcx` is the `cx` variable we pass around in components and actions.
+ // This is pulled via getThreadContext().
+ // This stores information about the currently selected thread and its paused state.
+ threadcx: {
+ navigateCounter: 0,
+ thread,
+ pauseCounter: 0,
+ },
+ threads: {},
+ skipPausing: prefs.skipPausing,
+ mapScopes: prefs.mapScopes,
+ shouldPauseOnDebuggerStatement: prefs.pauseOnDebuggerStatement,
+ shouldPauseOnExceptions: prefs.pauseOnExceptions,
+ shouldPauseOnCaughtExceptions: prefs.pauseOnCaughtExceptions,
+ };
+}
+
+const resumedPauseState = {
+ isPaused: false,
+ frames: null,
+ framesLoading: false,
+ frameScopes: {
+ generated: {},
+ original: {},
+ mappings: {},
+ },
+ selectedFrameId: null,
+ why: null,
+ inlinePreview: {},
+};
+
+const createInitialPauseState = () => ({
+ ...resumedPauseState,
+ isWaitingOnBreak: false,
+ command: null,
+ previousLocation: null,
+ expandedScopes: new Set(),
+ lastExpandedScopes: [],
+});
+
+export function getThreadPauseState(state, thread) {
+ // Thread state is lazily initialized so that we don't have to keep track of
+ // the current set of worker threads.
+ return state.threads[thread] || createInitialPauseState();
+}
+
+function update(state = initialPauseState(), action) {
+ // All the actions updating pause state must pass an object which designate
+ // the related thread.
+ const getActionThread = () => {
+ const thread =
+ action.thread || action.selectedFrame?.thread || action.frame?.thread;
+ if (!thread) {
+ throw new Error(`Missing thread in action ${action.type}`);
+ }
+ return thread;
+ };
+
+ // `threadState` and `updateThreadState` help easily get and update
+ // the pause state for a given thread.
+ const threadState = () => {
+ return getThreadPauseState(state, getActionThread());
+ };
+ const updateThreadState = newThreadState => {
+ return {
+ ...state,
+ threads: {
+ ...state.threads,
+ [getActionThread()]: { ...threadState(), ...newThreadState },
+ },
+ };
+ };
+
+ switch (action.type) {
+ case "SELECT_THREAD": {
+ // Ignore the action if the related thread doesn't exist.
+ if (!state.threads[action.thread]) {
+ console.warn(
+ `Trying to select a destroyed or non-existent thread '${action.thread}'`
+ );
+ return state;
+ }
+
+ return {
+ ...state,
+ threadcx: {
+ ...state.threadcx,
+ thread: action.thread,
+ pauseCounter: state.threadcx.pauseCounter + 1,
+ },
+ };
+ }
+
+ case "INSERT_THREAD": {
+ // When navigating to a new location,
+ // we receive NAVIGATE early, which clear things
+ // then we have REMOVE_THREAD of the previous thread.
+ // INSERT_THREAD will be the very first event with the new thread actor ID.
+ // Automatically select the new top level thread.
+ if (action.newThread.isTopLevel) {
+ return {
+ ...state,
+ threadcx: {
+ ...state.threadcx,
+ thread: action.newThread.actor,
+ pauseCounter: state.threadcx.pauseCounter + 1,
+ },
+ threads: {
+ ...state.threads,
+ [action.newThread.actor]: createInitialPauseState(),
+ },
+ };
+ }
+
+ return {
+ ...state,
+ threads: {
+ ...state.threads,
+ [action.newThread.actor]: createInitialPauseState(),
+ },
+ };
+ }
+
+ case "REMOVE_THREAD": {
+ if (
+ action.threadActorID in state.threads ||
+ action.threadActorID == state.threadcx.thread
+ ) {
+ // Remove the thread from the cached list
+ const threads = { ...state.threads };
+ delete threads[action.threadActorID];
+ let threadcx = state.threadcx;
+
+ // And also switch to another thread if this was the currently selected one.
+ // As we don't store thread objects in this reducer, and only store thread actor IDs,
+ // we can't try to find the top level thread. So we pick the first available thread,
+ // and hope that's the top level one.
+ if (state.threadcx.thread == action.threadActorID) {
+ threadcx = {
+ ...threadcx,
+ thread: Object.keys(threads)[0],
+ pauseCounter: threadcx.pauseCounter + 1,
+ };
+ }
+ return {
+ ...state,
+ threadcx,
+ threads,
+ };
+ }
+ break;
+ }
+
+ case "PAUSED": {
+ const { thread, topFrame, why } = action;
+ state = {
+ ...state,
+ threadcx: {
+ ...state.threadcx,
+ pauseCounter: state.threadcx.pauseCounter + 1,
+ thread,
+ },
+ };
+
+ return updateThreadState({
+ isWaitingOnBreak: false,
+ selectedFrameId: topFrame.id,
+ isPaused: true,
+ // On pause, we only receive the top frame, all subsequent ones
+ // will be asynchronously populated via `fetchFrames` action
+ frames: [topFrame],
+ framesLoading: true,
+ frameScopes: { ...resumedPauseState.frameScopes },
+ why,
+ shouldBreakpointsPaneOpenOnPause: why.type === "breakpoint",
+ });
+ }
+
+ case "FETCHED_FRAMES": {
+ const { frames } = action;
+
+ // We typically receive a PAUSED action before this one,
+ // with only the first frame. Here, we avoid replacing it
+ // with a copy of it in order to avoid triggerring selectors
+ // uncessarily
+ // (note that in jest, action's frames might be empty)
+ // (and if we resume in between PAUSED and FETCHED_FRAMES
+ // threadState().frames might be null)
+ if (threadState().frames) {
+ const previousFirstFrame = threadState().frames[0];
+ if (previousFirstFrame.id == frames[0]?.id) {
+ frames.splice(0, 1, previousFirstFrame);
+ }
+ }
+ return updateThreadState({ frames, framesLoading: false });
+ }
+
+ case "MAP_FRAMES": {
+ const { selectedFrameId, frames } = action;
+ return updateThreadState({ frames, selectedFrameId });
+ }
+
+ case "ADD_SCOPES": {
+ const { status, value } = action;
+ const selectedFrameId = action.selectedFrame.id;
+
+ const generated = {
+ ...threadState().frameScopes.generated,
+ [selectedFrameId]: {
+ pending: status !== "done",
+ scope: value,
+ },
+ };
+
+ return updateThreadState({
+ frameScopes: {
+ ...threadState().frameScopes,
+ generated,
+ },
+ });
+ }
+
+ case "MAP_SCOPES": {
+ const { status, value } = action;
+ const selectedFrameId = action.selectedFrame.id;
+
+ const original = {
+ ...threadState().frameScopes.original,
+ [selectedFrameId]: {
+ pending: status !== "done",
+ scope: value?.scope,
+ },
+ };
+
+ const mappings = {
+ ...threadState().frameScopes.mappings,
+ [selectedFrameId]: value?.mappings,
+ };
+
+ return updateThreadState({
+ frameScopes: {
+ ...threadState().frameScopes,
+ original,
+ mappings,
+ },
+ });
+ }
+
+ case "BREAK_ON_NEXT":
+ return updateThreadState({ isWaitingOnBreak: true });
+
+ case "SELECT_FRAME":
+ return updateThreadState({ selectedFrameId: action.frame.id });
+
+ case "PAUSE_ON_DEBUGGER_STATEMENT": {
+ const { shouldPauseOnDebuggerStatement } = action;
+
+ prefs.pauseOnDebuggerStatement = shouldPauseOnDebuggerStatement;
+
+ return {
+ ...state,
+ shouldPauseOnDebuggerStatement,
+ };
+ }
+
+ case "PAUSE_ON_EXCEPTIONS": {
+ const { shouldPauseOnExceptions, shouldPauseOnCaughtExceptions } = action;
+
+ prefs.pauseOnExceptions = shouldPauseOnExceptions;
+ prefs.pauseOnCaughtExceptions = shouldPauseOnCaughtExceptions;
+
+ // Preserving for the old debugger
+ prefs.ignoreCaughtExceptions = !shouldPauseOnCaughtExceptions;
+
+ return {
+ ...state,
+ shouldPauseOnExceptions,
+ shouldPauseOnCaughtExceptions,
+ };
+ }
+
+ case "COMMAND":
+ if (action.status === "start") {
+ return updateThreadState({
+ ...resumedPauseState,
+ command: action.command,
+ previousLocation: getPauseLocation(threadState(), action),
+ });
+ }
+ return updateThreadState({ command: null });
+
+ case "RESUME": {
+ if (action.thread == state.threadcx.thread) {
+ state = {
+ ...state,
+ threadcx: {
+ ...state.threadcx,
+ pauseCounter: state.threadcx.pauseCounter + 1,
+ },
+ };
+ }
+
+ return updateThreadState({
+ ...resumedPauseState,
+ expandedScopes: new Set(),
+ lastExpandedScopes: [...threadState().expandedScopes],
+ shouldBreakpointsPaneOpenOnPause: false,
+ });
+ }
+
+ case "EVALUATE_EXPRESSION":
+ return updateThreadState({
+ command: action.status === "start" ? "expression" : null,
+ });
+
+ case "NAVIGATE": {
+ const navigateCounter = state.cx.navigateCounter + 1;
+ return {
+ ...state,
+ cx: {
+ navigateCounter,
+ },
+ threadcx: {
+ navigateCounter,
+ thread: action.mainThread.actor,
+ pauseCounter: 0,
+ },
+ threads: {
+ ...state.threads,
+ [action.mainThread.actor]: {
+ ...getThreadPauseState(state, action.mainThread.actor),
+ ...resumedPauseState,
+ },
+ },
+ };
+ }
+
+ case "TOGGLE_SKIP_PAUSING": {
+ const { skipPausing } = action;
+ prefs.skipPausing = skipPausing;
+
+ return { ...state, skipPausing };
+ }
+
+ case "TOGGLE_MAP_SCOPES": {
+ const { mapScopes } = action;
+ prefs.mapScopes = mapScopes;
+ return { ...state, mapScopes };
+ }
+
+ case "SET_EXPANDED_SCOPE": {
+ const { path, expanded } = action;
+ const expandedScopes = new Set(threadState().expandedScopes);
+ if (expanded) {
+ expandedScopes.add(path);
+ } else {
+ expandedScopes.delete(path);
+ }
+ return updateThreadState({ expandedScopes });
+ }
+
+ case "ADD_INLINE_PREVIEW": {
+ const { selectedFrame, previews } = action;
+ const selectedFrameId = selectedFrame.id;
+
+ return updateThreadState({
+ inlinePreview: {
+ ...threadState().inlinePreview,
+ [selectedFrameId]: previews,
+ },
+ });
+ }
+
+ case "RESET_BREAKPOINTS_PANE_STATE": {
+ return updateThreadState({
+ ...threadState(),
+ shouldBreakpointsPaneOpenOnPause: false,
+ });
+ }
+ }
+
+ return state;
+}
+
+function getPauseLocation(state, action) {
+ const { frames, previousLocation } = state;
+
+ // NOTE: We store the previous location so that we ensure that we
+ // do not stop at the same location twice when we step over.
+ if (action.command !== "stepOver") {
+ return null;
+ }
+
+ const frame = frames?.[0];
+ if (!frame) {
+ return previousLocation;
+ }
+
+ return {
+ location: frame.location,
+ generatedLocation: frame.generatedLocation,
+ };
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/pending-breakpoints.js b/devtools/client/debugger/src/reducers/pending-breakpoints.js
new file mode 100644
index 0000000000..a0d51e5693
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/pending-breakpoints.js
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Pending breakpoints reducer.
+ *
+ * Pending breakpoints are a more lightweight version compared to regular breakpoints objects.
+ * They are meant to be persisted across Firefox restarts and stored into async-storage.
+ * This reducer data is saved into asyncStore from bootstrap.js and restored from main.js.
+ *
+ * The main difference with pending breakpoints is that we only save breakpoints
+ * against source with an URL as only them can be restored. (source IDs are different across reloads).
+ * The second difference is that we don't store the whole source object but only the source URL.
+ */
+
+import { isPrettyURL } from "../utils/source";
+import assert from "../utils/assert";
+
+function update(state = {}, action) {
+ switch (action.type) {
+ case "SET_BREAKPOINT":
+ if (action.status === "start") {
+ return setBreakpoint(state, action.breakpoint);
+ }
+ return state;
+
+ case "REMOVE_BREAKPOINT":
+ if (action.status === "start") {
+ return removeBreakpoint(state, action.breakpoint);
+ }
+ return state;
+
+ case "REMOVE_PENDING_BREAKPOINT":
+ return removePendingBreakpoint(state, action.pendingBreakpoint);
+
+ case "CLEAR_BREAKPOINTS": {
+ return {};
+ }
+ }
+
+ return state;
+}
+
+function shouldBreakpointBePersisted(breakpoint) {
+ // We only save breakpoint for source with URL.
+ // Source without URL can only be identified via their source actor ID
+ // which isn't persisted across reloads.
+ return !breakpoint.options.hidden && breakpoint.location.source.url;
+}
+
+function setBreakpoint(state, breakpoint) {
+ if (!shouldBreakpointBePersisted(breakpoint)) {
+ return state;
+ }
+
+ const id = makeIdFromBreakpoint(breakpoint);
+ const pendingBreakpoint = createPendingBreakpoint(breakpoint);
+
+ return { ...state, [id]: pendingBreakpoint };
+}
+
+function removeBreakpoint(state, breakpoint) {
+ if (!shouldBreakpointBePersisted(breakpoint)) {
+ return state;
+ }
+
+ const id = makeIdFromBreakpoint(breakpoint);
+ state = { ...state };
+
+ delete state[id];
+ return state;
+}
+
+function removePendingBreakpoint(state, pendingBreakpoint) {
+ const id = makeIdFromPendingBreakpoint(pendingBreakpoint);
+ state = { ...state };
+
+ delete state[id];
+ return state;
+}
+
+/**
+ * Return a unique identifier for a given breakpoint,
+ * using its original location, or for pretty-printed sources,
+ * its generated location.
+ *
+ * @param {Object} breakpoint
+ */
+function makeIdFromBreakpoint(breakpoint) {
+ const location = isPrettyURL(breakpoint.location.source.url)
+ ? breakpoint.generatedLocation
+ : breakpoint.location;
+
+ const { source, line, column } = location;
+ const sourceUrlString = source.url || "";
+ const columnString = column || "";
+
+ return `${sourceUrlString}:${line}:${columnString}`;
+}
+
+function makeIdFromPendingBreakpoint(pendingBreakpoint) {
+ const { sourceUrl, line, column } = pendingBreakpoint.location;
+ const sourceUrlString = sourceUrl || "";
+ const columnString = column || "";
+
+ return `${sourceUrlString}:${line}:${columnString}`;
+}
+
+/**
+ * Convert typical debugger frontend location (created via location.js:createLocation)
+ * to a more lightweight flavor of it which will be stored in async storage.
+ */
+function createPendingLocation(location) {
+ assert(location.hasOwnProperty("line"), "location must have a line");
+ assert(location.hasOwnProperty("column"), "location must have a column");
+
+ const { source, line, column } = location;
+ assert(source.url !== undefined, "pending location must have a source url");
+ return { sourceUrl: source.url, line, column };
+}
+
+/**
+ * Create a new pending breakpoint, which is a more lightweight version of the regular breakpoint object.
+ */
+function createPendingBreakpoint(bp) {
+ return {
+ options: bp.options,
+ disabled: bp.disabled,
+ location: createPendingLocation(bp.location),
+ generatedLocation: createPendingLocation(bp.generatedLocation),
+ };
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/quick-open.js b/devtools/client/debugger/src/reducers/quick-open.js
new file mode 100644
index 0000000000..459e530e7b
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/quick-open.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Quick Open reducer
+ * @module reducers/quick-open
+ */
+
+import { parseQuickOpenQuery } from "../utils/quick-open";
+
+export const initialQuickOpenState = () => ({
+ enabled: false,
+ query: "",
+ searchType: "sources",
+});
+
+export default function update(state = initialQuickOpenState(), action) {
+ switch (action.type) {
+ case "OPEN_QUICK_OPEN":
+ if (action.query != null) {
+ return {
+ ...state,
+ enabled: true,
+ query: action.query,
+ searchType: parseQuickOpenQuery(action.query),
+ };
+ }
+ return { ...state, enabled: true };
+ case "CLOSE_QUICK_OPEN":
+ return initialQuickOpenState();
+ case "SET_QUICK_OPEN_QUERY":
+ return {
+ ...state,
+ query: action.query,
+ searchType: parseQuickOpenQuery(action.query),
+ };
+ default:
+ return state;
+ }
+}
diff --git a/devtools/client/debugger/src/reducers/source-actors.js b/devtools/client/debugger/src/reducers/source-actors.js
new file mode 100644
index 0000000000..fd99955742
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/source-actors.js
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * This reducer stores the list of all source actors as well their breakable lines.
+ *
+ * There is a one-one relationship with Source Actors from the server codebase,
+ * as well as SOURCE Resources distributed by the ResourceCommand API.
+ *
+ */
+function initialSourceActorsState() {
+ return {
+ // Map(Source Actor ID: string => SourceActor: object)
+ // See create.js: `createSourceActor` for the shape of the source actor objects.
+ mutableSourceActors: new Map(),
+
+ // Map(Source Actor ID: string => Breakable lines: Array<Number>)
+ // The array is the list of all lines where breakpoints can be set
+ mutableBreakableLines: new Map(),
+
+ // Set(Source Actor ID: string)
+ // List of all IDs of source actor which have a valid related source map / original source.
+ // The SourceActor object may have a sourceMapURL attribute set,
+ // but this may be invalid. The source map URL or source map file content may be invalid.
+ // In these scenarios we will remove the source actor from this set.
+ mutableSourceActorsWithSourceMap: new Set(),
+
+ // Map(Source Actor ID: string => string)
+ // Store the exception message when processing the sourceMapURL field of the source actor.
+ mutableSourceMapErrors: new Map(),
+
+ // Map(Source Actor ID: string => string)
+ // When a bundle has a functional sourcemap, reports the resolved source map URL.
+ mutableResolvedSourceMapURL: new Map(),
+ };
+}
+
+export const initial = initialSourceActorsState();
+
+export default function update(state = initialSourceActorsState(), action) {
+ switch (action.type) {
+ case "INSERT_SOURCE_ACTORS": {
+ for (const sourceActor of action.sourceActors) {
+ state.mutableSourceActors.set(sourceActor.id, sourceActor);
+
+ // If the sourceMapURL attribute is set, consider that it is valid.
+ // But this may be revised later and removed from this Set.
+ if (sourceActor.sourceMapURL) {
+ state.mutableSourceActorsWithSourceMap.add(sourceActor.id);
+ }
+ }
+ return {
+ ...state,
+ };
+ }
+
+ case "REMOVE_THREAD": {
+ for (const sourceActor of state.mutableSourceActors.values()) {
+ if (sourceActor.thread == action.threadActorID) {
+ state.mutableSourceActors.delete(sourceActor.id);
+ state.mutableBreakableLines.delete(sourceActor.id);
+ state.mutableSourceActorsWithSourceMap.delete(sourceActor.id);
+ }
+ }
+ return {
+ ...state,
+ };
+ }
+
+ case "SET_SOURCE_ACTOR_BREAKABLE_LINES":
+ state.mutableBreakableLines.set(
+ action.sourceActor.id,
+ action.breakableLines
+ );
+
+ return {
+ ...state,
+ };
+
+ case "CLEAR_SOURCE_ACTOR_MAP_URL":
+ if (
+ state.mutableSourceActorsWithSourceMap.delete(action.sourceActor.id)
+ ) {
+ return {
+ ...state,
+ };
+ }
+ return state;
+
+ case "SOURCE_MAP_ERROR": {
+ state.mutableSourceMapErrors.set(
+ action.sourceActor.id,
+ action.errorMessage
+ );
+ return { ...state };
+ }
+
+ case "RESOLVED_SOURCEMAP_URL": {
+ state.mutableResolvedSourceMapURL.set(
+ action.sourceActor.id,
+ action.resolvedSourceMapURL
+ );
+ return { ...state };
+ }
+ }
+
+ return state;
+}
diff --git a/devtools/client/debugger/src/reducers/source-blackbox.js b/devtools/client/debugger/src/reducers/source-blackbox.js
new file mode 100644
index 0000000000..3ccca49790
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/source-blackbox.js
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Reducer containing all data about sources being "black boxed".
+ *
+ * i.e. sources which should be ignored by the debugger.
+ * Typically, these sources should be hidden from paused stack frames,
+ * and any debugger statement or breakpoint should be ignored.
+ */
+
+export function initialSourceBlackBoxState(state) {
+ return {
+ /* FORMAT:
+ * blackboxedRanges: {
+ * [source url]: [range, range, ...], -- source lines blackboxed
+ * [source url]: [], -- whole source blackboxed
+ * ...
+ * }
+ */
+ blackboxedRanges: state?.blackboxedRanges ?? {},
+
+ blackboxedSet: state?.blackboxedRanges
+ ? new Set(Object.keys(state.blackboxedRanges))
+ : new Set(),
+
+ sourceMapIgnoreListUrls: [],
+ };
+}
+
+function update(state = initialSourceBlackBoxState(), action) {
+ switch (action.type) {
+ case "BLACKBOX_WHOLE_SOURCES": {
+ const { sources } = action;
+
+ const currentBlackboxedRanges = { ...state.blackboxedRanges };
+ const currentBlackboxedSet = new Set(state.blackboxedSet);
+
+ for (const source of sources) {
+ currentBlackboxedRanges[source.url] = [];
+ currentBlackboxedSet.add(source.url);
+ }
+
+ return {
+ ...state,
+ blackboxedRanges: currentBlackboxedRanges,
+ blackboxedSet: currentBlackboxedSet,
+ };
+ }
+
+ case "BLACKBOX_SOURCE_RANGES": {
+ const { source, ranges } = action;
+
+ const currentBlackboxedRanges = { ...state.blackboxedRanges };
+ const currentBlackboxedSet = new Set(state.blackboxedSet);
+
+ if (!currentBlackboxedRanges[source.url]) {
+ currentBlackboxedRanges[source.url] = [];
+ currentBlackboxedSet.add(source.url);
+ } else {
+ currentBlackboxedRanges[source.url] = [
+ ...state.blackboxedRanges[source.url],
+ ];
+ }
+
+ // Add new blackboxed lines in acsending order
+ for (const newRange of ranges) {
+ const index = currentBlackboxedRanges[source.url].findIndex(
+ range =>
+ range.end.line <= newRange.start.line &&
+ range.end.column <= newRange.start.column
+ );
+ currentBlackboxedRanges[source.url].splice(index + 1, 0, newRange);
+ }
+
+ return {
+ ...state,
+ blackboxedRanges: currentBlackboxedRanges,
+ blackboxedSet: currentBlackboxedSet,
+ };
+ }
+
+ case "UNBLACKBOX_WHOLE_SOURCES": {
+ const { sources } = action;
+
+ const currentBlackboxedRanges = { ...state.blackboxedRanges };
+ const currentBlackboxedSet = new Set(state.blackboxedSet);
+
+ for (const source of sources) {
+ delete currentBlackboxedRanges[source.url];
+ currentBlackboxedSet.delete(source.url);
+ }
+
+ return {
+ ...state,
+ blackboxedRanges: currentBlackboxedRanges,
+ blackboxedSet: currentBlackboxedSet,
+ };
+ }
+
+ case "UNBLACKBOX_SOURCE_RANGES": {
+ const { source, ranges } = action;
+
+ const currentBlackboxedRanges = {
+ ...state.blackboxedRanges,
+ [source.url]: [...state.blackboxedRanges[source.url]],
+ };
+
+ for (const newRange of ranges) {
+ const index = currentBlackboxedRanges[source.url].findIndex(
+ range =>
+ range.start.line === newRange.start.line &&
+ range.end.line === newRange.end.line
+ );
+
+ if (index !== -1) {
+ currentBlackboxedRanges[source.url].splice(index, 1);
+ }
+ }
+
+ return {
+ ...state,
+ blackboxedRanges: currentBlackboxedRanges,
+ };
+ }
+
+ case "ADD_SOURCEMAP_IGNORE_LIST_SOURCES": {
+ return {
+ ...state,
+ sourceMapIgnoreListUrls: [
+ ...state.sourceMapIgnoreListUrls,
+ ...action.ignoreListUrls,
+ ],
+ };
+ }
+
+ case "NAVIGATE":
+ return initialSourceBlackBoxState(state);
+ }
+
+ return state;
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/sources-content.js b/devtools/client/debugger/src/reducers/sources-content.js
new file mode 100644
index 0000000000..e71330dc5c
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/sources-content.js
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Sources content reducer.
+ *
+ * This store the textual content for each source.
+ */
+
+import { pending, fulfilled, rejected } from "../utils/async-value";
+
+export function initialSourcesContentState() {
+ return {
+ /**
+ * Text content of all the original sources.
+ * This is large data, so this is only fetched on-demand for a subset of sources.
+ * This state attribute is mutable in order to avoid cloning this possibly large map
+ * on each new source. But selectors are never based on the map. Instead they only
+ * query elements of the map.
+ *
+ * Map(source id => AsyncValue<String>)
+ */
+ mutableOriginalSourceTextContentMapBySourceId: new Map(),
+
+ /**
+ * Text content of all the generated sources.
+ *
+ * Map(source actor is => AsyncValue<String>)
+ */
+ mutableGeneratedSourceTextContentMapBySourceActorId: new Map(),
+
+ /**
+ * Incremental number that is bumped each time we navigate to a new page.
+ *
+ * This is used to better handle async race condition where we mix previous page data
+ * with the new page. As sources are keyed by URL we may easily conflate the two page loads data.
+ */
+ epoch: 1,
+ };
+}
+
+function update(state = initialSourcesContentState(), action) {
+ switch (action.type) {
+ case "LOAD_ORIGINAL_SOURCE_TEXT":
+ if (!action.source) {
+ throw new Error("Missing source");
+ }
+ return updateSourceTextContent(state, action);
+
+ case "LOAD_GENERATED_SOURCE_TEXT":
+ if (!action.sourceActor) {
+ throw new Error("Missing source actor.");
+ }
+ return updateSourceTextContent(state, action);
+
+ case "REMOVE_THREAD":
+ return removeThread(state, action);
+ }
+
+ return state;
+}
+
+/*
+ * Update a source's loaded text content.
+ */
+function updateSourceTextContent(state, action) {
+ // If there was a navigation between the time the action was started and
+ // completed, we don't want to update the store.
+ if (action.epoch !== state.epoch) {
+ return state;
+ }
+
+ let content;
+ if (action.status === "start") {
+ content = pending();
+ } else if (action.status === "error") {
+ content = rejected(action.error);
+ } else if (typeof action.value.text === "string") {
+ content = fulfilled({
+ type: "text",
+ value: action.value.text,
+ contentType: action.value.contentType,
+ });
+ } else {
+ content = fulfilled({
+ type: "wasm",
+ value: action.value.text,
+ });
+ }
+
+ if (action.source && action.sourceActor) {
+ throw new Error(
+ "Both the source and the source actor should not exist at the same time"
+ );
+ }
+
+ if (action.source) {
+ state.mutableOriginalSourceTextContentMapBySourceId.set(
+ action.source.id,
+ content
+ );
+ }
+
+ if (action.sourceActor) {
+ state.mutableGeneratedSourceTextContentMapBySourceActorId.set(
+ action.sourceActor.id,
+ content
+ );
+ }
+
+ return {
+ ...state,
+ };
+}
+
+function removeThread(state, action) {
+ const originalSizeBefore =
+ state.mutableOriginalSourceTextContentMapBySourceId.size;
+ for (const source of action.sources) {
+ state.mutableOriginalSourceTextContentMapBySourceId.delete(source.id);
+ }
+ const generatedSizeBefore =
+ state.mutableGeneratedSourceTextContentMapBySourceActorId.size;
+ for (const actor of action.actors) {
+ state.mutableGeneratedSourceTextContentMapBySourceActorId.delete(actor.id);
+ }
+ if (
+ originalSizeBefore !=
+ state.mutableOriginalSourceTextContentMapBySourceId.size ||
+ generatedSizeBefore !=
+ state.mutableGeneratedSourceTextContentMapBySourceActorId.size
+ ) {
+ return { ...state };
+ }
+ return state;
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/sources-tree.js b/devtools/client/debugger/src/reducers/sources-tree.js
new file mode 100644
index 0000000000..0f0e8dadb3
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/sources-tree.js
@@ -0,0 +1,704 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Sources tree reducer
+ *
+ * A Source Tree is composed of:
+ *
+ * - Thread Items To designate targets/threads.
+ * These are the roots of the Tree if no project directory is selected.
+ *
+ * - Group Items To designates the different domains used in the website.
+ * These are direct children of threads and may contain directory or source items.
+ *
+ * - Directory Items To designate all the folders.
+ * Note that each every folder has an items. The Source Tree React component is doing the magic to coallesce folders made of only one sub folder.
+ *
+ * - Source Items To designate sources.
+ * They are the leaves of the Tree. (we should not have empty directories.)
+ */
+
+const IGNORED_URLS = ["debugger eval code", "XStringBundle"];
+const IGNORED_EXTENSIONS = ["css", "svg", "png"];
+import { isPretty, getRawSourceURL } from "../utils/source";
+import { prefs } from "../utils/prefs";
+
+export function initialSourcesTreeState() {
+ return {
+ // List of all Thread Tree Items.
+ // All other item types are children of these and aren't store in
+ // the reducer as top level objects.
+ threadItems: [],
+
+ // List of `uniquePath` of Tree Items that are expanded.
+ // This should be all but Source Tree Items.
+ expanded: new Set(),
+
+ // Reference to the currently focused Tree Item.
+ // It can be any type of Tree Item.
+ focusedItem: null,
+
+ // Project root set from the Source Tree.
+ // This focuses the source tree on a subset of sources.
+ // This is a `uniquePath`, where ${thread} is replaced by "top-level"
+ // when we picked an item from the main thread. This allows to preserve
+ // the root selection on page reload.
+ projectDirectoryRoot: prefs.projectDirectoryRoot,
+
+ // The name is displayed in Source Tree header
+ projectDirectoryRootName: prefs.projectDirectoryRootName,
+
+ // Reports if the top level target is a web extension.
+ // If so, we should display all web extension sources.
+ isWebExtension: false,
+
+ /**
+ * Boolean, to be set to true in order to display WebExtension's content scripts
+ * that are applied to the current page we are debugging.
+ *
+ * Covered by: browser_dbg-content-script-sources.js
+ * Bound to: devtools.chrome.enabled
+ *
+ */
+ chromeAndExtensionsEnabled: prefs.chromeAndExtensionsEnabled,
+ };
+}
+
+// eslint-disable-next-line complexity
+export default function update(state = initialSourcesTreeState(), action) {
+ switch (action.type) {
+ case "ADD_ORIGINAL_SOURCES": {
+ const { generatedSourceActor } = action;
+ const validOriginalSources = action.originalSources.filter(source =>
+ isSourceVisibleInSourceTree(
+ source,
+ state.chromeAndExtensionsEnabled,
+ state.isWebExtension
+ )
+ );
+ if (!validOriginalSources.length) {
+ return state;
+ }
+ let changed = false;
+ // Fork the array only once for all the sources
+ const threadItems = [...state.threadItems];
+ for (const source of validOriginalSources) {
+ changed |= addSource(threadItems, source, generatedSourceActor);
+ }
+ if (changed) {
+ return {
+ ...state,
+ threadItems,
+ };
+ }
+ return state;
+ }
+ case "INSERT_SOURCE_ACTORS": {
+ // With this action, we only cover generated sources.
+ // (i.e. we need something else for sourcemapped/original sources)
+ // But we do want to process source actors in order to be able to display
+ // distinct Source Tree Items for sources with the same URL loaded in distinct thread.
+ // (And may be also later be able to highlight the many sources with the same URL loaded in a given thread)
+ const newSourceActors = action.sourceActors.filter(sourceActor =>
+ isSourceVisibleInSourceTree(
+ sourceActor.sourceObject,
+ state.chromeAndExtensionsEnabled,
+ state.isWebExtension
+ )
+ );
+ if (!newSourceActors.length) {
+ return state;
+ }
+ let changed = false;
+ // Fork the array only once for all the sources
+ const threadItems = [...state.threadItems];
+ for (const sourceActor of newSourceActors) {
+ // We mostly wanted to read the thread of the SourceActor,
+ // most of the interesting attributes are on the Source Object.
+ changed |= addSource(
+ threadItems,
+ sourceActor.sourceObject,
+ sourceActor
+ );
+ }
+ if (changed) {
+ return {
+ ...state,
+ threadItems,
+ };
+ }
+ return state;
+ }
+
+ case "INSERT_THREAD":
+ state = { ...state };
+ addThread(state, action.newThread);
+ return state;
+
+ case "REMOVE_THREAD": {
+ const { threadActorID } = action;
+ const index = state.threadItems.findIndex(item => {
+ return item.threadActorID == threadActorID;
+ });
+
+ if (index == -1) {
+ return state;
+ }
+
+ // Also clear focusedItem and expanded items related
+ // to this thread. These fields store uniquePath which starts
+ // with the thread actor ID.
+ let { focusedItem } = state;
+ if (focusedItem && focusedItem.uniquePath.startsWith(threadActorID)) {
+ focusedItem = null;
+ }
+ const expanded = new Set();
+ for (const path of state.expanded) {
+ if (!path.startsWith(threadActorID)) {
+ expanded.add(path);
+ }
+ }
+
+ const threadItems = [...state.threadItems];
+ threadItems.splice(index, 1);
+ return {
+ ...state,
+ threadItems,
+ focusedItem,
+ expanded,
+ };
+ }
+
+ case "SET_EXPANDED_STATE":
+ return updateExpanded(state, action);
+
+ case "SET_FOCUSED_SOURCE_ITEM":
+ return { ...state, focusedItem: action.item };
+
+ case "SET_SELECTED_LOCATION":
+ return updateSelectedLocation(state, action.location);
+
+ case "SET_PROJECT_DIRECTORY_ROOT":
+ const { uniquePath, name } = action;
+ return updateProjectDirectoryRoot(state, uniquePath, name);
+
+ case "BLACKBOX_WHOLE_SOURCES":
+ case "BLACKBOX_SOURCE_RANGES": {
+ const sources = action.sources || [action.source];
+ return updateBlackbox(state, sources, true);
+ }
+
+ case "UNBLACKBOX_WHOLE_SOURCES": {
+ const sources = action.sources || [action.source];
+ return updateBlackbox(state, sources, false);
+ }
+ }
+ return state;
+}
+
+function addThread(state, thread) {
+ const threadActorID = thread.actor;
+ // When processing the top level target,
+ // see if we are debugging an extension.
+ if (thread.isTopLevel) {
+ state.isWebExtension = thread.isWebExtension;
+ }
+ let threadItem = state.threadItems.find(item => {
+ return item.threadActorID == threadActorID;
+ });
+ if (!threadItem) {
+ threadItem = createThreadTreeItem(threadActorID);
+ state.threadItems = [...state.threadItems];
+ threadItem.thread = thread;
+ addSortedItem(state.threadItems, threadItem, sortThreadItems);
+ } else {
+ // We force updating the list to trigger mapStateToProps
+ // as the getSourcesTreeSources selector is awaiting for the `thread` attribute
+ // which we will set here.
+ state.threadItems = [...state.threadItems];
+
+ // Inject the reducer thread object on Thread Tree Items
+ // (this is handy shortcut to have access to from React components)
+ // (this is also used by sortThreadItems to sort the thread as a Tree in the Browser Toolbox)
+ threadItem.thread = thread;
+
+ // We have to re-sort all threads because of the new `thread` attribute on current thread item
+ state.threadItems.sort(sortThreadItems);
+ }
+}
+
+function updateBlackbox(state, sources, shouldBlackBox) {
+ const threadItems = [...state.threadItems];
+
+ for (const source of sources) {
+ for (const threadItem of threadItems) {
+ const sourceTreeItem = findSourceInThreadItem(source, threadItem);
+ if (sourceTreeItem && sourceTreeItem.isBlackBoxed != shouldBlackBox) {
+ // Replace the Item with a clone so that we get the expected React updates
+ const { children } = sourceTreeItem.parent;
+ children.splice(children.indexOf(sourceTreeItem), 1, {
+ ...sourceTreeItem,
+ isBlackBoxed: shouldBlackBox,
+ });
+ }
+ }
+ }
+ return { ...state, threadItems };
+}
+
+function updateExpanded(state, action) {
+ // We receive the full list of all expanded items
+ // (not only the one added/removed)
+ // Also assume that this action is called only if the Set changed.
+ return {
+ ...state,
+ // Consider that the action already cloned the Set
+ expanded: action.expanded,
+ };
+}
+
+/**
+ * Update the project directory root
+ */
+function updateProjectDirectoryRoot(state, uniquePath, name) {
+ // Only persists root within the top level target.
+ // Otherwise the thread actor ID will change on page reload and we won't match anything
+ if (!uniquePath || uniquePath.startsWith("top-level")) {
+ prefs.projectDirectoryRoot = uniquePath;
+ prefs.projectDirectoryRootName = name;
+ }
+
+ return {
+ ...state,
+ projectDirectoryRoot: uniquePath,
+ projectDirectoryRootName: name,
+ };
+}
+
+function isSourceVisibleInSourceTree(
+ source,
+ chromeAndExtensionsEnabled,
+ debuggeeIsWebExtension
+) {
+ return (
+ !!source.url &&
+ !IGNORED_EXTENSIONS.includes(source.displayURL.fileExtension) &&
+ !IGNORED_URLS.includes(source.url) &&
+ !isPretty(source) &&
+ // Only accept web extension sources when the chrome pref is enabled (to allows showing content scripts),
+ // or when we are debugging an extension
+ (!source.isExtension ||
+ chromeAndExtensionsEnabled ||
+ debuggeeIsWebExtension)
+ );
+}
+
+/**
+ * Generic Array helper to add a new value at the right position
+ * given that the array is already sorted.
+ *
+ * @param {Array} array
+ * The already sorted into which a value should be added.
+ * @param {any} newValue
+ * The value to add in the array while keeping the array sorted.
+ * @param {Function} sortFunction
+ * A function to compare two array values and their ordering.
+ * Follow same behavior as Array sorting function.
+ */
+function addSortedItem(array, newValue, sortFunction) {
+ let index = array.findIndex(value => sortFunction(value, newValue) === 1);
+ index = index >= 0 ? index : array.length;
+ array.splice(index, 0, newValue);
+}
+
+function addSource(threadItems, source, sourceActor) {
+ // Ensure creating or fetching the related Thread Item
+ let threadItem = threadItems.find(item => {
+ return item.threadActorID == sourceActor.thread;
+ });
+ if (!threadItem) {
+ threadItem = createThreadTreeItem(sourceActor.thread);
+ // Note that threadItems will be cloned once to force a state update
+ // by the callsite of `addSourceActor`
+ addSortedItem(threadItems, threadItem, sortThreadItems);
+ }
+
+ // Then ensure creating or fetching the related Group Item
+ // About `source` versus `sourceActor`:
+ const { displayURL } = source;
+ const { group } = displayURL;
+
+ let groupItem = threadItem.children.find(item => {
+ return item.groupName == group;
+ });
+
+ if (!groupItem) {
+ groupItem = createGroupTreeItem(group, threadItem, source);
+ // Copy children in order to force updating react in case we picked
+ // this directory as a project root
+ threadItem.children = [...threadItem.children];
+
+ addSortedItem(threadItem.children, groupItem, sortItems);
+ }
+
+ // Then ensure creating or fetching all possibly nested Directory Item(s)
+ const { path } = displayURL;
+ const parentPath = path.substring(0, path.lastIndexOf("/"));
+ const directoryItem = addOrGetParentDirectory(groupItem, parentPath);
+
+ // Check if a previous source actor registered this source.
+ // It happens if we load the same url multiple times, or,
+ // for inline sources (=HTML pages with inline scripts).
+ const existing = directoryItem.children.find(item => {
+ return item.type == "source" && item.source == source;
+ });
+ if (existing) {
+ return false;
+ }
+
+ // Finaly, create the Source Item and register it in its parent Directory Item
+ const sourceItem = createSourceTreeItem(source, sourceActor, directoryItem);
+ // Copy children in order to force updating react in case we picked
+ // this directory as a project root
+ directoryItem.children = [...directoryItem.children];
+ addSortedItem(directoryItem.children, sourceItem, sortItems);
+
+ return true;
+}
+/**
+ * Find all the source items in tree
+ * @param {Object} item - Current item node in the tree
+ * @param {Function} callback
+ */
+function findSourceInThreadItem(source, threadItem) {
+ const { displayURL } = source;
+ const { group, path } = displayURL;
+ const groupItem = threadItem.children.find(item => {
+ return item.groupName == group;
+ });
+ if (!groupItem) return null;
+
+ const parentPath = path.substring(0, path.lastIndexOf("/"));
+ const directoryItem = groupItem._allGroupDirectoryItems.find(item => {
+ return item.type == "directory" && item.path == parentPath;
+ });
+ if (!directoryItem) return null;
+
+ return directoryItem.children.find(item => {
+ return item.type == "source" && item.source == source;
+ });
+}
+
+function sortItems(a, b) {
+ if (a.type == "directory" && b.type == "source") {
+ return -1;
+ } else if (b.type == "directory" && a.type == "source") {
+ return 1;
+ } else if (a.type == "directory" && b.type == "directory") {
+ return a.path.localeCompare(b.path);
+ } else if (a.type == "source" && b.type == "source") {
+ return a.source.displayURL.filename.localeCompare(
+ b.source.displayURL.filename
+ );
+ }
+ return 0;
+}
+
+function sortThreadItems(a, b) {
+ // Jest tests aren't emitting the necessary actions to populate the thread attributes.
+ // Ignore sorting for them.
+ if (!a.thread || !b.thread) {
+ return 0;
+ }
+
+ // Top level target is always listed first
+ if (a.thread.isTopLevel) {
+ return -1;
+ } else if (b.thread.isTopLevel) {
+ return 1;
+ }
+
+ // Process targets should come next and after that frame targets
+ if (a.thread.targetType == "process" && b.thread.targetType == "frame") {
+ return -1;
+ } else if (
+ a.thread.targetType == "frame" &&
+ b.thread.targetType == "process"
+ ) {
+ return 1;
+ }
+
+ // And we display the worker targets last.
+ if (
+ a.thread.targetType.endsWith("worker") &&
+ !b.thread.targetType.endsWith("worker")
+ ) {
+ return 1;
+ } else if (
+ !a.thread.targetType.endsWith("worker") &&
+ b.thread.targetType.endsWith("worker")
+ ) {
+ return -1;
+ }
+
+ // Order the process targets by their process ids
+ if (a.thread.processID > b.thread.processID) {
+ return 1;
+ } else if (a.thread.processID < b.thread.processID) {
+ return 0;
+ }
+
+ // Order the frame targets and the worker targets by their target name
+ if (a.thread.targetType == "frame" && b.thread.targetType == "frame") {
+ return a.thread.name.localeCompare(b.thread.name);
+ } else if (
+ a.thread.targetType.endsWith("worker") &&
+ b.thread.targetType.endsWith("worker")
+ ) {
+ return a.thread.name.localeCompare(b.thread.name);
+ }
+
+ return 0;
+}
+
+/**
+ * For a given URL's path, in the given group (i.e. typically a given scheme+domain),
+ * return the already existing parent directory item, or create it if it doesn't exists.
+ * Note that it will create all ancestors up to the Group Item.
+ *
+ * @param {GroupItem} groupItem
+ * The Group Item for the group where the path should be displayed.
+ * @param {String} path
+ * Path of the directory for which we want a Directory Item.
+ * @return {GroupItem|DirectoryItem}
+ * The parent Item where this path should be inserted.
+ * Note that it may be displayed right under the Group Item if the path is empty.
+ */
+function addOrGetParentDirectory(groupItem, path) {
+ // We reached the top of the Tree, so return the Group Item.
+ if (!path) {
+ return groupItem;
+ }
+ // See if we have this directory already registered by a previous source
+ const existing = groupItem._allGroupDirectoryItems.find(item => {
+ return item.type == "directory" && item.path == path;
+ });
+ if (existing) {
+ return existing;
+ }
+ // It doesn't exists, so we will create a new Directory Item.
+ // But now, lookup recursively for the parent Item for this to-be-create Directory Item
+ const parentPath = path.substring(0, path.lastIndexOf("/"));
+ const parentDirectory = addOrGetParentDirectory(groupItem, parentPath);
+
+ // We can now create the new Directory Item and register it in its parent Item.
+ const directory = createDirectoryTreeItem(path, parentDirectory);
+ // Copy children in order to force updating react in case we picked
+ // this directory as a project root
+ parentDirectory.children = [...parentDirectory.children];
+
+ addSortedItem(parentDirectory.children, directory, sortItems);
+
+ // Also maintain the list of all group items,
+ // Which helps speedup querying for existing items.
+ groupItem._allGroupDirectoryItems.push(directory);
+
+ return directory;
+}
+
+/**
+ * Definition of all Items of a SourceTree
+ */
+// Highlights the attributes that all Source Tree Item should expose
+function createBaseTreeItem({ type, parent, uniquePath, children }) {
+ return {
+ // Can be: thread, group, directory or source
+ type,
+ // Reference to the parent TreeItem
+ parent,
+ // This attribute is used for two things:
+ // * as a string key identified in the React Tree
+ // * for project root in order to find the root in the tree
+ // It is of the form:
+ // `${ThreadActorID}|${GroupName}|${DirectoryPath}|${SourceID}`
+ // Group and path/ID are optional.
+ // `|` is used as separator in order to avoid having this character being used in name/path/IDs.
+ uniquePath,
+ // Array of TreeItem, children of this item.
+ // Will be null for Source Tree Item
+ children,
+ };
+}
+function createThreadTreeItem(thread) {
+ return {
+ ...createBaseTreeItem({
+ type: "thread",
+ // Each thread is considered as an independant root item
+ parent: null,
+ uniquePath: thread,
+ // Children of threads will only be Group Items
+ children: [],
+ }),
+
+ // This will be used to set the reducer's thread object.
+ // This threadActorID attribute isn't meant to be used outside of this selector.
+ // A `thread` attribute will be exposed from INSERT_THREAD action.
+ threadActorID: thread,
+ };
+}
+function createGroupTreeItem(groupName, parent, source) {
+ return {
+ ...createBaseTreeItem({
+ type: "group",
+ parent,
+ uniquePath: `${parent.uniquePath}|${groupName}`,
+ // Children of Group can be Directory and Source items
+ children: [],
+ }),
+
+ groupName,
+
+ // When a content script appear in a web page,
+ // a dedicated group is created for it and should
+ // be having an extension icon.
+ isForExtensionSource: source.isExtension,
+
+ // List of all nested items for this group.
+ // This helps find any nested directory in a given group without having to walk the tree.
+ // This is meant to be used only by the reducer.
+ _allGroupDirectoryItems: [],
+ };
+}
+function createDirectoryTreeItem(path, parent) {
+ // If the parent is a group we want to use '/' as separator
+ const pathSeparator = parent.type == "directory" ? "/" : "|";
+
+ // `path` will be the absolute path from the group/domain,
+ // while we want to append only the directory name in uniquePath.
+ // Also, we need to strip '/' prefix.
+ const relativePath =
+ parent.type == "directory"
+ ? path.replace(parent.path, "").replace(/^\//, "")
+ : path;
+
+ return {
+ ...createBaseTreeItem({
+ type: "directory",
+ parent,
+ uniquePath: `${parent.uniquePath}${pathSeparator}${relativePath}`,
+ // Children can be nested Directory or Source items
+ children: [],
+ }),
+
+ // This is the absolute path from the "group"
+ // i.e. the path from the domain name
+ // For http://mozilla.org/foo/bar folder,
+ // path will be:
+ // foo/bar
+ path,
+ };
+}
+function createSourceTreeItem(source, sourceActor, parent) {
+ return {
+ ...createBaseTreeItem({
+ type: "source",
+ parent,
+ uniquePath: `${parent.uniquePath}|${source.id}`,
+ // Sources items are leaves of the SourceTree
+ children: null,
+ }),
+
+ source,
+ sourceActor,
+ };
+}
+
+/**
+ * Update `expanded` and `focusedItem` so that we show and focus
+ * the new selected source.
+ *
+ * @param {Object} state
+ * @param {Object} selectedLocation
+ * The new location being selected.
+ */
+function updateSelectedLocation(state, selectedLocation) {
+ const sourceItem = getSourceItemForSelectedLocation(state, selectedLocation);
+ if (sourceItem) {
+ // Walk up the tree to expand all ancestor items up to the root of the tree.
+ const expanded = new Set(state.expanded);
+ let parentDirectory = sourceItem;
+ while (parentDirectory) {
+ expanded.add(parentDirectory.uniquePath);
+ parentDirectory = parentDirectory.parent;
+ }
+
+ return {
+ ...state,
+ expanded,
+ focusedItem: sourceItem,
+ };
+ }
+ return state;
+}
+
+/**
+ * Get the SourceItem displayed in the SourceTree for the currently selected location.
+ *
+ * @param {Object} state
+ * @param {Object} selectedLocation
+ * @return {SourceItem}
+ * The directory source item where the given source is displayed.
+ */
+function getSourceItemForSelectedLocation(state, selectedLocation) {
+ const { source, sourceActor } = selectedLocation;
+
+ // Sources without URLs are not visible in the SourceTree
+ if (!source.url) {
+ return null;
+ }
+
+ // In the SourceTree, we never show the pretty printed sources and only
+ // the minified version, so if we are selecting a pretty file, fake selecting
+ // the minified version by looking up for the minified URL instead of the pretty one.
+ const sourceUrl = getRawSourceURL(source.url);
+
+ const { displayURL } = source;
+ function findSourceInItem(item, path) {
+ if (item.type == "source") {
+ if (item.source.url == sourceUrl) {
+ return item;
+ }
+ return null;
+ }
+ // Bail out if the current item doesn't match the source
+ if (item.type == "thread" && item.threadActorID != sourceActor?.thread) {
+ return null;
+ }
+ if (item.type == "group" && displayURL.group != item.groupName) {
+ return null;
+ }
+ if (item.type == "directory" && !path.startsWith(item.path)) {
+ return null;
+ }
+ // Otherwise, walk down the tree if this ancestor item seems to match
+ for (const child of item.children) {
+ const match = findSourceInItem(child, path);
+ if (match) {
+ return match;
+ }
+ }
+
+ return null;
+ }
+ for (const rootItem of state.threadItems) {
+ // Note that when we are setting a project root, rootItem
+ // may no longer be only Thread Item, but also be Group, Directory or Source Items.
+ const item = findSourceInItem(rootItem, displayURL.path);
+ if (item) {
+ return item;
+ }
+ }
+ return null;
+}
diff --git a/devtools/client/debugger/src/reducers/sources.js b/devtools/client/debugger/src/reducers/sources.js
new file mode 100644
index 0000000000..76160b75f2
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/sources.js
@@ -0,0 +1,395 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Sources reducer
+ * @module reducers/sources
+ */
+
+import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/index";
+import { prefs } from "../utils/prefs";
+import { createPendingSelectedLocation } from "../utils/location";
+
+export const UNDEFINED_LOCATION = Symbol("Undefined location");
+export const NO_LOCATION = Symbol("No location");
+
+export function initialSourcesState(state) {
+ /* eslint sort-keys: "error" */
+ return {
+ /**
+ * List of all breakpoint positions for all sources (generated and original).
+ * Map of source id (string) to dictionary object whose keys are line numbers
+ * and values of array of positions.
+ * A position is an object made with two attributes:
+ * location and generatedLocation. Both refering to breakpoint positions
+ * in original and generated sources.
+ * In case of generated source, the two location will be the same.
+ *
+ * Map(source id => Dictionary(int => array<Position>))
+ */
+ mutableBreakpointPositions: new Map(),
+
+ /**
+ * List of all breakable lines for original sources only.
+ *
+ * Map(source id => array<int : breakable line numbers>)
+ */
+ mutableOriginalBreakableLines: new Map(),
+
+ /**
+ * Map of the source id's to one or more related original source id's
+ * Only generated sources which have related original sources will be maintained here.
+ *
+ * Map(source id => array<Original Source ID>)
+ */
+ mutableOriginalSources: new Map(),
+
+ /**
+ * List of override objects whose sources texts have been locally overridden.
+ *
+ * Object { sourceUrl, path }
+ */
+ mutableOverrideSources: state?.mutableOverrideSources || new Map(),
+
+ /**
+ * Mapping of source id's to one or more source-actor's.
+ * Dictionary whose keys are source id's and values are arrays
+ * made of all the related source-actor's.
+ * Note: The source mapped here are only generated sources.
+ *
+ * "source" are the objects stored in this reducer, in the `sources` attribute.
+ * "source-actor" are the objects stored in the "source-actors.js" reducer, in its `sourceActors` attribute.
+ *
+ * Map(source id => array<Source Actor object>)
+ */
+ mutableSourceActors: new Map(),
+
+ /**
+ * All currently available sources.
+ *
+ * See create.js: `createSourceObject` method for the description of stored objects.
+ */
+ mutableSources: new Map(),
+
+ /**
+ * All sources associated with a given URL. When using source maps, multiple
+ * sources can have the same URL.
+ *
+ * Map(url => array<source>)
+ */
+ mutableSourcesPerUrl: new Map(),
+
+ /**
+ * When we want to select a source that isn't available yet, use this.
+ * The location object should have a url attribute instead of a sourceId.
+ *
+ * See `createPendingSelectedLocation` for the definition of this object.
+ */
+ pendingSelectedLocation: prefs.pendingSelectedLocation,
+
+ /**
+ * The actual currently selected location.
+ * Only set if the related source is already registered in the sources reducer.
+ * Otherwise, pendingSelectedLocation should be used. Typically for sources
+ * which are about to be created.
+ *
+ * It also includes line and column information.
+ *
+ * See `createLocation` for the definition of this object.
+ */
+ selectedLocation: undefined,
+
+ /**
+ * When selectedLocation refers to a generated source mapping to an original source
+ * via a source-map, refers to the related original location.
+ *
+ * This is UNDEFINED_LOCATION by default and will switch to NO_LOCATION asynchronously after location
+ * selection if there is no valid original location to map to.
+ */
+ selectedOriginalLocation: UNDEFINED_LOCATION,
+
+ /**
+ * By default, the `selectedLocation` should be highlighted in the editor with a special background.
+ * On demand, this flag can be set to false in order to prevent this.
+ * The location will be shown, but not highlighted.
+ */
+ shouldHighlightSelectedLocation: true,
+
+ /**
+ * By default, if we have a source-mapped source, we would automatically try
+ * to select and show the content of the original source. But, if we explicitly
+ * select a generated source, we remember this choice. That, until we explicitly
+ * select an original source.
+ * Note that selections related to non-source-mapped sources should never
+ * change this setting.
+ */
+ shouldSelectOriginalLocation: true,
+ };
+ /* eslint-disable sort-keys */
+}
+
+function update(state = initialSourcesState(), action) {
+ switch (action.type) {
+ case "ADD_SOURCES":
+ return addSources(state, action.sources);
+
+ case "ADD_ORIGINAL_SOURCES":
+ return addSources(state, action.originalSources);
+
+ case "INSERT_SOURCE_ACTORS":
+ return insertSourceActors(state, action);
+
+ case "SET_SELECTED_LOCATION": {
+ let pendingSelectedLocation = null;
+
+ if (action.location.source.url) {
+ pendingSelectedLocation = createPendingSelectedLocation(
+ action.location
+ );
+ prefs.pendingSelectedLocation = pendingSelectedLocation;
+ }
+
+ return {
+ ...state,
+ selectedLocation: action.location,
+ selectedOriginalLocation: UNDEFINED_LOCATION,
+ pendingSelectedLocation,
+ shouldSelectOriginalLocation: action.shouldSelectOriginalLocation,
+ shouldHighlightSelectedLocation: action.shouldHighlightSelectedLocation,
+ };
+ }
+
+ case "CLEAR_SELECTED_LOCATION": {
+ const pendingSelectedLocation = { url: "" };
+ prefs.pendingSelectedLocation = pendingSelectedLocation;
+
+ return {
+ ...state,
+ selectedLocation: null,
+ selectedOriginalLocation: UNDEFINED_LOCATION,
+ pendingSelectedLocation,
+ };
+ }
+
+ case "SET_ORIGINAL_SELECTED_LOCATION": {
+ if (action.location != state.selectedLocation) {
+ return state;
+ }
+ return {
+ ...state,
+ selectedOriginalLocation: action.originalLocation,
+ };
+ }
+
+ case "SET_PENDING_SELECTED_LOCATION": {
+ const pendingSelectedLocation = {
+ url: action.url,
+ line: action.line,
+ column: action.column,
+ };
+
+ prefs.pendingSelectedLocation = pendingSelectedLocation;
+ return { ...state, pendingSelectedLocation };
+ }
+
+ case "SET_ORIGINAL_BREAKABLE_LINES": {
+ state.mutableOriginalBreakableLines.set(
+ action.source.id,
+ action.breakableLines
+ );
+
+ return {
+ ...state,
+ };
+ }
+
+ case "ADD_BREAKPOINT_POSITIONS": {
+ // Merge existing and new reported position if some where already stored
+ let positions = state.mutableBreakpointPositions.get(action.source.id);
+ if (positions) {
+ positions = { ...positions, ...action.positions };
+ } else {
+ positions = action.positions;
+ }
+
+ state.mutableBreakpointPositions.set(action.source.id, positions);
+
+ return {
+ ...state,
+ };
+ }
+
+ case "REMOVE_THREAD": {
+ return removeSourcesAndActors(state, action);
+ }
+
+ case "SET_OVERRIDE": {
+ state.mutableOverrideSources.set(action.url, action.path);
+ return state;
+ }
+
+ case "REMOVE_OVERRIDE": {
+ if (state.mutableOverrideSources.has(action.url)) {
+ state.mutableOverrideSources.delete(action.url);
+ }
+ return state;
+ }
+ }
+
+ return state;
+}
+
+/*
+ * Add sources to the sources store
+ * - Add the source to the sources store
+ * - Add the source URL to the source url map
+ */
+function addSources(state, sources) {
+ for (const source of sources) {
+ state.mutableSources.set(source.id, source);
+
+ // Update the source url map
+ const existing = state.mutableSourcesPerUrl.get(source.url);
+ if (existing) {
+ // We never return this array from selectors as-is,
+ // we either return the first entry or lookup for a precise entry
+ // so we can mutate it.
+ existing.push(source);
+ } else {
+ state.mutableSourcesPerUrl.set(source.url, [source]);
+ }
+
+ // In case of original source, maintain the mapping of generated source to original sources map.
+ if (source.isOriginal) {
+ const generatedSourceId = originalToGeneratedId(source.id);
+ let originalSourceIds =
+ state.mutableOriginalSources.get(generatedSourceId);
+ if (!originalSourceIds) {
+ originalSourceIds = [];
+ state.mutableOriginalSources.set(generatedSourceId, originalSourceIds);
+ }
+ // We never return this array out of selectors, so mutate the list
+ originalSourceIds.push(source.id);
+ }
+ }
+
+ return { ...state };
+}
+
+function removeSourcesAndActors(state, action) {
+ const {
+ mutableSourcesPerUrl,
+ mutableSources,
+ mutableOriginalSources,
+ mutableSourceActors,
+ mutableOriginalBreakableLines,
+ mutableBreakpointPositions,
+ } = state;
+
+ const newState = { ...state };
+
+ for (const removedSource of action.sources) {
+ const sourceId = removedSource.id;
+
+ // Clear the urls Map
+ const sourceUrl = removedSource.url;
+ if (sourceUrl) {
+ const sourcesForSameUrl = (
+ mutableSourcesPerUrl.get(sourceUrl) || []
+ ).filter(s => s != removedSource);
+ if (!sourcesForSameUrl.length) {
+ // All sources with this URL have been removed
+ mutableSourcesPerUrl.delete(sourceUrl);
+ } else {
+ // There are other sources still alive with the same URL
+ mutableSourcesPerUrl.set(sourceUrl, sourcesForSameUrl);
+ }
+ }
+
+ mutableSources.delete(sourceId);
+
+ // Note that the caller of this method queried the reducer state
+ // to aggregate the related original sources.
+ // So if we were having related original sources, they will be
+ // in `action.sources`.
+ mutableOriginalSources.delete(sourceId);
+
+ // If a source is removed, immediately remove all its related source actors.
+ // It can speed-up the following for loop cleaning actors.
+ mutableSourceActors.delete(sourceId);
+
+ if (removedSource.isOriginal) {
+ mutableOriginalBreakableLines.delete(sourceId);
+ }
+
+ mutableBreakpointPositions.delete(sourceId);
+
+ if (newState.selectedLocation?.source == removedSource) {
+ newState.selectedLocation = null;
+ newState.selectedOriginalLocation = UNDEFINED_LOCATION;
+ }
+ }
+
+ for (const removedActor of action.actors) {
+ const sourceId = removedActor.source;
+ const actorsForSource = mutableSourceActors.get(sourceId);
+ // actors may have already been cleared by the previous for..loop
+ if (!actorsForSource) {
+ continue;
+ }
+ const idx = actorsForSource.indexOf(removedActor);
+ if (idx != -1) {
+ actorsForSource.splice(idx, 1);
+ // While the Map is mutable, we expect new array instance on each new change
+ mutableSourceActors.set(sourceId, [...actorsForSource]);
+ }
+
+ // Remove the entry in the Map if there is no more actors for that source
+ if (!actorsForSource.length) {
+ mutableSourceActors.delete(sourceId);
+ }
+
+ if (newState.selectedLocation?.sourceActor == removedActor) {
+ newState.selectedLocation = null;
+ newState.selectedOriginalLocation = UNDEFINED_LOCATION;
+ }
+ }
+
+ return newState;
+}
+
+function insertSourceActors(state, action) {
+ const { sourceActors } = action;
+
+ const { mutableSourceActors } = state;
+ // The `sourceActor` objects are defined from `newGeneratedSources` action:
+ // https://searchfox.org/mozilla-central/rev/4646b826a25d3825cf209db890862b45fa09ffc3/devtools/client/debugger/src/actions/sources/newSources.js#300-314
+ for (const sourceActor of sourceActors) {
+ const sourceId = sourceActor.source;
+ // We always clone the array of source actors as we return it from selectors.
+ // So the map is mutable, but its values are considered immutable and will change
+ // anytime there is a new actor added per source ID.
+ const existing = mutableSourceActors.get(sourceId);
+ if (existing) {
+ mutableSourceActors.set(sourceId, [...existing, sourceActor]);
+ } else {
+ mutableSourceActors.set(sourceId, [sourceActor]);
+ }
+ }
+
+ const scriptActors = sourceActors.filter(
+ item => item.introductionType === "scriptElement"
+ );
+ if (scriptActors.length) {
+ // If new HTML sources are being added, we need to clear the breakpoint
+ // positions since the new source is a <script> with new breakpoints.
+ for (const { source } of scriptActors) {
+ state.mutableBreakpointPositions.delete(source);
+ }
+ }
+
+ return { ...state };
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/tabs.js b/devtools/client/debugger/src/reducers/tabs.js
new file mode 100644
index 0000000000..28d1d7b568
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/tabs.js
@@ -0,0 +1,200 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Tabs reducer
+ * @module reducers/tabs
+ */
+
+import { isSimilarTab } from "../utils/tabs";
+
+export function initialTabState() {
+ return { tabs: [] };
+}
+
+function update(state = initialTabState(), action) {
+ switch (action.type) {
+ case "ADD_TAB":
+ return updateTabList(state, action.source, action.sourceActor);
+
+ case "MOVE_TAB":
+ return moveTabInList(state, action);
+
+ case "MOVE_TAB_BY_SOURCE_ID":
+ return moveTabInListBySourceId(state, action);
+
+ case "CLOSE_TABS":
+ return removeSourcesFromTabList(state, action);
+
+ case "ADD_ORIGINAL_SOURCES":
+ return addVisibleTabsForOriginalSources(
+ state,
+ action.originalSources,
+ action.generatedSourceActor
+ );
+
+ case "INSERT_SOURCE_ACTORS":
+ return addVisibleTabsForSourceActors(state, action.sourceActors);
+
+ case "REMOVE_THREAD": {
+ return resetTabsForThread(state, action.threadActorID);
+ }
+
+ default:
+ return state;
+ }
+}
+
+function matchesSource(tab, source) {
+ return tab.source?.id === source.id || matchesUrl(tab, source);
+}
+
+function matchesUrl(tab, source) {
+ return (
+ source.url && tab.url === source.url && tab.isOriginal == source.isOriginal
+ );
+}
+
+function addVisibleTabsForSourceActors(state, sourceActors) {
+ let changed = false;
+ // Lookups for tabs matching any source actor's URL
+ // and reference their source and sourceActor attribute
+ // so that the tab becomes visible.
+ const tabs = state.tabs.map(tab => {
+ const sourceActor = sourceActors.find(actor =>
+ matchesUrl(tab, actor.sourceObject)
+ );
+ if (!sourceActor) {
+ return tab;
+ }
+ changed = true;
+ return {
+ ...tab,
+ source: sourceActor.sourceObject,
+ sourceActor,
+ };
+ });
+
+ return changed ? { tabs } : state;
+}
+
+function addVisibleTabsForOriginalSources(
+ state,
+ sources,
+ generatedSourceActor
+) {
+ let changed = false;
+
+ // Lookups for tabs matching any source's URL
+ // and reference their source and sourceActor attribute
+ // so that the tab becomes visible.
+ const tabs = state.tabs.map(tab => {
+ const source = sources.find(s => matchesUrl(tab, s));
+ if (!source) {
+ return tab;
+ }
+ changed = true;
+ return {
+ ...tab,
+ source,
+ // All currently reported original sources are related to a single source actor
+ sourceActor: generatedSourceActor,
+ };
+ });
+
+ return changed ? { tabs } : state;
+}
+
+function removeSourcesFromTabList(state, { sources }) {
+ const newTabs = sources.reduce(
+ (tabList, source) => tabList.filter(tab => !matchesSource(tab, source)),
+ state.tabs
+ );
+ if (newTabs.length == state.tabs.length) {
+ return state;
+ }
+
+ return { tabs: newTabs };
+}
+
+function resetTabsForThread(state, threadActorID) {
+ let changed = false;
+ // Nullify source and sourceActor attributes of all tabs
+ // related to the given thread so that they become hidden.
+ //
+ // They may later be restored if a source matches their URL again.
+ // This is similar to persistTabs, but specific to a unique thread.
+ const tabs = state.tabs.map(tab => {
+ if (tab.sourceActor?.thread != threadActorID) {
+ return tab;
+ }
+ changed = true;
+ return {
+ ...tab,
+ source: null,
+ sourceActor: null,
+ };
+ });
+
+ return changed ? { tabs } : state;
+}
+
+/**
+ * Adds the new source to the tab list if it is not already there.
+ */
+function updateTabList(state, source, sourceActor) {
+ const { url } = source;
+ const isOriginal = source.isOriginal;
+
+ let { tabs } = state;
+ // Set currentIndex to -1 for URL-less tabs so that they aren't
+ // filtered by isSimilarTab
+ const currentIndex = url
+ ? tabs.findIndex(tab => isSimilarTab(tab, url, isOriginal))
+ : -1;
+
+ if (currentIndex === -1) {
+ const newTab = {
+ url,
+ source,
+ isOriginal,
+ sourceActor,
+ };
+ // New tabs are added first in the list
+ tabs = [newTab, ...tabs];
+ } else {
+ return state;
+ }
+
+ return { ...state, tabs };
+}
+
+function moveTabInList(state, { url, tabIndex: newIndex }) {
+ const currentIndex = state.tabs.findIndex(tab => tab.url == url);
+ return moveTab(state, currentIndex, newIndex);
+}
+
+function moveTabInListBySourceId(state, { sourceId, tabIndex: newIndex }) {
+ const currentIndex = state.tabs.findIndex(tab => tab.source?.id == sourceId);
+ return moveTab(state, currentIndex, newIndex);
+}
+
+function moveTab(state, currentIndex, newIndex) {
+ const { tabs } = state;
+ const item = tabs[currentIndex];
+ // Avoid any state change if we are on the same position or the new is invalid
+ if (currentIndex == newIndex || isNaN(newIndex)) {
+ return state;
+ }
+
+ const newTabs = Array.from(tabs);
+ // Remove the item from its current location
+ newTabs.splice(currentIndex, 1);
+ // And add it to the new one
+ newTabs.splice(newIndex, 0, item);
+
+ return { tabs: newTabs };
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js b/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js
new file mode 100644
index 0000000000..043c59dc5f
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/tests/breakpoints.spec.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 { initialBreakpointsState } from "../breakpoints";
+import { getBreakpointsForSource } from "../../selectors/breakpoints";
+
+import { makeMockBreakpoint, makeMockSource } from "../../utils/test-mockup";
+
+function initializeStateWith(data) {
+ const state = initialBreakpointsState();
+ state.breakpoints = data;
+ return state;
+}
+
+describe("Breakpoints Selectors", () => {
+ it("gets a breakpoint for an original source", () => {
+ const sourceId = "server1.conn1.child1/source1/originalSource";
+ const source = makeMockSource(undefined, sourceId);
+ const matchingBreakpoints = {
+ id1: makeMockBreakpoint(source, 1),
+ };
+
+ const otherBreakpoints = {
+ id2: makeMockBreakpoint(makeMockSource(undefined, "not-this-source"), 1),
+ };
+
+ const data = {
+ ...matchingBreakpoints,
+ ...otherBreakpoints,
+ };
+
+ const breakpoints = initializeStateWith(data);
+ const allBreakpoints = Object.values(matchingBreakpoints);
+ const sourceBreakpoints = getBreakpointsForSource({ breakpoints }, source);
+
+ expect(sourceBreakpoints).toEqual(allBreakpoints);
+ expect(sourceBreakpoints[0] === allBreakpoints[0]).toBe(true);
+ });
+
+ it("gets a breakpoint for a generated source", () => {
+ const generatedSourceId = "random-source";
+ const generatedSource = makeMockSource(undefined, generatedSourceId);
+ const matchingBreakpoints = {
+ id1: {
+ ...makeMockBreakpoint(generatedSource, 1),
+ location: { line: 1, source: { id: "original-source-id-1" } },
+ },
+ };
+
+ const otherBreakpoints = {
+ id2: {
+ ...makeMockBreakpoint(makeMockSource(undefined, "not-this-source"), 1),
+ location: { line: 1, source: { id: "original-source-id-2" } },
+ },
+ };
+
+ const data = {
+ ...matchingBreakpoints,
+ ...otherBreakpoints,
+ };
+
+ const breakpoints = initializeStateWith(data);
+
+ const allBreakpoints = Object.values(matchingBreakpoints);
+ const sourceBreakpoints = getBreakpointsForSource(
+ { breakpoints },
+ generatedSource
+ );
+
+ expect(sourceBreakpoints).toEqual(allBreakpoints);
+ });
+});
diff --git a/devtools/client/debugger/src/reducers/tests/quick-open.spec.js b/devtools/client/debugger/src/reducers/tests/quick-open.spec.js
new file mode 100644
index 0000000000..cd91b040be
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/tests/quick-open.spec.js
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import update, { initialQuickOpenState } from "../quick-open";
+import {
+ getQuickOpenEnabled,
+ getQuickOpenQuery,
+ getQuickOpenType,
+} from "../../selectors/quick-open";
+import {
+ setQuickOpenQuery,
+ openQuickOpen,
+ closeQuickOpen,
+} from "../../actions/quick-open";
+
+describe("quickOpen reducer", () => {
+ test("initial state", () => {
+ const state = update(undefined, { type: "FAKE" });
+ expect(getQuickOpenQuery({ quickOpen: state })).toEqual("");
+ expect(getQuickOpenType({ quickOpen: state })).toEqual("sources");
+ });
+
+ test("opens the quickOpen modal", () => {
+ const state = update(initialQuickOpenState(), openQuickOpen());
+ expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(true);
+ });
+
+ test("closes the quickOpen modal", () => {
+ let state = update(initialQuickOpenState(), openQuickOpen());
+ expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(true);
+ state = update(initialQuickOpenState(), closeQuickOpen());
+ expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(false);
+ });
+
+ test("leaves query alone on open if not provided", () => {
+ const state = update(initialQuickOpenState(), openQuickOpen());
+ expect(getQuickOpenQuery({ quickOpen: state })).toEqual("");
+ expect(getQuickOpenType({ quickOpen: state })).toEqual("sources");
+ });
+
+ test("set query on open if provided", () => {
+ const state = update(initialQuickOpenState(), openQuickOpen("@"));
+ expect(getQuickOpenQuery({ quickOpen: state })).toEqual("@");
+ expect(getQuickOpenType({ quickOpen: state })).toEqual("functions");
+ });
+
+ test("clear query on close", () => {
+ const state = update(initialQuickOpenState(), closeQuickOpen());
+ expect(getQuickOpenQuery({ quickOpen: state })).toEqual("");
+ expect(getQuickOpenType({ quickOpen: state })).toEqual("sources");
+ });
+
+ test("sets the query to the provided string", () => {
+ const state = update(initialQuickOpenState(), setQuickOpenQuery("test"));
+ expect(getQuickOpenQuery({ quickOpen: state })).toEqual("test");
+ expect(getQuickOpenType({ quickOpen: state })).toEqual("sources");
+ });
+});
diff --git a/devtools/client/debugger/src/reducers/tests/ui.spec.js b/devtools/client/debugger/src/reducers/tests/ui.spec.js
new file mode 100644
index 0000000000..0be451429f
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/tests/ui.spec.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 { prefs } from "../../utils/prefs";
+import update, { initialUIState } from "../ui";
+
+describe("ui reducer", () => {
+ it("toggle framework grouping to false", () => {
+ const state = initialUIState();
+ const value = false;
+ const updatedState = update(state, {
+ type: "TOGGLE_FRAMEWORK_GROUPING",
+ value,
+ });
+ expect(updatedState.frameworkGroupingOn).toBe(value);
+ expect(prefs.frameworkGroupingOn).toBe(value);
+ });
+
+ it("toggle framework grouping to true", () => {
+ const state = initialUIState();
+ const value = true;
+ const updatedState = update(state, {
+ type: "TOGGLE_FRAMEWORK_GROUPING",
+ value,
+ });
+ expect(updatedState.frameworkGroupingOn).toBe(value);
+ expect(prefs.frameworkGroupingOn).toBe(value);
+ });
+});
diff --git a/devtools/client/debugger/src/reducers/threads.js b/devtools/client/debugger/src/reducers/threads.js
new file mode 100644
index 0000000000..0131c6c7e8
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/threads.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Threads reducer
+ * @module reducers/threads
+ */
+
+export function initialThreadsState() {
+ return {
+ threads: [],
+
+ // List of thread actor IDs which are current tracing.
+ // i.e. where JavaScript tracing is enabled.
+ mutableTracingThreads: new Set(),
+ };
+}
+
+export default function update(state = initialThreadsState(), action) {
+ switch (action.type) {
+ case "INSERT_THREAD":
+ return {
+ ...state,
+ threads: [...state.threads, action.newThread],
+ };
+
+ case "REMOVE_THREAD":
+ return {
+ ...state,
+ threads: state.threads.filter(
+ thread => action.threadActorID != thread.actor
+ ),
+ };
+
+ case "UPDATE_SERVICE_WORKER_STATUS":
+ return {
+ ...state,
+ threads: state.threads.map(t => {
+ if (t.actor == action.thread) {
+ return { ...t, serviceWorkerStatus: action.status };
+ }
+ return t;
+ }),
+ };
+
+ case "TRACING_TOGGLED":
+ const { mutableTracingThreads } = state;
+ const sizeBefore = mutableTracingThreads.size;
+ if (action.enabled) {
+ mutableTracingThreads.add(action.thread);
+ } else {
+ mutableTracingThreads.delete(action.thread);
+ }
+ // We may receive toggle events when we change the logging method
+ // while we are already tracing, but the list of tracing thread stays the same.
+ const changed = mutableTracingThreads.size != sizeBefore;
+ if (changed) {
+ return {
+ ...state,
+ mutableTracingThreads,
+ };
+ }
+ return state;
+
+ default:
+ return state;
+ }
+}
diff --git a/devtools/client/debugger/src/reducers/ui.js b/devtools/client/debugger/src/reducers/ui.js
new file mode 100644
index 0000000000..7f37d2f54f
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/ui.js
@@ -0,0 +1,253 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/* eslint-disable complexity */
+
+/**
+ * UI reducer
+ * @module reducers/ui
+ */
+
+import { prefs, features } from "../utils/prefs";
+import { searchKeys } from "../constants";
+
+export const initialUIState = () => ({
+ selectedPrimaryPaneTab: "sources",
+ activeSearch: null,
+ startPanelCollapsed: prefs.startPanelCollapsed,
+ endPanelCollapsed: prefs.endPanelCollapsed,
+ frameworkGroupingOn: prefs.frameworkGroupingOn,
+
+ // This is used from Outline's copy to clipboard context menu
+ // and QuickOpen to highlight lines temporarily.
+ // If defined, it will be an object with following attributes:
+ // - sourceId, String
+ // - start, Number, start line to highlight, 1-based
+ // - end, Number, end line to highlight, 1-based
+ highlightedLineRange: null,
+
+ conditionalPanelLocation: null,
+ isLogPoint: false,
+ orientation: "horizontal",
+ viewport: null,
+ cursorPosition: null,
+ inlinePreviewEnabled: features.inlinePreview,
+ editorWrappingEnabled: prefs.editorWrapping,
+ javascriptEnabled: true,
+ javascriptTracingEnabled: false,
+ javascriptTracingLogMethod: prefs.javascriptTracingLogMethod,
+ javascriptTracingValues: prefs.javascriptTracingValues,
+ javascriptTracingOnNextInteraction: prefs.javascriptTracingOnNextInteraction,
+ javascriptTracingOnNextLoad: prefs.javascriptTracingOnNextLoad,
+ javascriptTracingFunctionReturn: prefs.javascriptTracingFunctionReturn,
+ mutableSearchOptions: prefs.searchOptions || {
+ [searchKeys.FILE_SEARCH]: {
+ regexMatch: false,
+ wholeWord: false,
+ caseSensitive: false,
+ excludePatterns: "",
+ },
+ [searchKeys.PROJECT_SEARCH]: {
+ regexMatch: false,
+ wholeWord: false,
+ caseSensitive: false,
+ excludePatterns: "",
+ },
+ [searchKeys.QUICKOPEN_SEARCH]: {
+ regexMatch: false,
+ wholeWord: false,
+ caseSensitive: false,
+ excludePatterns: "",
+ },
+ },
+ projectSearchQuery: "",
+ hideIgnoredSources: prefs.hideIgnoredSources,
+ sourceMapIgnoreListEnabled: prefs.sourceMapIgnoreListEnabled,
+});
+
+function update(state = initialUIState(), action) {
+ switch (action.type) {
+ case "TOGGLE_ACTIVE_SEARCH": {
+ return { ...state, activeSearch: action.value };
+ }
+
+ case "TOGGLE_FRAMEWORK_GROUPING": {
+ prefs.frameworkGroupingOn = action.value;
+ return { ...state, frameworkGroupingOn: action.value };
+ }
+
+ case "TOGGLE_INLINE_PREVIEW": {
+ features.inlinePreview = action.value;
+ return { ...state, inlinePreviewEnabled: action.value };
+ }
+
+ case "TOGGLE_EDITOR_WRAPPING": {
+ prefs.editorWrapping = action.value;
+ return { ...state, editorWrappingEnabled: action.value };
+ }
+
+ case "TOGGLE_JAVASCRIPT_ENABLED": {
+ return { ...state, javascriptEnabled: action.value };
+ }
+
+ case "TOGGLE_SOURCE_MAPS_ENABLED": {
+ prefs.clientSourceMapsEnabled = action.value;
+ return { ...state };
+ }
+
+ case "SET_ORIENTATION": {
+ return { ...state, orientation: action.orientation };
+ }
+
+ case "TOGGLE_PANE": {
+ if (action.position == "start") {
+ prefs.startPanelCollapsed = action.paneCollapsed;
+ return { ...state, startPanelCollapsed: action.paneCollapsed };
+ }
+
+ prefs.endPanelCollapsed = action.paneCollapsed;
+ return { ...state, endPanelCollapsed: action.paneCollapsed };
+ }
+
+ case "HIGHLIGHT_LINES": {
+ return { ...state, highlightedLineRange: action.location };
+ }
+
+ case "CLOSE_QUICK_OPEN":
+ case "CLEAR_HIGHLIGHT_LINES":
+ if (!state.highlightedLineRange) {
+ return state;
+ }
+ return { ...state, highlightedLineRange: null };
+
+ case "OPEN_CONDITIONAL_PANEL":
+ return {
+ ...state,
+ conditionalPanelLocation: action.location,
+ isLogPoint: action.log,
+ };
+
+ case "CLOSE_CONDITIONAL_PANEL":
+ return { ...state, conditionalPanelLocation: null };
+
+ case "SET_PRIMARY_PANE_TAB":
+ return { ...state, selectedPrimaryPaneTab: action.tabName };
+
+ case "CLOSE_PROJECT_SEARCH": {
+ if (state.activeSearch === "project") {
+ return { ...state, activeSearch: null };
+ }
+ return state;
+ }
+
+ case "SET_VIEWPORT": {
+ return { ...state, viewport: action.viewport };
+ }
+
+ case "SET_CURSOR_POSITION": {
+ return { ...state, cursorPosition: action.cursorPosition };
+ }
+
+ case "NAVIGATE": {
+ return { ...state, highlightedLineRange: null };
+ }
+
+ case "REMOVE_THREAD": {
+ // Reset the highlighted range if the related source has been removed
+ const sourceId = state.highlightedLineRange?.sourceId;
+ if (sourceId && action.sources.some(s => s.id == sourceId)) {
+ return { ...state, highlightedLineRange: null };
+ }
+ return state;
+ }
+
+ case "TOGGLE_TRACING": {
+ if (action.status === "start") {
+ return { ...state, javascriptTracingEnabled: action.enabled };
+ }
+ return state;
+ }
+
+ case "SET_JAVASCRIPT_TRACING_LOG_METHOD": {
+ prefs.javascriptTracingLogMethod = action.value;
+ return { ...state, javascriptTracingLogMethod: action.value };
+ }
+
+ case "TOGGLE_JAVASCRIPT_TRACING_VALUES": {
+ prefs.javascriptTracingValues = !prefs.javascriptTracingValues;
+ return {
+ ...state,
+ javascriptTracingValues: prefs.javascriptTracingValues,
+ };
+ }
+
+ case "TOGGLE_JAVASCRIPT_TRACING_ON_NEXT_INTERACTION": {
+ prefs.javascriptTracingOnNextInteraction =
+ !prefs.javascriptTracingOnNextInteraction;
+ return {
+ ...state,
+ javascriptTracingOnNextInteraction:
+ prefs.javascriptTracingOnNextInteraction,
+ };
+ }
+
+ case "TOGGLE_JAVASCRIPT_TRACING_ON_NEXT_LOAD": {
+ prefs.javascriptTracingOnNextLoad = !prefs.javascriptTracingOnNextLoad;
+ return {
+ ...state,
+ javascriptTracingOnNextLoad: prefs.javascriptTracingOnNextLoad,
+ };
+ }
+
+ case "TOGGLE_JAVASCRIPT_TRACING_FUNCTION_RETURN": {
+ prefs.javascriptTracingFunctionReturn =
+ !prefs.javascriptTracingFunctionReturn;
+ return {
+ ...state,
+ javascriptTracingFunctionReturn: prefs.javascriptTracingFunctionReturn,
+ };
+ }
+
+ case "SET_SEARCH_OPTIONS": {
+ state.mutableSearchOptions[action.searchKey] = {
+ ...state.mutableSearchOptions[action.searchKey],
+ ...action.searchOptions,
+ };
+ prefs.searchOptions = state.mutableSearchOptions;
+ return { ...state };
+ }
+
+ case "SET_PROJECT_SEARCH_QUERY": {
+ if (action.query != state.projectSearchQuery) {
+ state.projectSearchQuery = action.query;
+ return { ...state };
+ }
+ return state;
+ }
+
+ case "HIDE_IGNORED_SOURCES": {
+ const { shouldHide } = action;
+ if (shouldHide !== state.hideIgnoredSources) {
+ prefs.hideIgnoredSources = shouldHide;
+ return { ...state, hideIgnoredSources: shouldHide };
+ }
+ return state;
+ }
+
+ case "ENABLE_SOURCEMAP_IGNORELIST": {
+ const { shouldEnable } = action;
+ if (shouldEnable !== state.sourceMapIgnoreListEnabled) {
+ prefs.sourceMapIgnoreListEnabled = shouldEnable;
+ return { ...state, sourceMapIgnoreListEnabled: shouldEnable };
+ }
+ return state;
+ }
+
+ default: {
+ return state;
+ }
+ }
+}
+
+export default update;