diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/debugger/src/reducers | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/reducers')
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; |