summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/reducers
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /devtools/client/debugger/src/reducers
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
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.js62
-rw-r--r--devtools/client/debugger/src/reducers/async-requests.js31
-rw-r--r--devtools/client/debugger/src/reducers/breakpoints.js154
-rw-r--r--devtools/client/debugger/src/reducers/event-listeners.js38
-rw-r--r--devtools/client/debugger/src/reducers/exceptions.js47
-rw-r--r--devtools/client/debugger/src/reducers/expressions.js133
-rw-r--r--devtools/client/debugger/src/reducers/file-search.js70
-rw-r--r--devtools/client/debugger/src/reducers/index.js90
-rw-r--r--devtools/client/debugger/src/reducers/moz.build30
-rw-r--r--devtools/client/debugger/src/reducers/pause.js408
-rw-r--r--devtools/client/debugger/src/reducers/pending-breakpoints.js73
-rw-r--r--devtools/client/debugger/src/reducers/preview.js30
-rw-r--r--devtools/client/debugger/src/reducers/project-text-search.js65
-rw-r--r--devtools/client/debugger/src/reducers/quick-open.js41
-rw-r--r--devtools/client/debugger/src/reducers/source-actors.js88
-rw-r--r--devtools/client/debugger/src/reducers/source-blackbox.js131
-rw-r--r--devtools/client/debugger/src/reducers/sources-content.js117
-rw-r--r--devtools/client/debugger/src/reducers/sources-tree.js530
-rw-r--r--devtools/client/debugger/src/reducers/sources.js280
-rw-r--r--devtools/client/debugger/src/reducers/tabs.js173
-rw-r--r--devtools/client/debugger/src/reducers/tests/breakpoints.spec.js74
-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.js46
-rw-r--r--devtools/client/debugger/src/reducers/ui.js128
25 files changed, 2928 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..b1e3e21ecf
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/ast.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Ast reducer
+ * @module reducers/ast
+ */
+
+import { makeBreakpointId } from "../utils/breakpoint";
+
+export function initialASTState() {
+ return {
+ // Internal map of the source id to the specific source actor
+ // that loads the text for these symbols.
+ actors: {},
+ symbols: {},
+ inScopeLines: {},
+ };
+}
+
+function update(state = initialASTState(), action) {
+ switch (action.type) {
+ case "SET_SYMBOLS": {
+ const { sourceId, sourceActorId } = action;
+ if (action.status === "start") {
+ return state;
+ }
+
+ const value = action.value;
+ return {
+ ...state,
+ actors: { ...state.actors, [sourceId]: sourceActorId },
+ symbols: { ...state.symbols, [sourceId]: value },
+ };
+ }
+
+ case "IN_SCOPE_LINES": {
+ return {
+ ...state,
+ inScopeLines: {
+ ...state.inScopeLines,
+ [makeBreakpointId(action.location)]: action.lines,
+ },
+ };
+ }
+
+ case "RESUME": {
+ return { ...state, inScopeLines: {} };
+ }
+
+ case "NAVIGATE": {
+ return initialASTState();
+ }
+
+ default: {
+ return state;
+ }
+ }
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/async-requests.js b/devtools/client/debugger/src/reducers/async-requests.js
new file mode 100644
index 0000000000..b83fe90b2b
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/async-requests.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Async request reducer
+ * @module reducers/async-request
+ */
+
+const initialAsyncRequestState = [];
+
+function update(state = initialAsyncRequestState, action) {
+ const { seqId } = action;
+
+ if (action.type === "NAVIGATE") {
+ return initialAsyncRequestState;
+ } else if (seqId) {
+ let newState;
+ if (action.status === "start") {
+ newState = [...state, seqId];
+ } else if (action.status === "error" || action.status === "done") {
+ newState = state.filter(id => id !== seqId);
+ }
+
+ return newState;
+ }
+
+ 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..46ca083d16
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/breakpoints.js
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+export function initialBreakpointsState(xhrBreakpoints = []) {
+ return {
+ breakpoints: {},
+ xhrBreakpoints,
+ breakpointsDisabled: false,
+ };
+}
+
+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 "NAVIGATE": {
+ return initialBreakpointsState(state.xhrBreakpoints);
+ }
+
+ case "REMOVE_THREAD": {
+ return removeBreakpointsForThread(state, action.threadActorID);
+ }
+
+ 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 removeBreakpointsForThread(state, threadActorID) {
+ const remainingBreakpoints = {};
+ for (const [id, breakpoint] of Object.entries(state.breakpoints)) {
+ if (breakpoint.thread !== threadActorID) {
+ 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..f4d98115ea
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/exceptions.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 {
+ exceptions: {},
+ };
+}
+
+function update(state = initialExceptionsState(), action) {
+ switch (action.type) {
+ case "ADD_EXCEPTION":
+ return updateExceptions(state, action);
+ }
+ return state;
+}
+
+function updateExceptions(state, action) {
+ const { exception } = action;
+ const sourceActorId = exception.sourceActorId;
+
+ if (state.exceptions[sourceActorId]) {
+ const sourceExceptions = state.exceptions[sourceActorId];
+ return {
+ ...state,
+ exceptions: {
+ ...state.exceptions,
+ [sourceActorId]: [...sourceExceptions, exception],
+ },
+ };
+ }
+ return {
+ ...state,
+ exceptions: {
+ ...state.exceptions,
+ [sourceActorId]: [exception],
+ },
+ };
+}
+
+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..d8589eca84
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/expressions.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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(),
+ expressionError: false,
+ autocompleteMatches: {},
+ currentAutocompleteInput: null,
+});
+
+function update(state = initialExpressionState(), action) {
+ switch (action.type) {
+ case "ADD_EXPRESSION":
+ if (action.expressionError) {
+ return { ...state, expressionError: !!action.expressionError };
+ }
+ return appendExpressionToList(state, {
+ input: action.input,
+ value: null,
+ updating: true,
+ });
+
+ case "UPDATE_EXPRESSION":
+ const key = action.expression.input;
+ const newState = updateExpressionInList(state, key, {
+ input: action.input,
+ value: null,
+ updating: true,
+ });
+
+ return { ...newState, expressionError: !!action.expressionError };
+
+ 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 "CLEAR_EXPRESSION_ERROR":
+ return { ...state, expressionError: false };
+
+ 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/file-search.js b/devtools/client/debugger/src/reducers/file-search.js
new file mode 100644
index 0000000000..b15351a523
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/file-search.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * File Search reducer
+ * @module reducers/fileSearch
+ */
+
+import { prefs } from "../utils/prefs";
+
+const emptySearchResults = Object.freeze({
+ matches: Object.freeze([]),
+ matchIndex: -1,
+ index: -1,
+ count: 0,
+});
+
+export const initialFileSearchState = () => ({
+ query: "",
+ searchResults: emptySearchResults,
+ modifiers: {
+ caseSensitive: prefs.fileSearchCaseSensitive,
+ wholeWord: prefs.fileSearchWholeWord,
+ regexMatch: prefs.fileSearchRegexMatch,
+ },
+});
+
+function update(state = initialFileSearchState(), action) {
+ switch (action.type) {
+ case "UPDATE_FILE_SEARCH_QUERY": {
+ return { ...state, query: action.query };
+ }
+
+ case "UPDATE_SEARCH_RESULTS": {
+ return { ...state, searchResults: action.results };
+ }
+
+ case "TOGGLE_FILE_SEARCH_MODIFIER": {
+ const actionVal = !state.modifiers[action.modifier];
+
+ if (action.modifier == "caseSensitive") {
+ prefs.fileSearchCaseSensitive = actionVal;
+ }
+
+ if (action.modifier == "wholeWord") {
+ prefs.fileSearchWholeWord = actionVal;
+ }
+
+ if (action.modifier == "regexMatch") {
+ prefs.fileSearchRegexMatch = actionVal;
+ }
+
+ return {
+ ...state,
+ modifiers: { ...state.modifiers, [action.modifier]: actionVal },
+ };
+ }
+
+ case "NAVIGATE": {
+ return { ...state, query: "", searchResults: emptySearchResults };
+ }
+
+ default: {
+ return state;
+ }
+ }
+}
+
+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..6cf3b60914
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/index.js
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Reducer index
+ * @module reducers/index
+ */
+
+import expressions, { initialExpressionState } from "./expressions";
+import sourceActors 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 asyncRequests from "./async-requests";
+import pause, { initialPauseState } from "./pause";
+import ui, { initialUIState } from "./ui";
+import fileSearch, { initialFileSearchState } from "./file-search";
+import ast, { initialASTState } from "./ast";
+import preview, { initialPreviewState } from "./preview";
+import projectTextSearch, {
+ initialProjectTextSearchState,
+} from "./project-text-search";
+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: new Map(),
+ sourceBlackBox: initialSourceBlackBoxState(),
+ tabs: initialTabState(),
+ breakpoints: initialBreakpointsState(),
+ pendingBreakpoints: {},
+ asyncRequests: [],
+ pause: initialPauseState(),
+ ui: initialUIState(),
+ fileSearch: initialFileSearchState(),
+ ast: initialASTState(),
+ projectTextSearch: initialProjectTextSearchState(),
+ quickOpen: initialQuickOpenState(),
+ sourcesTree: initialSourcesTreeState(),
+ threads: initialThreadsState(),
+ objectInspector: objectInspector.reducer.initialOIState(),
+ eventListenerBreakpoints: initialEventListenerState(),
+ preview: initialPreviewState(),
+ exceptions: initialExceptionsState(),
+ };
+}
+
+export default {
+ expressions,
+ sourceActors,
+ sourceBlackBox,
+ sourcesContent,
+ sources,
+ tabs,
+ breakpoints,
+ pendingBreakpoints,
+ asyncRequests,
+ pause,
+ ui,
+ fileSearch,
+ ast,
+ projectTextSearch,
+ quickOpen,
+ sourcesTree,
+ threads,
+ objectInspector: objectInspector.reducer.default,
+ eventListenerBreakpoints,
+ preview,
+ 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..00f3f3e27d
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/moz.build
@@ -0,0 +1,30 @@
+# 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",
+ "async-requests.js",
+ "breakpoints.js",
+ "event-listeners.js",
+ "exceptions.js",
+ "expressions.js",
+ "file-search.js",
+ "index.js",
+ "pause.js",
+ "pending-breakpoints.js",
+ "preview.js",
+ "project-text-search.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..4eac87f5f9
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/pause.js
@@ -0,0 +1,408 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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,
+ },
+ highlightedCalls: null,
+ threads: {},
+ skipPausing: prefs.skipPausing,
+ mapScopes: prefs.mapScopes,
+ shouldPauseOnExceptions: prefs.pauseOnExceptions,
+ shouldPauseOnCaughtExceptions: prefs.pauseOnCaughtExceptions,
+ };
+}
+
+const resumedPauseState = {
+ isPaused: false,
+ frames: null,
+ framesLoading: false,
+ frameScopes: {
+ generated: {},
+ original: {},
+ mappings: {},
+ },
+ selectedFrameId: null,
+ why: null,
+ inlinePreview: {},
+ highlightedCalls: null,
+};
+
+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) {
+ // Actions need to specify any thread they are operating on. These helpers
+ // manage updating the pause state for that thread.
+ const threadState = () => {
+ if (!action.thread) {
+ throw new Error(`Missing thread in action ${action.type}`);
+ }
+ return getThreadPauseState(state, action.thread);
+ };
+
+ const updateThreadState = newThreadState => {
+ if (!action.thread) {
+ throw new Error(`Missing thread in action ${action.type}`);
+ }
+ return {
+ ...state,
+ threads: {
+ ...state.threads,
+ [action.thread]: { ...threadState(), ...newThreadState },
+ },
+ };
+ };
+
+ switch (action.type) {
+ case "SELECT_THREAD": {
+ 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,
+ },
+ };
+ }
+ break;
+ }
+
+ 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, frame, why } = action;
+ state = {
+ ...state,
+ threadcx: {
+ ...state.threadcx,
+ pauseCounter: state.threadcx.pauseCounter + 1,
+ thread,
+ },
+ };
+
+ return updateThreadState({
+ isWaitingOnBreak: false,
+ selectedFrameId: frame ? frame.id : undefined,
+ isPaused: true,
+ frames: frame ? [frame] : undefined,
+ 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 "MAP_FRAME_DISPLAY_NAMES": {
+ const { frames } = action;
+ return updateThreadState({ frames });
+ }
+
+ case "ADD_SCOPES": {
+ const { frame, status, value } = action;
+ const selectedFrameId = frame.id;
+
+ const generated = {
+ ...threadState().frameScopes.generated,
+ [selectedFrameId]: {
+ pending: status !== "done",
+ scope: value,
+ },
+ };
+
+ return updateThreadState({
+ frameScopes: {
+ ...threadState().frameScopes,
+ generated,
+ },
+ });
+ }
+
+ case "MAP_SCOPES": {
+ const { frame, status, value } = action;
+ const selectedFrameId = frame.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_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: {
+ [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 { frame, previews } = action;
+ const selectedFrameId = frame.id;
+
+ return updateThreadState({
+ inlinePreview: {
+ ...threadState().inlinePreview,
+ [selectedFrameId]: previews,
+ },
+ });
+ }
+
+ case "HIGHLIGHT_CALLS": {
+ const { highlightedCalls } = action;
+ return updateThreadState({ ...threadState(), highlightedCalls });
+ }
+
+ case "UNHIGHLIGHT_CALLS": {
+ return updateThreadState({
+ ...threadState(),
+ highlightedCalls: null,
+ });
+ }
+
+ 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..c07875ddc5
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/pending-breakpoints.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/>. */
+
+/**
+ * Pending breakpoints reducer
+ * @module reducers/pending-breakpoints
+ */
+
+import {
+ createPendingBreakpoint,
+ makePendingLocationId,
+} from "../utils/breakpoint";
+
+import { isPrettyURL } from "../utils/source";
+
+function update(state = {}, 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 "REMOVE_PENDING_BREAKPOINT":
+ return removeBreakpoint(state, action);
+
+ case "CLEAR_BREAKPOINTS": {
+ return {};
+ }
+ }
+
+ return state;
+}
+
+/**
+ * Return a location id representing a breakpoint's original location, or for
+ * pretty-printed sources, its generated location.
+ * @param {{ location: Location, originalLocation?: Location }} breakpoint
+ */
+function makePendingLocationIdFromBreakpoint(breakpoint) {
+ const location =
+ !breakpoint.location.sourceUrl || isPrettyURL(breakpoint.location.sourceUrl)
+ ? breakpoint.generatedLocation
+ : breakpoint.location;
+ return makePendingLocationId(location);
+}
+
+function setBreakpoint(state, { breakpoint }) {
+ if (breakpoint.options.hidden) {
+ return state;
+ }
+ const locationId = makePendingLocationIdFromBreakpoint(breakpoint);
+ const pendingBreakpoint = createPendingBreakpoint(breakpoint);
+
+ return { ...state, [locationId]: pendingBreakpoint };
+}
+
+function removeBreakpoint(state, { breakpoint }) {
+ const locationId = makePendingLocationIdFromBreakpoint(breakpoint);
+ state = { ...state };
+
+ delete state[locationId];
+ return state;
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/preview.js b/devtools/client/debugger/src/reducers/preview.js
new file mode 100644
index 0000000000..d7ee396bf5
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/preview.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+export function initialPreviewState() {
+ return {
+ preview: null,
+ previewCount: 0,
+ };
+}
+
+function update(state = initialPreviewState(), action) {
+ switch (action.type) {
+ case "CLEAR_PREVIEW": {
+ return { ...state, preview: null };
+ }
+
+ case "START_PREVIEW": {
+ return { ...state, previewCount: state.previewCount + 1 };
+ }
+
+ case "SET_PREVIEW": {
+ return { ...state, preview: action.value };
+ }
+ }
+
+ return state;
+}
+
+export default update;
diff --git a/devtools/client/debugger/src/reducers/project-text-search.js b/devtools/client/debugger/src/reducers/project-text-search.js
new file mode 100644
index 0000000000..66d5b8756d
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/project-text-search.js
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// @format
+
+/**
+ * Project text search reducer
+ * @module reducers/project-text-search
+ */
+
+export const statusType = {
+ initial: "INITIAL",
+ fetching: "FETCHING",
+ cancelled: "CANCELLED",
+ done: "DONE",
+ error: "ERROR",
+};
+
+export function initialProjectTextSearchState() {
+ return {
+ query: "",
+ results: [],
+ ongoingSearch: null,
+ status: statusType.initial,
+ };
+}
+
+function update(state = initialProjectTextSearchState(), action) {
+ switch (action.type) {
+ case "ADD_QUERY":
+ return { ...state, query: action.query };
+
+ case "ADD_SEARCH_RESULT":
+ if (action.result.matches.length === 0) {
+ return state;
+ }
+
+ const result = {
+ type: "RESULT",
+ ...action.result,
+ matches: action.result.matches.map(m => ({ type: "MATCH", ...m })),
+ };
+ return { ...state, results: [...state.results, result] };
+
+ case "UPDATE_STATUS":
+ const ongoingSearch =
+ action.status == statusType.fetching ? state.ongoingSearch : null;
+ return { ...state, status: action.status, ongoingSearch };
+
+ case "CLEAR_SEARCH_RESULTS":
+ return { ...state, results: [] };
+
+ case "ADD_ONGOING_SEARCH":
+ return { ...state, ongoingSearch: action.ongoingSearch };
+
+ case "CLEAR_SEARCH":
+ case "CLOSE_PROJECT_SEARCH":
+ case "NAVIGATE":
+ return initialProjectTextSearchState();
+ }
+ return state;
+}
+
+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..dde5e7e961
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/source-actors.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { asyncActionAsValue } from "../actions/utils/middleware/promise";
+
+/**
+ * This reducer stores the list of all source actors.
+ * There is a one-one relationship with Source Actors from the server codebase,
+ * as well as SOURCE Resources distributed by the ResourceCommand API.
+ *
+ * See create.js: `createSourceActor` for the shape of the source actor objects.
+ * This reducer will append the following attributes:
+ * - breakableLines: { state: <"pending"|"fulfilled">, value: Array<Number> }
+ * List of all lines where breakpoints can be set
+ */
+export const initial = new Map();
+
+export default function update(state = initial, action) {
+ switch (action.type) {
+ case "INSERT_SOURCE_ACTORS": {
+ const { items } = action;
+ // The `item` objects are defined from create.js: `createSource` method.
+ state = new Map(state);
+ for (const sourceActor of items) {
+ state.set(sourceActor.id, {
+ ...sourceActor,
+ breakableLines: null,
+ });
+ }
+ break;
+ }
+
+ case "NAVIGATE": {
+ state = initial;
+ break;
+ }
+
+ case "REMOVE_THREAD": {
+ state = new Map(state);
+ for (const sourceActor of state.values()) {
+ if (sourceActor.thread == action.threadActorID) {
+ state.delete(sourceActor.id);
+ }
+ }
+ break;
+ }
+
+ case "SET_SOURCE_ACTOR_BREAKABLE_LINES":
+ state = updateBreakableLines(state, action);
+ break;
+
+ case "CLEAR_SOURCE_ACTOR_MAP_URL":
+ state = clearSourceActorMapURL(state, action.id);
+ break;
+ }
+
+ return state;
+}
+
+function clearSourceActorMapURL(state, id) {
+ if (!state.has(id)) {
+ return state;
+ }
+
+ const newMap = new Map(state);
+ newMap.set(id, {
+ ...state.get(id),
+ sourceMapURL: "",
+ });
+ return newMap;
+}
+
+function updateBreakableLines(state, action) {
+ const value = asyncActionAsValue(action);
+ const { sourceId } = action;
+
+ if (!state.has(sourceId)) {
+ return state;
+ }
+
+ const newMap = new Map(state);
+ newMap.set(sourceId, {
+ ...state.get(sourceId),
+ breakableLines: value,
+ });
+ return newMap;
+}
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..d3902f3120
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/source-blackbox.js
@@ -0,0 +1,131 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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: new Set(),
+ };
+}
+
+function update(state = initialSourceBlackBoxState(), action) {
+ switch (action.type) {
+ case "BLACKBOX":
+ if (action.status === "done") {
+ const { blackboxSources } = action.value;
+ state = updateBlackBoxState(state, blackboxSources);
+ }
+ break;
+
+ case "NAVIGATE":
+ return initialSourceBlackBoxState(state);
+ }
+
+ return state;
+}
+
+function updateBlackboxRangesForSourceUrl(
+ currentRanges,
+ currentSet,
+ url,
+ shouldBlackBox,
+ newRanges
+) {
+ if (shouldBlackBox) {
+ currentSet.add(url);
+ // If newRanges is an empty array, it would mean we are blackboxing the whole
+ // source. To do that lets reset the content to an empty array.
+ if (!newRanges.length) {
+ currentRanges[url] = [];
+ } else {
+ currentRanges[url] = currentRanges[url] || [];
+ newRanges.forEach(newRange => {
+ // To avoid adding duplicate ranges make sure
+ // no range alredy exists with same start and end lines.
+ const duplicate = currentRanges[url].findIndex(
+ r =>
+ r.start.line == newRange.start.line &&
+ r.end.line == newRange.end.line
+ );
+ if (duplicate !== -1) {
+ return;
+ }
+ // ranges are sorted in asc
+ const index = currentRanges[url].findIndex(
+ range =>
+ range.end.line <= newRange.start.line &&
+ range.end.column <= newRange.start.column
+ );
+ currentRanges[url].splice(index + 1, 0, newRange);
+ });
+ }
+ } else {
+ // if there are no ranges to blackbox, then we are unblackboxing
+ // the whole source
+ if (!newRanges.length) {
+ currentSet.delete(url);
+ delete currentRanges[url];
+ return;
+ }
+ // Remove only the lines represented by the ranges provided.
+ newRanges.forEach(newRange => {
+ const index = currentRanges[url].findIndex(
+ range =>
+ range.start.line === newRange.start.line &&
+ range.end.line === newRange.end.line
+ );
+
+ if (index !== -1) {
+ currentRanges[url].splice(index, 1);
+ }
+ });
+
+ // if the last blackboxed line has been removed, unblackbox the source.
+ if (!currentRanges[url].length) {
+ currentSet.delete(url);
+ delete currentRanges[url];
+ }
+ }
+}
+
+/*
+ * Updates the all the state necessary for blackboxing
+ *
+ */
+function updateBlackBoxState(state, blackboxSources) {
+ const currentRanges = { ...state.blackboxedRanges };
+ const currentSet = new Set(state.blackboxedSet);
+ blackboxSources.map(({ source, shouldBlackBox, ranges }) =>
+ updateBlackboxRangesForSourceUrl(
+ currentRanges,
+ currentSet,
+ source.url,
+ shouldBlackBox,
+ ranges
+ )
+ );
+ return {
+ ...state,
+ blackboxedRanges: currentRanges,
+ blackboxedSet: currentSet,
+ };
+}
+
+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..f7a83b28ec
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/sources-content.js
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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>)
+ */
+ mutableOriginalSourceTextContentMap: new Map(),
+
+ /**
+ * Text content of all the generated sources.
+ *
+ * Map(source actor is => AsyncValue<String>)
+ */
+ mutableGeneratedSourceTextContentMap: 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.sourceId) {
+ throw new Error("No source id found.");
+ }
+ return updateSourceTextContent(state, action);
+
+ case "LOAD_GENERATED_SOURCE_TEXT":
+ if (!action.sourceActorId) {
+ throw new Error("No source actor id found.");
+ }
+ return updateSourceTextContent(state, action);
+
+ case "NAVIGATE":
+ return {
+ ...initialSourcesContentState(),
+ epoch: state.epoch + 1,
+ };
+ }
+
+ 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.sourceId && action.sourceActorId) {
+ throw new Error(
+ "Both the source id and the source actor should not exist at the same time"
+ );
+ }
+
+ if (action.sourceId) {
+ state.mutableOriginalSourceTextContentMap.set(action.sourceId, content);
+ }
+
+ if (action.sourceActorId) {
+ state.mutableGeneratedSourceTextContentMap.set(
+ action.sourceActorId,
+ content
+ );
+ }
+
+ 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..9c7b5ff72a
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/sources-tree.js
@@ -0,0 +1,530 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 } 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(),
+
+ // `uniquePath` of 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,
+ };
+}
+
+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.items.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 "NAVIGATE":
+ state = initialSourcesTreeState();
+ // WebExtension is being special. A navigation is notified when reloading the add-on,
+ // but the top level target is not being destroyed/re-created. It stays up.
+ // So here, we ensure re-registering the thread so that its related sources keep
+ // being displayed.
+ if (action.mainThread.isWebExtension) {
+ addThread(state, action.mainThread);
+ }
+ return state;
+
+ case "INSERT_THREAD":
+ state = { ...state };
+ addThread(state, action.newThread);
+ return state;
+
+ case "REMOVE_THREAD": {
+ const index = state.threadItems.findIndex(item => {
+ return item.threadActorID == action.threadActorID;
+ });
+
+ if (index == -1) {
+ return state;
+ }
+ const threadItems = [...state.threadItems];
+ threadItems.splice(index, 1);
+ return {
+ ...state,
+ threadItems,
+ };
+ }
+
+ case "SET_EXPANDED_STATE":
+ return updateExpanded(state, action);
+
+ case "SET_FOCUSED_SOURCE_ITEM":
+ return { ...state, focusedItem: action.item };
+
+ case "SET_PROJECT_DIRECTORY_ROOT":
+ const { url, name } = action;
+ return updateProjectDirectoryRoot(state, url, name);
+ }
+
+ 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];
+ } 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;
+ state.threadItems.sort(sortThreadItems);
+}
+
+function updateExpanded(state, action) {
+ // We receive the full list of all expanded items
+ // (not only the one added/removed)
+ return {
+ ...state,
+ expanded: new Set(action.expanded),
+ };
+}
+
+/**
+ * Update the project directory root
+ */
+function updateProjectDirectoryRoot(state, root, 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 (!root || root.startsWith("top-level")) {
+ prefs.projectDirectoryRoot = root;
+ prefs.projectDirectoryRootName = name;
+ }
+
+ return {
+ ...state,
+ projectDirectoryRoot: root,
+ 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)
+ );
+}
+
+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`
+ threadItems.push(threadItem);
+ threadItems.sort(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, groupItem];
+ // As we add a new item, re-sort the groups in this thread
+ threadItem.children.sort(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, sourceItem];
+ // Re-sort the items in this directory
+ directoryItem.children.sort(sortItems);
+
+ return true;
+}
+
+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, directory];
+ // Re-sort the items in this directory
+ parentDirectory.children.sort(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,
+ };
+}
diff --git a/devtools/client/debugger/src/reducers/sources.js b/devtools/client/debugger/src/reducers/sources.js
new file mode 100644
index 0000000000..9cebb7dc94
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/sources.js
@@ -0,0 +1,280 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+export function initialSourcesState(state) {
+ return {
+ /**
+ * All currently available sources.
+ *
+ * See create.js: `createSourceObject` method for the description of stored objects.
+ */
+ sources: new Map(),
+
+ /**
+ * All sources associated with a given URL. When using source maps, multiple
+ * sources can have the same URL.
+ *
+ * Dictionary(url => array<source id>)
+ */
+ urls: {},
+
+ /**
+ * 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.
+ *
+ * Dictionary(source id => array<Original Source ID>)
+ */
+ originalSources: {},
+
+ /**
+ * 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.
+ *
+ * Dictionary(source id => array<Object(id: SourceActor Id, thread: Thread Actor ID)>)
+ */
+ actors: {},
+
+ breakpointPositions: {},
+ breakableLines: {},
+
+ /**
+ * 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 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.
+ */
+ pendingSelectedLocation: prefs.pendingSelectedLocation,
+ };
+}
+
+function update(state = initialSourcesState(), action) {
+ let location = null;
+
+ 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":
+ location = {
+ ...action.location,
+ url: action.source.url,
+ };
+
+ if (action.source.url) {
+ prefs.pendingSelectedLocation = location;
+ }
+
+ return {
+ ...state,
+ selectedLocation: {
+ sourceId: action.source.id,
+ ...action.location,
+ },
+ pendingSelectedLocation: location,
+ };
+
+ case "CLEAR_SELECTED_LOCATION":
+ location = { url: "" };
+ prefs.pendingSelectedLocation = location;
+
+ return {
+ ...state,
+ selectedLocation: null,
+ pendingSelectedLocation: location,
+ };
+
+ case "SET_PENDING_SELECTED_LOCATION":
+ location = {
+ url: action.url,
+ line: action.line,
+ column: action.column,
+ };
+
+ prefs.pendingSelectedLocation = location;
+ return { ...state, pendingSelectedLocation: location };
+
+ case "SET_ORIGINAL_BREAKABLE_LINES": {
+ const { breakableLines, sourceId } = action;
+ return {
+ ...state,
+ breakableLines: {
+ ...state.breakableLines,
+ [sourceId]: breakableLines,
+ },
+ };
+ }
+
+ case "ADD_BREAKPOINT_POSITIONS": {
+ const { source, positions } = action;
+ const breakpointPositions = state.breakpointPositions[source.id];
+
+ return {
+ ...state,
+ breakpointPositions: {
+ ...state.breakpointPositions,
+ [source.id]: { ...breakpointPositions, ...positions },
+ },
+ };
+ }
+
+ case "NAVIGATE":
+ return initialSourcesState(state);
+
+ case "REMOVE_THREAD": {
+ return removeSourcesAndActors(state, action.threadActorID);
+ }
+ }
+
+ return state;
+}
+
+/*
+ * Add sources to the sources store
+ * - Add the source to the sources store
+ * - Add the source URL to the urls map
+ */
+function addSources(state, sources) {
+ state = {
+ ...state,
+ urls: { ...state.urls },
+ };
+
+ const newSourceMap = new Map(state.sources);
+ for (const source of sources) {
+ newSourceMap.set(source.id, source);
+
+ // Update the source url map
+ const existing = state.urls[source.url] || [];
+ if (!existing.includes(source.id)) {
+ state.urls[source.url] = [...existing, source.id];
+ }
+
+ if (source.isOriginal) {
+ const generatedSourceId = originalToGeneratedId(source.id);
+ if (!state.originalSources[generatedSourceId]) {
+ state.originalSources[generatedSourceId] = [];
+ }
+ state.originalSources[generatedSourceId].push(source.id);
+ }
+ }
+ state.sources = newSourceMap;
+
+ return state;
+}
+
+function removeSourcesAndActors(state, threadActorID) {
+ state = {
+ ...state,
+ urls: { ...state.urls },
+ actors: { ...state.actors },
+ originalSources: { ...state.originalSources },
+ };
+
+ const newSourceMap = new Map(state.sources);
+
+ for (const sourceId in state.actors) {
+ let i = state.actors[sourceId].length;
+ while (i--) {
+ // delete the source actors which belong to the
+ // specified thread.
+ if (state.actors[sourceId][i].thread == threadActorID) {
+ state.actors[sourceId].splice(i, 1);
+ }
+ }
+ // Delete the source only if all its actors belong to
+ // the same thread.
+ if (!state.actors[sourceId].length) {
+ delete state.actors[sourceId];
+
+ const source = newSourceMap.get(sourceId);
+ if (source.url) {
+ // urls
+ if (state.urls[source.url]) {
+ state.urls[source.url] = state.urls[source.url].filter(
+ id => id !== source.id
+ );
+ }
+ if (state.urls[source.url]?.length == 0) {
+ delete state.urls[source.url];
+ }
+ }
+
+ newSourceMap.delete(sourceId);
+
+ // Also remove any original sources related to this generated source
+ const originalSourceIds = state.originalSources[sourceId];
+ if (originalSourceIds && originalSourceIds.length) {
+ originalSourceIds.forEach(id => newSourceMap.delete(id));
+ delete state.originalSources[sourceId];
+ }
+ }
+ }
+ state.sources = newSourceMap;
+ return state;
+}
+
+function insertSourceActors(state, action) {
+ const { items } = action;
+ state = {
+ ...state,
+ actors: { ...state.actors },
+ };
+
+ // 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 items) {
+ state.actors[sourceActor.source] = [
+ ...(state.actors[sourceActor.source] || []),
+ { id: sourceActor.id, thread: sourceActor.thread },
+ ];
+ }
+
+ const scriptActors = items.filter(
+ item => item.introductionType === "scriptElement"
+ );
+ if (scriptActors.length) {
+ const { ...breakpointPositions } = state.breakpointPositions;
+
+ // 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) {
+ delete breakpointPositions[source];
+ }
+
+ state = { ...state, breakpointPositions };
+ }
+
+ 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..29b6671f84
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/tabs.js
@@ -0,0 +1,173 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Tabs reducer
+ * @module reducers/tabs
+ */
+
+import { isOriginalId } from "devtools/client/shared/source-map-loader/index";
+
+import { isSimilarTab, persistTabs } from "../utils/tabs";
+
+export function initialTabState() {
+ return { tabs: [] };
+}
+
+function resetTabState(state) {
+ const tabs = persistTabs(state.tabs);
+ 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_TAB":
+ return removeSourceFromTabList(state, action);
+
+ case "CLOSE_TABS":
+ return removeSourcesFromTabList(state, action);
+
+ case "INSERT_SOURCE_ACTORS":
+ return addVisibleTabs(state, action.items);
+
+ case "NAVIGATE": {
+ return resetTabState(state);
+ }
+
+ case "REMOVE_THREAD": {
+ return resetTabsForThread(state, action.threadActorID);
+ }
+
+ default:
+ return state;
+ }
+}
+
+function matchesSource(tab, source) {
+ return tab.sourceId === source.id || matchesUrl(tab, source);
+}
+
+function matchesUrl(tab, source) {
+ return tab.url === source.url && tab.isOriginal == isOriginalId(source.id);
+}
+
+function addVisibleTabs(state, sourceActors) {
+ const tabCount = state.tabs.filter(({ sourceId }) => sourceId).length;
+ const tabs = state.tabs
+ .map(tab => {
+ const sourceActor = sourceActors.find(actor =>
+ matchesUrl(tab, actor.sourceObject)
+ );
+ if (!sourceActor) {
+ return tab;
+ }
+ return {
+ ...tab,
+ sourceId: sourceActor.source,
+ threadActorId: sourceActor.thread,
+ sourceActorId: sourceActor.actor,
+ };
+ })
+ .filter(tab => tab.sourceId);
+
+ if (tabs.length == tabCount) {
+ return state;
+ }
+
+ return { tabs };
+}
+
+function removeSourceFromTabList(state, { source }) {
+ const newTabs = state.tabs.filter(tab => !matchesSource(tab, source));
+ if (newTabs.length == state.tabs.length) {
+ return state;
+ }
+ return { tabs: newTabs };
+}
+
+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) {
+ const newTabs = state.tabs.filter(tab => tab.threadActorId !== threadActorID);
+ if (newTabs.length == state.tabs.length) {
+ return state;
+ }
+ return { tabs: newTabs };
+}
+
+/**
+ * Adds the new source to the tab list if it is not already there.
+ */
+function updateTabList(state, source, sourceActor) {
+ const { url } = source;
+ const isOriginal = isOriginalId(source.id);
+
+ 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,
+ sourceId: source.id,
+ isOriginal,
+ threadActorId: sourceActor?.thread,
+ sourceActorId: sourceActor?.actor,
+ };
+ // New tabs are added first in the list
+ tabs = [newTab, ...tabs];
+ } else {
+ return state;
+ }
+
+ return { ...state, tabs };
+}
+
+function moveTabInList(state, { url, tabIndex: newIndex }) {
+ const { tabs } = state;
+ const currentIndex = tabs.findIndex(tab => tab.url == url);
+ return moveTab(tabs, currentIndex, newIndex);
+}
+
+function moveTabInListBySourceId(state, { sourceId, tabIndex: newIndex }) {
+ const { tabs } = state;
+ const currentIndex = tabs.findIndex(tab => tab.sourceId == sourceId);
+ return moveTab(tabs, currentIndex, newIndex);
+}
+
+function moveTab(tabs, currentIndex, newIndex) {
+ const item = tabs[currentIndex];
+
+ 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..f1a2a18e86
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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("it gets a breakpoint for an original source", () => {
+ const sourceId = "server1.conn1.child1/source1/originalSource";
+ const matchingBreakpoints = {
+ id1: makeMockBreakpoint(makeMockSource(undefined, sourceId), 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 },
+ sourceId
+ );
+
+ expect(sourceBreakpoints).toEqual(allBreakpoints);
+ expect(sourceBreakpoints[0] === allBreakpoints[0]).toBe(true);
+ });
+
+ it("it gets a breakpoint for a generated source", () => {
+ const generatedSourceId = "random-source";
+ const matchingBreakpoints = {
+ id1: {
+ ...makeMockBreakpoint(makeMockSource(undefined, generatedSourceId), 1),
+ location: { line: 1, sourceId: "original-source-id-1" },
+ },
+ };
+
+ const otherBreakpoints = {
+ id2: {
+ ...makeMockBreakpoint(makeMockSource(undefined, "not-this-source"), 1),
+ location: { line: 1, sourceId: "original-source-id-2" },
+ },
+ };
+
+ const data = {
+ ...matchingBreakpoints,
+ ...otherBreakpoints,
+ };
+
+ const breakpoints = initializeStateWith(data);
+
+ const allBreakpoints = Object.values(matchingBreakpoints);
+ const sourceBreakpoints = getBreakpointsForSource(
+ { breakpoints },
+ generatedSourceId
+ );
+
+ 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..0373ace150
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/threads.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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: [],
+ };
+}
+
+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":
+ const { thread, status } = action;
+ return {
+ ...state,
+ threads: state.threads.map(t => {
+ if (t.actor == thread) {
+ return { ...t, serviceWorkerStatus: status };
+ }
+ return t;
+ }),
+ };
+
+ 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..5f16ee5d38
--- /dev/null
+++ b/devtools/client/debugger/src/reducers/ui.js
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/* eslint complexity: ["error", 35]*/
+
+/**
+ * UI reducer
+ * @module reducers/ui
+ */
+
+import { prefs, features } from "../utils/prefs";
+
+export const initialUIState = () => ({
+ selectedPrimaryPaneTab: "sources",
+ activeSearch: null,
+ startPanelCollapsed: prefs.startPanelCollapsed,
+ endPanelCollapsed: prefs.endPanelCollapsed,
+ frameworkGroupingOn: prefs.frameworkGroupingOn,
+ highlightedLineRange: undefined,
+ conditionalPanelLocation: null,
+ isLogPoint: false,
+ orientation: "horizontal",
+ viewport: null,
+ cursorPosition: null,
+ inlinePreviewEnabled: features.inlinePreview,
+ editorWrappingEnabled: prefs.editorWrapping,
+ javascriptEnabled: true,
+});
+
+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":
+ const { start, end, sourceId } = action.location;
+ let lineRange;
+
+ // Lines are one-based so the check below is fine.
+ if (start && end && sourceId) {
+ lineRange = { start, end, sourceId };
+ }
+
+ return { ...state, highlightedLineRange: lineRange };
+
+ case "CLOSE_QUICK_OPEN":
+ case "CLEAR_HIGHLIGHT_LINES":
+ return { ...state, highlightedLineRange: undefined };
+
+ 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, activeSearch: null, highlightedLineRange: {} };
+ }
+
+ default: {
+ return state;
+ }
+ }
+}
+
+export default update;