diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /devtools/client/debugger/src/reducers | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/reducers')
25 files changed, 2928 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/reducers/ast.js b/devtools/client/debugger/src/reducers/ast.js new file mode 100644 index 0000000000..b1e3e21ecf --- /dev/null +++ b/devtools/client/debugger/src/reducers/ast.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Ast reducer + * @module reducers/ast + */ + +import { makeBreakpointId } from "../utils/breakpoint"; + +export function initialASTState() { + return { + // Internal map of the source id to the specific source actor + // that loads the text for these symbols. + actors: {}, + symbols: {}, + inScopeLines: {}, + }; +} + +function update(state = initialASTState(), action) { + switch (action.type) { + case "SET_SYMBOLS": { + const { sourceId, sourceActorId } = action; + if (action.status === "start") { + return state; + } + + const value = action.value; + return { + ...state, + actors: { ...state.actors, [sourceId]: sourceActorId }, + symbols: { ...state.symbols, [sourceId]: value }, + }; + } + + case "IN_SCOPE_LINES": { + return { + ...state, + inScopeLines: { + ...state.inScopeLines, + [makeBreakpointId(action.location)]: action.lines, + }, + }; + } + + case "RESUME": { + return { ...state, inScopeLines: {} }; + } + + case "NAVIGATE": { + return initialASTState(); + } + + default: { + return state; + } + } +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/async-requests.js b/devtools/client/debugger/src/reducers/async-requests.js new file mode 100644 index 0000000000..b83fe90b2b --- /dev/null +++ b/devtools/client/debugger/src/reducers/async-requests.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Async request reducer + * @module reducers/async-request + */ + +const initialAsyncRequestState = []; + +function update(state = initialAsyncRequestState, action) { + const { seqId } = action; + + if (action.type === "NAVIGATE") { + return initialAsyncRequestState; + } else if (seqId) { + let newState; + if (action.status === "start") { + newState = [...state, seqId]; + } else if (action.status === "error" || action.status === "done") { + newState = state.filter(id => id !== seqId); + } + + return newState; + } + + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/breakpoints.js b/devtools/client/debugger/src/reducers/breakpoints.js new file mode 100644 index 0000000000..46ca083d16 --- /dev/null +++ b/devtools/client/debugger/src/reducers/breakpoints.js @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Breakpoints reducer + * @module reducers/breakpoints + */ + +import { makeBreakpointId } from "../utils/breakpoint"; + +export function initialBreakpointsState(xhrBreakpoints = []) { + return { + breakpoints: {}, + xhrBreakpoints, + breakpointsDisabled: false, + }; +} + +function update(state = initialBreakpointsState(), action) { + switch (action.type) { + case "SET_BREAKPOINT": { + if (action.status === "start") { + return setBreakpoint(state, action); + } + return state; + } + + case "REMOVE_BREAKPOINT": { + if (action.status === "start") { + return removeBreakpoint(state, action); + } + return state; + } + + case "CLEAR_BREAKPOINTS": { + return { ...state, breakpoints: {} }; + } + + case "NAVIGATE": { + return initialBreakpointsState(state.xhrBreakpoints); + } + + case "REMOVE_THREAD": { + return removeBreakpointsForThread(state, action.threadActorID); + } + + case "SET_XHR_BREAKPOINT": { + return addXHRBreakpoint(state, action); + } + + case "REMOVE_XHR_BREAKPOINT": { + return removeXHRBreakpoint(state, action); + } + + case "UPDATE_XHR_BREAKPOINT": { + return updateXHRBreakpoint(state, action); + } + + case "ENABLE_XHR_BREAKPOINT": { + return updateXHRBreakpoint(state, action); + } + + case "DISABLE_XHR_BREAKPOINT": { + return updateXHRBreakpoint(state, action); + } + case "CLEAR_XHR_BREAKPOINTS": { + if (action.status == "start") { + return state; + } + return { ...state, xhrBreakpoints: [] }; + } + } + + return state; +} + +function addXHRBreakpoint(state, action) { + const { xhrBreakpoints } = state; + const { breakpoint } = action; + const { path, method } = breakpoint; + + const existingBreakpointIndex = state.xhrBreakpoints.findIndex( + bp => bp.path === path && bp.method === method + ); + + if (existingBreakpointIndex === -1) { + return { + ...state, + xhrBreakpoints: [...xhrBreakpoints, breakpoint], + }; + } else if (xhrBreakpoints[existingBreakpointIndex] !== breakpoint) { + const newXhrBreakpoints = [...xhrBreakpoints]; + newXhrBreakpoints[existingBreakpointIndex] = breakpoint; + return { + ...state, + xhrBreakpoints: newXhrBreakpoints, + }; + } + + return state; +} + +function removeXHRBreakpoint(state, action) { + const { breakpoint } = action; + const { xhrBreakpoints } = state; + + if (action.status === "start") { + return state; + } + + return { + ...state, + xhrBreakpoints: xhrBreakpoints.filter( + bp => bp.path !== breakpoint.path || bp.method !== breakpoint.method + ), + }; +} + +function updateXHRBreakpoint(state, action) { + const { breakpoint, index } = action; + const { xhrBreakpoints } = state; + const newXhrBreakpoints = [...xhrBreakpoints]; + newXhrBreakpoints[index] = breakpoint; + return { + ...state, + xhrBreakpoints: newXhrBreakpoints, + }; +} + +function setBreakpoint(state, { breakpoint }) { + const id = makeBreakpointId(breakpoint.location); + const breakpoints = { ...state.breakpoints, [id]: breakpoint }; + return { ...state, breakpoints }; +} + +function removeBreakpoint(state, { breakpoint }) { + const id = makeBreakpointId(breakpoint.location); + const breakpoints = { ...state.breakpoints }; + delete breakpoints[id]; + return { ...state, breakpoints }; +} + +function removeBreakpointsForThread(state, threadActorID) { + const remainingBreakpoints = {}; + for (const [id, breakpoint] of Object.entries(state.breakpoints)) { + if (breakpoint.thread !== threadActorID) { + remainingBreakpoints[id] = breakpoint; + } + } + return { ...state, breakpoints: remainingBreakpoints }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/event-listeners.js b/devtools/client/debugger/src/reducers/event-listeners.js new file mode 100644 index 0000000000..d0418b9ece --- /dev/null +++ b/devtools/client/debugger/src/reducers/event-listeners.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { prefs } from "../utils/prefs"; + +export function initialEventListenerState() { + return { + active: [], + categories: [], + expanded: [], + logEventBreakpoints: prefs.logEventBreakpoints, + }; +} + +function update(state = initialEventListenerState(), action) { + switch (action.type) { + case "UPDATE_EVENT_LISTENERS": + return { ...state, active: action.active }; + + case "RECEIVE_EVENT_LISTENER_TYPES": + return { ...state, categories: action.categories }; + + case "UPDATE_EVENT_LISTENER_EXPANDED": + return { ...state, expanded: action.expanded }; + + case "TOGGLE_EVENT_LISTENERS": { + const { logEventBreakpoints } = action; + prefs.logEventBreakpoints = logEventBreakpoints; + return { ...state, logEventBreakpoints }; + } + + default: + return state; + } +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/exceptions.js b/devtools/client/debugger/src/reducers/exceptions.js new file mode 100644 index 0000000000..f4d98115ea --- /dev/null +++ b/devtools/client/debugger/src/reducers/exceptions.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Exceptions reducer + * @module reducers/exceptionss + */ + +export function initialExceptionsState() { + return { + exceptions: {}, + }; +} + +function update(state = initialExceptionsState(), action) { + switch (action.type) { + case "ADD_EXCEPTION": + return updateExceptions(state, action); + } + return state; +} + +function updateExceptions(state, action) { + const { exception } = action; + const sourceActorId = exception.sourceActorId; + + if (state.exceptions[sourceActorId]) { + const sourceExceptions = state.exceptions[sourceActorId]; + return { + ...state, + exceptions: { + ...state.exceptions, + [sourceActorId]: [...sourceExceptions, exception], + }, + }; + } + return { + ...state, + exceptions: { + ...state.exceptions, + [sourceActorId]: [exception], + }, + }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/expressions.js b/devtools/client/debugger/src/reducers/expressions.js new file mode 100644 index 0000000000..d8589eca84 --- /dev/null +++ b/devtools/client/debugger/src/reducers/expressions.js @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Expressions reducer + * @module reducers/expressions + */ + +import { prefs } from "../utils/prefs"; + +export const initialExpressionState = () => ({ + expressions: restoreExpressions(), + expressionError: false, + autocompleteMatches: {}, + currentAutocompleteInput: null, +}); + +function update(state = initialExpressionState(), action) { + switch (action.type) { + case "ADD_EXPRESSION": + if (action.expressionError) { + return { ...state, expressionError: !!action.expressionError }; + } + return appendExpressionToList(state, { + input: action.input, + value: null, + updating: true, + }); + + case "UPDATE_EXPRESSION": + const key = action.expression.input; + const newState = updateExpressionInList(state, key, { + input: action.input, + value: null, + updating: true, + }); + + return { ...newState, expressionError: !!action.expressionError }; + + case "EVALUATE_EXPRESSION": + return updateExpressionInList(state, action.input, { + input: action.input, + value: action.value, + updating: false, + }); + + case "EVALUATE_EXPRESSIONS": + const { inputs, results } = action; + + return inputs.reduce( + (_state, input, index) => + updateExpressionInList(_state, input, { + input, + value: results[index], + updating: false, + }), + state + ); + + case "DELETE_EXPRESSION": + return deleteExpression(state, action.input); + + case "CLEAR_EXPRESSION_ERROR": + return { ...state, expressionError: false }; + + case "AUTOCOMPLETE": + const { matchProp, matches } = action.result; + + return { + ...state, + currentAutocompleteInput: matchProp, + autocompleteMatches: { + ...state.autocompleteMatches, + [matchProp]: matches, + }, + }; + + case "CLEAR_AUTOCOMPLETE": + return { + ...state, + autocompleteMatches: {}, + currentAutocompleteInput: "", + }; + } + + return state; +} + +function restoreExpressions() { + const exprs = prefs.expressions; + if (!exprs.length) { + return []; + } + + return exprs; +} + +function storeExpressions({ expressions }) { + // Return the expressions without the `value` property + prefs.expressions = expressions.map(({ input, updating }) => ({ + input, + updating, + })); +} + +function appendExpressionToList(state, value) { + const newState = { ...state, expressions: [...state.expressions, value] }; + + storeExpressions(newState); + return newState; +} + +function updateExpressionInList(state, key, value) { + const list = [...state.expressions]; + const index = list.findIndex(e => e.input == key); + list[index] = value; + + const newState = { ...state, expressions: list }; + storeExpressions(newState); + return newState; +} + +function deleteExpression(state, input) { + const list = [...state.expressions]; + const index = list.findIndex(e => e.input == input); + list.splice(index, 1); + const newState = { ...state, expressions: list }; + storeExpressions(newState); + return newState; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/file-search.js b/devtools/client/debugger/src/reducers/file-search.js new file mode 100644 index 0000000000..b15351a523 --- /dev/null +++ b/devtools/client/debugger/src/reducers/file-search.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * File Search reducer + * @module reducers/fileSearch + */ + +import { prefs } from "../utils/prefs"; + +const emptySearchResults = Object.freeze({ + matches: Object.freeze([]), + matchIndex: -1, + index: -1, + count: 0, +}); + +export const initialFileSearchState = () => ({ + query: "", + searchResults: emptySearchResults, + modifiers: { + caseSensitive: prefs.fileSearchCaseSensitive, + wholeWord: prefs.fileSearchWholeWord, + regexMatch: prefs.fileSearchRegexMatch, + }, +}); + +function update(state = initialFileSearchState(), action) { + switch (action.type) { + case "UPDATE_FILE_SEARCH_QUERY": { + return { ...state, query: action.query }; + } + + case "UPDATE_SEARCH_RESULTS": { + return { ...state, searchResults: action.results }; + } + + case "TOGGLE_FILE_SEARCH_MODIFIER": { + const actionVal = !state.modifiers[action.modifier]; + + if (action.modifier == "caseSensitive") { + prefs.fileSearchCaseSensitive = actionVal; + } + + if (action.modifier == "wholeWord") { + prefs.fileSearchWholeWord = actionVal; + } + + if (action.modifier == "regexMatch") { + prefs.fileSearchRegexMatch = actionVal; + } + + return { + ...state, + modifiers: { ...state.modifiers, [action.modifier]: actionVal }, + }; + } + + case "NAVIGATE": { + return { ...state, query: "", searchResults: emptySearchResults }; + } + + default: { + return state; + } + } +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/index.js b/devtools/client/debugger/src/reducers/index.js new file mode 100644 index 0000000000..6cf3b60914 --- /dev/null +++ b/devtools/client/debugger/src/reducers/index.js @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Reducer index + * @module reducers/index + */ + +import expressions, { initialExpressionState } from "./expressions"; +import sourceActors from "./source-actors"; +import sources, { initialSourcesState } from "./sources"; +import sourceBlackBox, { initialSourceBlackBoxState } from "./source-blackbox"; +import sourcesContent, { initialSourcesContentState } from "./sources-content"; +import tabs, { initialTabState } from "./tabs"; +import breakpoints, { initialBreakpointsState } from "./breakpoints"; +import pendingBreakpoints from "./pending-breakpoints"; +import asyncRequests from "./async-requests"; +import pause, { initialPauseState } from "./pause"; +import ui, { initialUIState } from "./ui"; +import fileSearch, { initialFileSearchState } from "./file-search"; +import ast, { initialASTState } from "./ast"; +import preview, { initialPreviewState } from "./preview"; +import projectTextSearch, { + initialProjectTextSearchState, +} from "./project-text-search"; +import quickOpen, { initialQuickOpenState } from "./quick-open"; +import sourcesTree, { initialSourcesTreeState } from "./sources-tree"; +import threads, { initialThreadsState } from "./threads"; +import eventListenerBreakpoints, { + initialEventListenerState, +} from "./event-listeners"; +import exceptions, { initialExceptionsState } from "./exceptions"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +/** + * Note that this is only used by jest tests. + * + * Production is using loadInitialState() in main.js + */ +export function initialState() { + return { + sources: initialSourcesState(), + sourcesContent: initialSourcesContentState(), + expressions: initialExpressionState(), + sourceActors: new Map(), + sourceBlackBox: initialSourceBlackBoxState(), + tabs: initialTabState(), + breakpoints: initialBreakpointsState(), + pendingBreakpoints: {}, + asyncRequests: [], + pause: initialPauseState(), + ui: initialUIState(), + fileSearch: initialFileSearchState(), + ast: initialASTState(), + projectTextSearch: initialProjectTextSearchState(), + quickOpen: initialQuickOpenState(), + sourcesTree: initialSourcesTreeState(), + threads: initialThreadsState(), + objectInspector: objectInspector.reducer.initialOIState(), + eventListenerBreakpoints: initialEventListenerState(), + preview: initialPreviewState(), + exceptions: initialExceptionsState(), + }; +} + +export default { + expressions, + sourceActors, + sourceBlackBox, + sourcesContent, + sources, + tabs, + breakpoints, + pendingBreakpoints, + asyncRequests, + pause, + ui, + fileSearch, + ast, + projectTextSearch, + quickOpen, + sourcesTree, + threads, + objectInspector: objectInspector.reducer.default, + eventListenerBreakpoints, + preview, + exceptions, +}; diff --git a/devtools/client/debugger/src/reducers/moz.build b/devtools/client/debugger/src/reducers/moz.build new file mode 100644 index 0000000000..00f3f3e27d --- /dev/null +++ b/devtools/client/debugger/src/reducers/moz.build @@ -0,0 +1,30 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "ast.js", + "async-requests.js", + "breakpoints.js", + "event-listeners.js", + "exceptions.js", + "expressions.js", + "file-search.js", + "index.js", + "pause.js", + "pending-breakpoints.js", + "preview.js", + "project-text-search.js", + "quick-open.js", + "source-actors.js", + "source-blackbox.js", + "sources.js", + "sources-content.js", + "sources-tree.js", + "tabs.js", + "threads.js", + "ui.js", +) diff --git a/devtools/client/debugger/src/reducers/pause.js b/devtools/client/debugger/src/reducers/pause.js new file mode 100644 index 0000000000..4eac87f5f9 --- /dev/null +++ b/devtools/client/debugger/src/reducers/pause.js @@ -0,0 +1,408 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* eslint complexity: ["error", 36]*/ + +/** + * Pause reducer + * @module reducers/pause + */ + +import { prefs } from "../utils/prefs"; + +// Pause state associated with an individual thread. + +// Pause state describing all threads. + +export function initialPauseState(thread = "UnknownThread") { + return { + cx: { + navigateCounter: 0, + }, + // This `threadcx` is the `cx` variable we pass around in components and actions. + // This is pulled via getThreadContext(). + // This stores information about the currently selected thread and its paused state. + threadcx: { + navigateCounter: 0, + thread, + pauseCounter: 0, + }, + highlightedCalls: null, + threads: {}, + skipPausing: prefs.skipPausing, + mapScopes: prefs.mapScopes, + shouldPauseOnExceptions: prefs.pauseOnExceptions, + shouldPauseOnCaughtExceptions: prefs.pauseOnCaughtExceptions, + }; +} + +const resumedPauseState = { + isPaused: false, + frames: null, + framesLoading: false, + frameScopes: { + generated: {}, + original: {}, + mappings: {}, + }, + selectedFrameId: null, + why: null, + inlinePreview: {}, + highlightedCalls: null, +}; + +const createInitialPauseState = () => ({ + ...resumedPauseState, + isWaitingOnBreak: false, + command: null, + previousLocation: null, + expandedScopes: new Set(), + lastExpandedScopes: [], +}); + +export function getThreadPauseState(state, thread) { + // Thread state is lazily initialized so that we don't have to keep track of + // the current set of worker threads. + return state.threads[thread] || createInitialPauseState(); +} + +function update(state = initialPauseState(), action) { + // Actions need to specify any thread they are operating on. These helpers + // manage updating the pause state for that thread. + const threadState = () => { + if (!action.thread) { + throw new Error(`Missing thread in action ${action.type}`); + } + return getThreadPauseState(state, action.thread); + }; + + const updateThreadState = newThreadState => { + if (!action.thread) { + throw new Error(`Missing thread in action ${action.type}`); + } + return { + ...state, + threads: { + ...state.threads, + [action.thread]: { ...threadState(), ...newThreadState }, + }, + }; + }; + + switch (action.type) { + case "SELECT_THREAD": { + return { + ...state, + threadcx: { + ...state.threadcx, + thread: action.thread, + pauseCounter: state.threadcx.pauseCounter + 1, + }, + }; + } + + case "INSERT_THREAD": { + // When navigating to a new location, + // we receive NAVIGATE early, which clear things + // then we have REMOVE_THREAD of the previous thread. + // INSERT_THREAD will be the very first event with the new thread actor ID. + // Automatically select the new top level thread. + if (action.newThread.isTopLevel) { + return { + ...state, + threadcx: { + ...state.threadcx, + thread: action.newThread.actor, + pauseCounter: state.threadcx.pauseCounter + 1, + }, + }; + } + break; + } + + case "REMOVE_THREAD": { + if ( + action.threadActorID in state.threads || + action.threadActorID == state.threadcx.thread + ) { + // Remove the thread from the cached list + const threads = { ...state.threads }; + delete threads[action.threadActorID]; + let threadcx = state.threadcx; + + // And also switch to another thread if this was the currently selected one. + // As we don't store thread objects in this reducer, and only store thread actor IDs, + // we can't try to find the top level thread. So we pick the first available thread, + // and hope that's the top level one. + if (state.threadcx.thread == action.threadActorID) { + threadcx = { + ...threadcx, + thread: Object.keys(threads)[0], + pauseCounter: threadcx.pauseCounter + 1, + }; + } + return { + ...state, + threadcx, + threads, + }; + } + break; + } + + case "PAUSED": { + const { thread, frame, why } = action; + state = { + ...state, + threadcx: { + ...state.threadcx, + pauseCounter: state.threadcx.pauseCounter + 1, + thread, + }, + }; + + return updateThreadState({ + isWaitingOnBreak: false, + selectedFrameId: frame ? frame.id : undefined, + isPaused: true, + frames: frame ? [frame] : undefined, + framesLoading: true, + frameScopes: { ...resumedPauseState.frameScopes }, + why, + shouldBreakpointsPaneOpenOnPause: why.type === "breakpoint", + }); + } + + case "FETCHED_FRAMES": { + const { frames } = action; + + // We typically receive a PAUSED action before this one, + // with only the first frame. Here, we avoid replacing it + // with a copy of it in order to avoid triggerring selectors + // uncessarily + // (note that in jest, action's frames might be empty) + // (and if we resume in between PAUSED and FETCHED_FRAMES + // threadState().frames might be null) + if (threadState().frames) { + const previousFirstFrame = threadState().frames[0]; + if (previousFirstFrame.id == frames[0]?.id) { + frames.splice(0, 1, previousFirstFrame); + } + } + return updateThreadState({ frames, framesLoading: false }); + } + + case "MAP_FRAMES": { + const { selectedFrameId, frames } = action; + return updateThreadState({ frames, selectedFrameId }); + } + + case "MAP_FRAME_DISPLAY_NAMES": { + const { frames } = action; + return updateThreadState({ frames }); + } + + case "ADD_SCOPES": { + const { frame, status, value } = action; + const selectedFrameId = frame.id; + + const generated = { + ...threadState().frameScopes.generated, + [selectedFrameId]: { + pending: status !== "done", + scope: value, + }, + }; + + return updateThreadState({ + frameScopes: { + ...threadState().frameScopes, + generated, + }, + }); + } + + case "MAP_SCOPES": { + const { frame, status, value } = action; + const selectedFrameId = frame.id; + + const original = { + ...threadState().frameScopes.original, + [selectedFrameId]: { + pending: status !== "done", + scope: value?.scope, + }, + }; + + const mappings = { + ...threadState().frameScopes.mappings, + [selectedFrameId]: value?.mappings, + }; + + return updateThreadState({ + frameScopes: { + ...threadState().frameScopes, + original, + mappings, + }, + }); + } + + case "BREAK_ON_NEXT": + return updateThreadState({ isWaitingOnBreak: true }); + + case "SELECT_FRAME": + return updateThreadState({ selectedFrameId: action.frame.id }); + + case "PAUSE_ON_EXCEPTIONS": { + const { shouldPauseOnExceptions, shouldPauseOnCaughtExceptions } = action; + + prefs.pauseOnExceptions = shouldPauseOnExceptions; + prefs.pauseOnCaughtExceptions = shouldPauseOnCaughtExceptions; + + // Preserving for the old debugger + prefs.ignoreCaughtExceptions = !shouldPauseOnCaughtExceptions; + + return { + ...state, + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + }; + } + + case "COMMAND": + if (action.status === "start") { + return updateThreadState({ + ...resumedPauseState, + command: action.command, + previousLocation: getPauseLocation(threadState(), action), + }); + } + return updateThreadState({ command: null }); + + case "RESUME": { + if (action.thread == state.threadcx.thread) { + state = { + ...state, + threadcx: { + ...state.threadcx, + pauseCounter: state.threadcx.pauseCounter + 1, + }, + }; + } + + return updateThreadState({ + ...resumedPauseState, + expandedScopes: new Set(), + lastExpandedScopes: [...threadState().expandedScopes], + shouldBreakpointsPaneOpenOnPause: false, + }); + } + + case "EVALUATE_EXPRESSION": + return updateThreadState({ + command: action.status === "start" ? "expression" : null, + }); + + case "NAVIGATE": { + const navigateCounter = state.cx.navigateCounter + 1; + return { + ...state, + cx: { + navigateCounter, + }, + threadcx: { + navigateCounter, + thread: action.mainThread.actor, + pauseCounter: 0, + }, + threads: { + [action.mainThread.actor]: { + ...getThreadPauseState(state, action.mainThread.actor), + ...resumedPauseState, + }, + }, + }; + } + + case "TOGGLE_SKIP_PAUSING": { + const { skipPausing } = action; + prefs.skipPausing = skipPausing; + + return { ...state, skipPausing }; + } + + case "TOGGLE_MAP_SCOPES": { + const { mapScopes } = action; + prefs.mapScopes = mapScopes; + return { ...state, mapScopes }; + } + + case "SET_EXPANDED_SCOPE": { + const { path, expanded } = action; + const expandedScopes = new Set(threadState().expandedScopes); + if (expanded) { + expandedScopes.add(path); + } else { + expandedScopes.delete(path); + } + return updateThreadState({ expandedScopes }); + } + + case "ADD_INLINE_PREVIEW": { + const { frame, previews } = action; + const selectedFrameId = frame.id; + + return updateThreadState({ + inlinePreview: { + ...threadState().inlinePreview, + [selectedFrameId]: previews, + }, + }); + } + + case "HIGHLIGHT_CALLS": { + const { highlightedCalls } = action; + return updateThreadState({ ...threadState(), highlightedCalls }); + } + + case "UNHIGHLIGHT_CALLS": { + return updateThreadState({ + ...threadState(), + highlightedCalls: null, + }); + } + + case "RESET_BREAKPOINTS_PANE_STATE": { + return updateThreadState({ + ...threadState(), + shouldBreakpointsPaneOpenOnPause: false, + }); + } + } + + return state; +} + +function getPauseLocation(state, action) { + const { frames, previousLocation } = state; + + // NOTE: We store the previous location so that we ensure that we + // do not stop at the same location twice when we step over. + if (action.command !== "stepOver") { + return null; + } + + const frame = frames?.[0]; + if (!frame) { + return previousLocation; + } + + return { + location: frame.location, + generatedLocation: frame.generatedLocation, + }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/pending-breakpoints.js b/devtools/client/debugger/src/reducers/pending-breakpoints.js new file mode 100644 index 0000000000..c07875ddc5 --- /dev/null +++ b/devtools/client/debugger/src/reducers/pending-breakpoints.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Pending breakpoints reducer + * @module reducers/pending-breakpoints + */ + +import { + createPendingBreakpoint, + makePendingLocationId, +} from "../utils/breakpoint"; + +import { isPrettyURL } from "../utils/source"; + +function update(state = {}, action) { + switch (action.type) { + case "SET_BREAKPOINT": + if (action.status === "start") { + return setBreakpoint(state, action); + } + return state; + + case "REMOVE_BREAKPOINT": + if (action.status === "start") { + return removeBreakpoint(state, action); + } + return state; + + case "REMOVE_PENDING_BREAKPOINT": + return removeBreakpoint(state, action); + + case "CLEAR_BREAKPOINTS": { + return {}; + } + } + + return state; +} + +/** + * Return a location id representing a breakpoint's original location, or for + * pretty-printed sources, its generated location. + * @param {{ location: Location, originalLocation?: Location }} breakpoint + */ +function makePendingLocationIdFromBreakpoint(breakpoint) { + const location = + !breakpoint.location.sourceUrl || isPrettyURL(breakpoint.location.sourceUrl) + ? breakpoint.generatedLocation + : breakpoint.location; + return makePendingLocationId(location); +} + +function setBreakpoint(state, { breakpoint }) { + if (breakpoint.options.hidden) { + return state; + } + const locationId = makePendingLocationIdFromBreakpoint(breakpoint); + const pendingBreakpoint = createPendingBreakpoint(breakpoint); + + return { ...state, [locationId]: pendingBreakpoint }; +} + +function removeBreakpoint(state, { breakpoint }) { + const locationId = makePendingLocationIdFromBreakpoint(breakpoint); + state = { ...state }; + + delete state[locationId]; + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/preview.js b/devtools/client/debugger/src/reducers/preview.js new file mode 100644 index 0000000000..d7ee396bf5 --- /dev/null +++ b/devtools/client/debugger/src/reducers/preview.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +export function initialPreviewState() { + return { + preview: null, + previewCount: 0, + }; +} + +function update(state = initialPreviewState(), action) { + switch (action.type) { + case "CLEAR_PREVIEW": { + return { ...state, preview: null }; + } + + case "START_PREVIEW": { + return { ...state, previewCount: state.previewCount + 1 }; + } + + case "SET_PREVIEW": { + return { ...state, preview: action.value }; + } + } + + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/project-text-search.js b/devtools/client/debugger/src/reducers/project-text-search.js new file mode 100644 index 0000000000..66d5b8756d --- /dev/null +++ b/devtools/client/debugger/src/reducers/project-text-search.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @format + +/** + * Project text search reducer + * @module reducers/project-text-search + */ + +export const statusType = { + initial: "INITIAL", + fetching: "FETCHING", + cancelled: "CANCELLED", + done: "DONE", + error: "ERROR", +}; + +export function initialProjectTextSearchState() { + return { + query: "", + results: [], + ongoingSearch: null, + status: statusType.initial, + }; +} + +function update(state = initialProjectTextSearchState(), action) { + switch (action.type) { + case "ADD_QUERY": + return { ...state, query: action.query }; + + case "ADD_SEARCH_RESULT": + if (action.result.matches.length === 0) { + return state; + } + + const result = { + type: "RESULT", + ...action.result, + matches: action.result.matches.map(m => ({ type: "MATCH", ...m })), + }; + return { ...state, results: [...state.results, result] }; + + case "UPDATE_STATUS": + const ongoingSearch = + action.status == statusType.fetching ? state.ongoingSearch : null; + return { ...state, status: action.status, ongoingSearch }; + + case "CLEAR_SEARCH_RESULTS": + return { ...state, results: [] }; + + case "ADD_ONGOING_SEARCH": + return { ...state, ongoingSearch: action.ongoingSearch }; + + case "CLEAR_SEARCH": + case "CLOSE_PROJECT_SEARCH": + case "NAVIGATE": + return initialProjectTextSearchState(); + } + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/quick-open.js b/devtools/client/debugger/src/reducers/quick-open.js new file mode 100644 index 0000000000..459e530e7b --- /dev/null +++ b/devtools/client/debugger/src/reducers/quick-open.js @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Quick Open reducer + * @module reducers/quick-open + */ + +import { parseQuickOpenQuery } from "../utils/quick-open"; + +export const initialQuickOpenState = () => ({ + enabled: false, + query: "", + searchType: "sources", +}); + +export default function update(state = initialQuickOpenState(), action) { + switch (action.type) { + case "OPEN_QUICK_OPEN": + if (action.query != null) { + return { + ...state, + enabled: true, + query: action.query, + searchType: parseQuickOpenQuery(action.query), + }; + } + return { ...state, enabled: true }; + case "CLOSE_QUICK_OPEN": + return initialQuickOpenState(); + case "SET_QUICK_OPEN_QUERY": + return { + ...state, + query: action.query, + searchType: parseQuickOpenQuery(action.query), + }; + default: + return state; + } +} diff --git a/devtools/client/debugger/src/reducers/source-actors.js b/devtools/client/debugger/src/reducers/source-actors.js new file mode 100644 index 0000000000..dde5e7e961 --- /dev/null +++ b/devtools/client/debugger/src/reducers/source-actors.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { asyncActionAsValue } from "../actions/utils/middleware/promise"; + +/** + * This reducer stores the list of all source actors. + * There is a one-one relationship with Source Actors from the server codebase, + * as well as SOURCE Resources distributed by the ResourceCommand API. + * + * See create.js: `createSourceActor` for the shape of the source actor objects. + * This reducer will append the following attributes: + * - breakableLines: { state: <"pending"|"fulfilled">, value: Array<Number> } + * List of all lines where breakpoints can be set + */ +export const initial = new Map(); + +export default function update(state = initial, action) { + switch (action.type) { + case "INSERT_SOURCE_ACTORS": { + const { items } = action; + // The `item` objects are defined from create.js: `createSource` method. + state = new Map(state); + for (const sourceActor of items) { + state.set(sourceActor.id, { + ...sourceActor, + breakableLines: null, + }); + } + break; + } + + case "NAVIGATE": { + state = initial; + break; + } + + case "REMOVE_THREAD": { + state = new Map(state); + for (const sourceActor of state.values()) { + if (sourceActor.thread == action.threadActorID) { + state.delete(sourceActor.id); + } + } + break; + } + + case "SET_SOURCE_ACTOR_BREAKABLE_LINES": + state = updateBreakableLines(state, action); + break; + + case "CLEAR_SOURCE_ACTOR_MAP_URL": + state = clearSourceActorMapURL(state, action.id); + break; + } + + return state; +} + +function clearSourceActorMapURL(state, id) { + if (!state.has(id)) { + return state; + } + + const newMap = new Map(state); + newMap.set(id, { + ...state.get(id), + sourceMapURL: "", + }); + return newMap; +} + +function updateBreakableLines(state, action) { + const value = asyncActionAsValue(action); + const { sourceId } = action; + + if (!state.has(sourceId)) { + return state; + } + + const newMap = new Map(state); + newMap.set(sourceId, { + ...state.get(sourceId), + breakableLines: value, + }); + return newMap; +} diff --git a/devtools/client/debugger/src/reducers/source-blackbox.js b/devtools/client/debugger/src/reducers/source-blackbox.js new file mode 100644 index 0000000000..d3902f3120 --- /dev/null +++ b/devtools/client/debugger/src/reducers/source-blackbox.js @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Reducer containing all data about sources being "black boxed". + * + * i.e. sources which should be ignored by the debugger. + * Typically, these sources should be hidden from paused stack frames, + * and any debugger statement or breakpoint should be ignored. + */ + +export function initialSourceBlackBoxState(state) { + return { + /* FORMAT: + * blackboxedRanges: { + * [source url]: [range, range, ...], -- source lines blackboxed + * [source url]: [], -- whole source blackboxed + * ... + * } + */ + blackboxedRanges: state?.blackboxedRanges ?? {}, + + blackboxedSet: new Set(), + }; +} + +function update(state = initialSourceBlackBoxState(), action) { + switch (action.type) { + case "BLACKBOX": + if (action.status === "done") { + const { blackboxSources } = action.value; + state = updateBlackBoxState(state, blackboxSources); + } + break; + + case "NAVIGATE": + return initialSourceBlackBoxState(state); + } + + return state; +} + +function updateBlackboxRangesForSourceUrl( + currentRanges, + currentSet, + url, + shouldBlackBox, + newRanges +) { + if (shouldBlackBox) { + currentSet.add(url); + // If newRanges is an empty array, it would mean we are blackboxing the whole + // source. To do that lets reset the content to an empty array. + if (!newRanges.length) { + currentRanges[url] = []; + } else { + currentRanges[url] = currentRanges[url] || []; + newRanges.forEach(newRange => { + // To avoid adding duplicate ranges make sure + // no range alredy exists with same start and end lines. + const duplicate = currentRanges[url].findIndex( + r => + r.start.line == newRange.start.line && + r.end.line == newRange.end.line + ); + if (duplicate !== -1) { + return; + } + // ranges are sorted in asc + const index = currentRanges[url].findIndex( + range => + range.end.line <= newRange.start.line && + range.end.column <= newRange.start.column + ); + currentRanges[url].splice(index + 1, 0, newRange); + }); + } + } else { + // if there are no ranges to blackbox, then we are unblackboxing + // the whole source + if (!newRanges.length) { + currentSet.delete(url); + delete currentRanges[url]; + return; + } + // Remove only the lines represented by the ranges provided. + newRanges.forEach(newRange => { + const index = currentRanges[url].findIndex( + range => + range.start.line === newRange.start.line && + range.end.line === newRange.end.line + ); + + if (index !== -1) { + currentRanges[url].splice(index, 1); + } + }); + + // if the last blackboxed line has been removed, unblackbox the source. + if (!currentRanges[url].length) { + currentSet.delete(url); + delete currentRanges[url]; + } + } +} + +/* + * Updates the all the state necessary for blackboxing + * + */ +function updateBlackBoxState(state, blackboxSources) { + const currentRanges = { ...state.blackboxedRanges }; + const currentSet = new Set(state.blackboxedSet); + blackboxSources.map(({ source, shouldBlackBox, ranges }) => + updateBlackboxRangesForSourceUrl( + currentRanges, + currentSet, + source.url, + shouldBlackBox, + ranges + ) + ); + return { + ...state, + blackboxedRanges: currentRanges, + blackboxedSet: currentSet, + }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/sources-content.js b/devtools/client/debugger/src/reducers/sources-content.js new file mode 100644 index 0000000000..f7a83b28ec --- /dev/null +++ b/devtools/client/debugger/src/reducers/sources-content.js @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Sources content reducer. + * + * This store the textual content for each source. + */ + +import { pending, fulfilled, rejected } from "../utils/async-value"; + +export function initialSourcesContentState() { + return { + /** + * Text content of all the original sources. + * This is large data, so this is only fetched on-demand for a subset of sources. + * This state attribute is mutable in order to avoid cloning this possibly large map + * on each new source. But selectors are never based on the map. Instead they only + * query elements of the map. + * + * Map(source id => AsyncValue<String>) + */ + mutableOriginalSourceTextContentMap: new Map(), + + /** + * Text content of all the generated sources. + * + * Map(source actor is => AsyncValue<String>) + */ + mutableGeneratedSourceTextContentMap: new Map(), + + /** + * Incremental number that is bumped each time we navigate to a new page. + * + * This is used to better handle async race condition where we mix previous page data + * with the new page. As sources are keyed by URL we may easily conflate the two page loads data. + */ + epoch: 1, + }; +} + +function update(state = initialSourcesContentState(), action) { + switch (action.type) { + case "LOAD_ORIGINAL_SOURCE_TEXT": + if (!action.sourceId) { + throw new Error("No source id found."); + } + return updateSourceTextContent(state, action); + + case "LOAD_GENERATED_SOURCE_TEXT": + if (!action.sourceActorId) { + throw new Error("No source actor id found."); + } + return updateSourceTextContent(state, action); + + case "NAVIGATE": + return { + ...initialSourcesContentState(), + epoch: state.epoch + 1, + }; + } + + return state; +} + +/* + * Update a source's loaded text content. + */ +function updateSourceTextContent(state, action) { + // If there was a navigation between the time the action was started and + // completed, we don't want to update the store. + if (action.epoch !== state.epoch) { + return state; + } + + let content; + if (action.status === "start") { + content = pending(); + } else if (action.status === "error") { + content = rejected(action.error); + } else if (typeof action.value.text === "string") { + content = fulfilled({ + type: "text", + value: action.value.text, + contentType: action.value.contentType, + }); + } else { + content = fulfilled({ + type: "wasm", + value: action.value.text, + }); + } + + if (action.sourceId && action.sourceActorId) { + throw new Error( + "Both the source id and the source actor should not exist at the same time" + ); + } + + if (action.sourceId) { + state.mutableOriginalSourceTextContentMap.set(action.sourceId, content); + } + + if (action.sourceActorId) { + state.mutableGeneratedSourceTextContentMap.set( + action.sourceActorId, + content + ); + } + + return { + ...state, + }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/sources-tree.js b/devtools/client/debugger/src/reducers/sources-tree.js new file mode 100644 index 0000000000..9c7b5ff72a --- /dev/null +++ b/devtools/client/debugger/src/reducers/sources-tree.js @@ -0,0 +1,530 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Sources tree reducer + * + * A Source Tree is composed of: + * + * - Thread Items To designate targets/threads. + * These are the roots of the Tree if no project directory is selected. + * + * - Group Items To designates the different domains used in the website. + * These are direct children of threads and may contain directory or source items. + * + * - Directory Items To designate all the folders. + * Note that each every folder has an items. The Source Tree React component is doing the magic to coallesce folders made of only one sub folder. + * + * - Source Items To designate sources. + * They are the leaves of the Tree. (we should not have empty directories.) + */ + +const IGNORED_URLS = ["debugger eval code", "XStringBundle"]; +const IGNORED_EXTENSIONS = ["css", "svg", "png"]; +import { isPretty } from "../utils/source"; +import { prefs } from "../utils/prefs"; + +export function initialSourcesTreeState() { + return { + // List of all Thread Tree Items. + // All other item types are children of these and aren't store in + // the reducer as top level objects. + threadItems: [], + + // List of `uniquePath` of Tree Items that are expanded. + // This should be all but Source Tree Items. + expanded: new Set(), + + // `uniquePath` of the currently focused Tree Item. + // It can be any type of Tree Item. + focusedItem: null, + + // Project root set from the Source Tree. + // This focuses the source tree on a subset of sources. + // This is a `uniquePath`, where ${thread} is replaced by "top-level" + // when we picked an item from the main thread. This allows to preserve + // the root selection on page reload. + projectDirectoryRoot: prefs.projectDirectoryRoot, + + // The name is displayed in Source Tree header + projectDirectoryRootName: prefs.projectDirectoryRootName, + + // Reports if the top level target is a web extension. + // If so, we should display all web extension sources. + isWebExtension: false, + + /** + * Boolean, to be set to true in order to display WebExtension's content scripts + * that are applied to the current page we are debugging. + * + * Covered by: browser_dbg-content-script-sources.js + * Bound to: devtools.chrome.enabled + * + */ + chromeAndExtensionsEnabled: prefs.chromeAndExtensionsEnabled, + }; +} + +export default function update(state = initialSourcesTreeState(), action) { + switch (action.type) { + case "ADD_ORIGINAL_SOURCES": { + const { generatedSourceActor } = action; + const validOriginalSources = action.originalSources.filter(source => + isSourceVisibleInSourceTree( + source, + state.chromeAndExtensionsEnabled, + state.isWebExtension + ) + ); + if (!validOriginalSources.length) { + return state; + } + let changed = false; + // Fork the array only once for all the sources + const threadItems = [...state.threadItems]; + for (const source of validOriginalSources) { + changed |= addSource(threadItems, source, generatedSourceActor); + } + if (changed) { + return { + ...state, + threadItems, + }; + } + return state; + } + case "INSERT_SOURCE_ACTORS": { + // With this action, we only cover generated sources. + // (i.e. we need something else for sourcemapped/original sources) + // But we do want to process source actors in order to be able to display + // distinct Source Tree Items for sources with the same URL loaded in distinct thread. + // (And may be also later be able to highlight the many sources with the same URL loaded in a given thread) + const newSourceActors = action.items.filter(sourceActor => + isSourceVisibleInSourceTree( + sourceActor.sourceObject, + state.chromeAndExtensionsEnabled, + state.isWebExtension + ) + ); + if (!newSourceActors.length) { + return state; + } + let changed = false; + // Fork the array only once for all the sources + const threadItems = [...state.threadItems]; + for (const sourceActor of newSourceActors) { + // We mostly wanted to read the thread of the SourceActor, + // most of the interesting attributes are on the Source Object. + changed |= addSource( + threadItems, + sourceActor.sourceObject, + sourceActor + ); + } + if (changed) { + return { + ...state, + threadItems, + }; + } + return state; + } + + case "NAVIGATE": + state = initialSourcesTreeState(); + // WebExtension is being special. A navigation is notified when reloading the add-on, + // but the top level target is not being destroyed/re-created. It stays up. + // So here, we ensure re-registering the thread so that its related sources keep + // being displayed. + if (action.mainThread.isWebExtension) { + addThread(state, action.mainThread); + } + return state; + + case "INSERT_THREAD": + state = { ...state }; + addThread(state, action.newThread); + return state; + + case "REMOVE_THREAD": { + const index = state.threadItems.findIndex(item => { + return item.threadActorID == action.threadActorID; + }); + + if (index == -1) { + return state; + } + const threadItems = [...state.threadItems]; + threadItems.splice(index, 1); + return { + ...state, + threadItems, + }; + } + + case "SET_EXPANDED_STATE": + return updateExpanded(state, action); + + case "SET_FOCUSED_SOURCE_ITEM": + return { ...state, focusedItem: action.item }; + + case "SET_PROJECT_DIRECTORY_ROOT": + const { url, name } = action; + return updateProjectDirectoryRoot(state, url, name); + } + + return state; +} + +function addThread(state, thread) { + const threadActorID = thread.actor; + // When processing the top level target, + // see if we are debugging an extension. + if (thread.isTopLevel) { + state.isWebExtension = thread.isWebExtension; + } + let threadItem = state.threadItems.find(item => { + return item.threadActorID == threadActorID; + }); + if (!threadItem) { + threadItem = createThreadTreeItem(threadActorID); + state.threadItems = [...state.threadItems, threadItem]; + } else { + // We force updating the list to trigger mapStateToProps + // as the getSourcesTreeSources selector is awaiting for the `thread` attribute + // which we will set here. + state.threadItems = [...state.threadItems]; + } + // Inject the reducer thread object on Thread Tree Items + // (this is handy shortcut to have access to from React components) + // (this is also used by sortThreadItems to sort the thread as a Tree in the Browser Toolbox) + threadItem.thread = thread; + state.threadItems.sort(sortThreadItems); +} + +function updateExpanded(state, action) { + // We receive the full list of all expanded items + // (not only the one added/removed) + return { + ...state, + expanded: new Set(action.expanded), + }; +} + +/** + * Update the project directory root + */ +function updateProjectDirectoryRoot(state, root, name) { + // Only persists root within the top level target. + // Otherwise the thread actor ID will change on page reload and we won't match anything + if (!root || root.startsWith("top-level")) { + prefs.projectDirectoryRoot = root; + prefs.projectDirectoryRootName = name; + } + + return { + ...state, + projectDirectoryRoot: root, + projectDirectoryRootName: name, + }; +} + +function isSourceVisibleInSourceTree( + source, + chromeAndExtensionsEnabled, + debuggeeIsWebExtension +) { + return ( + !!source.url && + !IGNORED_EXTENSIONS.includes(source.displayURL.fileExtension) && + !IGNORED_URLS.includes(source.url) && + !isPretty(source) && + // Only accept web extension sources when the chrome pref is enabled (to allows showing content scripts), + // or when we are debugging an extension + (!source.isExtension || + chromeAndExtensionsEnabled || + debuggeeIsWebExtension) + ); +} + +function addSource(threadItems, source, sourceActor) { + // Ensure creating or fetching the related Thread Item + let threadItem = threadItems.find(item => { + return item.threadActorID == sourceActor.thread; + }); + if (!threadItem) { + threadItem = createThreadTreeItem(sourceActor.thread); + // Note that threadItems will be cloned once to force a state update + // by the callsite of `addSourceActor` + threadItems.push(threadItem); + threadItems.sort(sortThreadItems); + } + + // Then ensure creating or fetching the related Group Item + // About `source` versus `sourceActor`: + const { displayURL } = source; + const { group } = displayURL; + + let groupItem = threadItem.children.find(item => { + return item.groupName == group; + }); + + if (!groupItem) { + groupItem = createGroupTreeItem(group, threadItem, source); + // Copy children in order to force updating react in case we picked + // this directory as a project root + threadItem.children = [...threadItem.children, groupItem]; + // As we add a new item, re-sort the groups in this thread + threadItem.children.sort(sortItems); + } + + // Then ensure creating or fetching all possibly nested Directory Item(s) + const { path } = displayURL; + const parentPath = path.substring(0, path.lastIndexOf("/")); + const directoryItem = addOrGetParentDirectory(groupItem, parentPath); + + // Check if a previous source actor registered this source. + // It happens if we load the same url multiple times, or, + // for inline sources (=HTML pages with inline scripts). + const existing = directoryItem.children.find(item => { + return item.type == "source" && item.source == source; + }); + if (existing) { + return false; + } + + // Finaly, create the Source Item and register it in its parent Directory Item + const sourceItem = createSourceTreeItem(source, sourceActor, directoryItem); + // Copy children in order to force updating react in case we picked + // this directory as a project root + directoryItem.children = [...directoryItem.children, sourceItem]; + // Re-sort the items in this directory + directoryItem.children.sort(sortItems); + + return true; +} + +function sortItems(a, b) { + if (a.type == "directory" && b.type == "source") { + return -1; + } else if (b.type == "directory" && a.type == "source") { + return 1; + } else if (a.type == "directory" && b.type == "directory") { + return a.path.localeCompare(b.path); + } else if (a.type == "source" && b.type == "source") { + return a.source.displayURL.filename.localeCompare( + b.source.displayURL.filename + ); + } + return 0; +} + +function sortThreadItems(a, b) { + // Jest tests aren't emitting the necessary actions to populate the thread attributes. + // Ignore sorting for them. + if (!a.thread || !b.thread) { + return 0; + } + + // Top level target is always listed first + if (a.thread.isTopLevel) { + return -1; + } else if (b.thread.isTopLevel) { + return 1; + } + + // Process targets should come next and after that frame targets + if (a.thread.targetType == "process" && b.thread.targetType == "frame") { + return -1; + } else if ( + a.thread.targetType == "frame" && + b.thread.targetType == "process" + ) { + return 1; + } + + // And we display the worker targets last. + if ( + a.thread.targetType.endsWith("worker") && + !b.thread.targetType.endsWith("worker") + ) { + return 1; + } else if ( + !a.thread.targetType.endsWith("worker") && + b.thread.targetType.endsWith("worker") + ) { + return -1; + } + + // Order the process targets by their process ids + if (a.thread.processID > b.thread.processID) { + return 1; + } else if (a.thread.processID < b.thread.processID) { + return 0; + } + + // Order the frame targets and the worker targets by their target name + if (a.thread.targetType == "frame" && b.thread.targetType == "frame") { + return a.thread.name.localeCompare(b.thread.name); + } else if ( + a.thread.targetType.endsWith("worker") && + b.thread.targetType.endsWith("worker") + ) { + return a.thread.name.localeCompare(b.thread.name); + } + + return 0; +} + +/** + * For a given URL's path, in the given group (i.e. typically a given scheme+domain), + * return the already existing parent directory item, or create it if it doesn't exists. + * Note that it will create all ancestors up to the Group Item. + * + * @param {GroupItem} groupItem + * The Group Item for the group where the path should be displayed. + * @param {String} path + * Path of the directory for which we want a Directory Item. + * @return {GroupItem|DirectoryItem} + * The parent Item where this path should be inserted. + * Note that it may be displayed right under the Group Item if the path is empty. + */ +function addOrGetParentDirectory(groupItem, path) { + // We reached the top of the Tree, so return the Group Item. + if (!path) { + return groupItem; + } + // See if we have this directory already registered by a previous source + const existing = groupItem._allGroupDirectoryItems.find(item => { + return item.type == "directory" && item.path == path; + }); + if (existing) { + return existing; + } + // It doesn't exists, so we will create a new Directory Item. + // But now, lookup recursively for the parent Item for this to-be-create Directory Item + const parentPath = path.substring(0, path.lastIndexOf("/")); + const parentDirectory = addOrGetParentDirectory(groupItem, parentPath); + + // We can now create the new Directory Item and register it in its parent Item. + const directory = createDirectoryTreeItem(path, parentDirectory); + // Copy children in order to force updating react in case we picked + // this directory as a project root + parentDirectory.children = [...parentDirectory.children, directory]; + // Re-sort the items in this directory + parentDirectory.children.sort(sortItems); + + // Also maintain the list of all group items, + // Which helps speedup querying for existing items. + groupItem._allGroupDirectoryItems.push(directory); + + return directory; +} + +/** + * Definition of all Items of a SourceTree + */ +// Highlights the attributes that all Source Tree Item should expose +function createBaseTreeItem({ type, parent, uniquePath, children }) { + return { + // Can be: thread, group, directory or source + type, + // Reference to the parent TreeItem + parent, + // This attribute is used for two things: + // * as a string key identified in the React Tree + // * for project root in order to find the root in the tree + // It is of the form: + // `${ThreadActorID}|${GroupName}|${DirectoryPath}|${SourceID}` + // Group and path/ID are optional. + // `|` is used as separator in order to avoid having this character being used in name/path/IDs. + uniquePath, + // Array of TreeItem, children of this item. + // Will be null for Source Tree Item + children, + }; +} +function createThreadTreeItem(thread) { + return { + ...createBaseTreeItem({ + type: "thread", + // Each thread is considered as an independant root item + parent: null, + uniquePath: thread, + // Children of threads will only be Group Items + children: [], + }), + + // This will be used to set the reducer's thread object. + // This threadActorID attribute isn't meant to be used outside of this selector. + // A `thread` attribute will be exposed from INSERT_THREAD action. + threadActorID: thread, + }; +} +function createGroupTreeItem(groupName, parent, source) { + return { + ...createBaseTreeItem({ + type: "group", + parent, + uniquePath: `${parent.uniquePath}|${groupName}`, + // Children of Group can be Directory and Source items + children: [], + }), + + groupName, + + // When a content script appear in a web page, + // a dedicated group is created for it and should + // be having an extension icon. + isForExtensionSource: source.isExtension, + + // List of all nested items for this group. + // This helps find any nested directory in a given group without having to walk the tree. + // This is meant to be used only by the reducer. + _allGroupDirectoryItems: [], + }; +} +function createDirectoryTreeItem(path, parent) { + // If the parent is a group we want to use '/' as separator + const pathSeparator = parent.type == "directory" ? "/" : "|"; + + // `path` will be the absolute path from the group/domain, + // while we want to append only the directory name in uniquePath. + // Also, we need to strip '/' prefix. + const relativePath = + parent.type == "directory" + ? path.replace(parent.path, "").replace(/^\//, "") + : path; + + return { + ...createBaseTreeItem({ + type: "directory", + parent, + uniquePath: `${parent.uniquePath}${pathSeparator}${relativePath}`, + // Children can be nested Directory or Source items + children: [], + }), + + // This is the absolute path from the "group" + // i.e. the path from the domain name + // For http://mozilla.org/foo/bar folder, + // path will be: + // foo/bar + path, + }; +} +function createSourceTreeItem(source, sourceActor, parent) { + return { + ...createBaseTreeItem({ + type: "source", + parent, + uniquePath: `${parent.uniquePath}|${source.id}`, + // Sources items are leaves of the SourceTree + children: null, + }), + + source, + sourceActor, + }; +} diff --git a/devtools/client/debugger/src/reducers/sources.js b/devtools/client/debugger/src/reducers/sources.js new file mode 100644 index 0000000000..9cebb7dc94 --- /dev/null +++ b/devtools/client/debugger/src/reducers/sources.js @@ -0,0 +1,280 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Sources reducer + * @module reducers/sources + */ + +import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/index"; +import { prefs } from "../utils/prefs"; + +export function initialSourcesState(state) { + return { + /** + * All currently available sources. + * + * See create.js: `createSourceObject` method for the description of stored objects. + */ + sources: new Map(), + + /** + * All sources associated with a given URL. When using source maps, multiple + * sources can have the same URL. + * + * Dictionary(url => array<source id>) + */ + urls: {}, + + /** + * Map of the source id's to one or more related original source id's + * Only generated sources which have related original sources will be maintained here. + * + * Dictionary(source id => array<Original Source ID>) + */ + originalSources: {}, + + /** + * Mapping of source id's to one or more source-actor's. + * Dictionary whose keys are source id's and values are arrays + * made of all the related source-actor's. + * Note: The source mapped here are only generated sources. + * + * "source" are the objects stored in this reducer, in the `sources` attribute. + * "source-actor" are the objects stored in the "source-actors.js" reducer, in its `sourceActors` attribute. + * + * Dictionary(source id => array<Object(id: SourceActor Id, thread: Thread Actor ID)>) + */ + actors: {}, + + breakpointPositions: {}, + breakableLines: {}, + + /** + * The actual currently selected location. + * Only set if the related source is already registered in the sources reducer. + * Otherwise, pendingSelectedLocation should be used. Typically for sources + * which are about to be created. + * + * It also includes line and column information. + * + * See `createLocation` for the definition of this object. + */ + selectedLocation: undefined, + + /** + * When we want to select a source that isn't available yet, use this. + * The location object should have a url attribute instead of a sourceId. + */ + pendingSelectedLocation: prefs.pendingSelectedLocation, + }; +} + +function update(state = initialSourcesState(), action) { + let location = null; + + switch (action.type) { + case "ADD_SOURCES": + return addSources(state, action.sources); + + case "ADD_ORIGINAL_SOURCES": + return addSources(state, action.originalSources); + + case "INSERT_SOURCE_ACTORS": + return insertSourceActors(state, action); + + case "SET_SELECTED_LOCATION": + location = { + ...action.location, + url: action.source.url, + }; + + if (action.source.url) { + prefs.pendingSelectedLocation = location; + } + + return { + ...state, + selectedLocation: { + sourceId: action.source.id, + ...action.location, + }, + pendingSelectedLocation: location, + }; + + case "CLEAR_SELECTED_LOCATION": + location = { url: "" }; + prefs.pendingSelectedLocation = location; + + return { + ...state, + selectedLocation: null, + pendingSelectedLocation: location, + }; + + case "SET_PENDING_SELECTED_LOCATION": + location = { + url: action.url, + line: action.line, + column: action.column, + }; + + prefs.pendingSelectedLocation = location; + return { ...state, pendingSelectedLocation: location }; + + case "SET_ORIGINAL_BREAKABLE_LINES": { + const { breakableLines, sourceId } = action; + return { + ...state, + breakableLines: { + ...state.breakableLines, + [sourceId]: breakableLines, + }, + }; + } + + case "ADD_BREAKPOINT_POSITIONS": { + const { source, positions } = action; + const breakpointPositions = state.breakpointPositions[source.id]; + + return { + ...state, + breakpointPositions: { + ...state.breakpointPositions, + [source.id]: { ...breakpointPositions, ...positions }, + }, + }; + } + + case "NAVIGATE": + return initialSourcesState(state); + + case "REMOVE_THREAD": { + return removeSourcesAndActors(state, action.threadActorID); + } + } + + return state; +} + +/* + * Add sources to the sources store + * - Add the source to the sources store + * - Add the source URL to the urls map + */ +function addSources(state, sources) { + state = { + ...state, + urls: { ...state.urls }, + }; + + const newSourceMap = new Map(state.sources); + for (const source of sources) { + newSourceMap.set(source.id, source); + + // Update the source url map + const existing = state.urls[source.url] || []; + if (!existing.includes(source.id)) { + state.urls[source.url] = [...existing, source.id]; + } + + if (source.isOriginal) { + const generatedSourceId = originalToGeneratedId(source.id); + if (!state.originalSources[generatedSourceId]) { + state.originalSources[generatedSourceId] = []; + } + state.originalSources[generatedSourceId].push(source.id); + } + } + state.sources = newSourceMap; + + return state; +} + +function removeSourcesAndActors(state, threadActorID) { + state = { + ...state, + urls: { ...state.urls }, + actors: { ...state.actors }, + originalSources: { ...state.originalSources }, + }; + + const newSourceMap = new Map(state.sources); + + for (const sourceId in state.actors) { + let i = state.actors[sourceId].length; + while (i--) { + // delete the source actors which belong to the + // specified thread. + if (state.actors[sourceId][i].thread == threadActorID) { + state.actors[sourceId].splice(i, 1); + } + } + // Delete the source only if all its actors belong to + // the same thread. + if (!state.actors[sourceId].length) { + delete state.actors[sourceId]; + + const source = newSourceMap.get(sourceId); + if (source.url) { + // urls + if (state.urls[source.url]) { + state.urls[source.url] = state.urls[source.url].filter( + id => id !== source.id + ); + } + if (state.urls[source.url]?.length == 0) { + delete state.urls[source.url]; + } + } + + newSourceMap.delete(sourceId); + + // Also remove any original sources related to this generated source + const originalSourceIds = state.originalSources[sourceId]; + if (originalSourceIds && originalSourceIds.length) { + originalSourceIds.forEach(id => newSourceMap.delete(id)); + delete state.originalSources[sourceId]; + } + } + } + state.sources = newSourceMap; + return state; +} + +function insertSourceActors(state, action) { + const { items } = action; + state = { + ...state, + actors: { ...state.actors }, + }; + + // The `sourceActor` objects are defined from `newGeneratedSources` action: + // https://searchfox.org/mozilla-central/rev/4646b826a25d3825cf209db890862b45fa09ffc3/devtools/client/debugger/src/actions/sources/newSources.js#300-314 + for (const sourceActor of items) { + state.actors[sourceActor.source] = [ + ...(state.actors[sourceActor.source] || []), + { id: sourceActor.id, thread: sourceActor.thread }, + ]; + } + + const scriptActors = items.filter( + item => item.introductionType === "scriptElement" + ); + if (scriptActors.length) { + const { ...breakpointPositions } = state.breakpointPositions; + + // If new HTML sources are being added, we need to clear the breakpoint + // positions since the new source is a <script> with new breakpoints. + for (const { source } of scriptActors) { + delete breakpointPositions[source]; + } + + state = { ...state, breakpointPositions }; + } + + return state; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/tabs.js b/devtools/client/debugger/src/reducers/tabs.js new file mode 100644 index 0000000000..29b6671f84 --- /dev/null +++ b/devtools/client/debugger/src/reducers/tabs.js @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Tabs reducer + * @module reducers/tabs + */ + +import { isOriginalId } from "devtools/client/shared/source-map-loader/index"; + +import { isSimilarTab, persistTabs } from "../utils/tabs"; + +export function initialTabState() { + return { tabs: [] }; +} + +function resetTabState(state) { + const tabs = persistTabs(state.tabs); + return { tabs }; +} + +function update(state = initialTabState(), action) { + switch (action.type) { + case "ADD_TAB": + return updateTabList(state, action.source, action.sourceActor); + + case "MOVE_TAB": + return moveTabInList(state, action); + + case "MOVE_TAB_BY_SOURCE_ID": + return moveTabInListBySourceId(state, action); + + case "CLOSE_TAB": + return removeSourceFromTabList(state, action); + + case "CLOSE_TABS": + return removeSourcesFromTabList(state, action); + + case "INSERT_SOURCE_ACTORS": + return addVisibleTabs(state, action.items); + + case "NAVIGATE": { + return resetTabState(state); + } + + case "REMOVE_THREAD": { + return resetTabsForThread(state, action.threadActorID); + } + + default: + return state; + } +} + +function matchesSource(tab, source) { + return tab.sourceId === source.id || matchesUrl(tab, source); +} + +function matchesUrl(tab, source) { + return tab.url === source.url && tab.isOriginal == isOriginalId(source.id); +} + +function addVisibleTabs(state, sourceActors) { + const tabCount = state.tabs.filter(({ sourceId }) => sourceId).length; + const tabs = state.tabs + .map(tab => { + const sourceActor = sourceActors.find(actor => + matchesUrl(tab, actor.sourceObject) + ); + if (!sourceActor) { + return tab; + } + return { + ...tab, + sourceId: sourceActor.source, + threadActorId: sourceActor.thread, + sourceActorId: sourceActor.actor, + }; + }) + .filter(tab => tab.sourceId); + + if (tabs.length == tabCount) { + return state; + } + + return { tabs }; +} + +function removeSourceFromTabList(state, { source }) { + const newTabs = state.tabs.filter(tab => !matchesSource(tab, source)); + if (newTabs.length == state.tabs.length) { + return state; + } + return { tabs: newTabs }; +} + +function removeSourcesFromTabList(state, { sources }) { + const newTabs = sources.reduce( + (tabList, source) => tabList.filter(tab => !matchesSource(tab, source)), + state.tabs + ); + if (newTabs.length == state.tabs.length) { + return state; + } + + return { tabs: newTabs }; +} + +function resetTabsForThread(state, threadActorID) { + const newTabs = state.tabs.filter(tab => tab.threadActorId !== threadActorID); + if (newTabs.length == state.tabs.length) { + return state; + } + return { tabs: newTabs }; +} + +/** + * Adds the new source to the tab list if it is not already there. + */ +function updateTabList(state, source, sourceActor) { + const { url } = source; + const isOriginal = isOriginalId(source.id); + + let { tabs } = state; + // Set currentIndex to -1 for URL-less tabs so that they aren't + // filtered by isSimilarTab + const currentIndex = url + ? tabs.findIndex(tab => isSimilarTab(tab, url, isOriginal)) + : -1; + + if (currentIndex === -1) { + const newTab = { + url, + sourceId: source.id, + isOriginal, + threadActorId: sourceActor?.thread, + sourceActorId: sourceActor?.actor, + }; + // New tabs are added first in the list + tabs = [newTab, ...tabs]; + } else { + return state; + } + + return { ...state, tabs }; +} + +function moveTabInList(state, { url, tabIndex: newIndex }) { + const { tabs } = state; + const currentIndex = tabs.findIndex(tab => tab.url == url); + return moveTab(tabs, currentIndex, newIndex); +} + +function moveTabInListBySourceId(state, { sourceId, tabIndex: newIndex }) { + const { tabs } = state; + const currentIndex = tabs.findIndex(tab => tab.sourceId == sourceId); + return moveTab(tabs, currentIndex, newIndex); +} + +function moveTab(tabs, currentIndex, newIndex) { + const item = tabs[currentIndex]; + + const newTabs = Array.from(tabs); + // Remove the item from its current location + newTabs.splice(currentIndex, 1); + // And add it to the new one + newTabs.splice(newIndex, 0, item); + + return { tabs: newTabs }; +} + +export default update; diff --git a/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js b/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js new file mode 100644 index 0000000000..f1a2a18e86 --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { initialBreakpointsState } from "../breakpoints"; +import { getBreakpointsForSource } from "../../selectors/breakpoints"; + +import { makeMockBreakpoint, makeMockSource } from "../../utils/test-mockup"; + +function initializeStateWith(data) { + const state = initialBreakpointsState(); + state.breakpoints = data; + return state; +} + +describe("Breakpoints Selectors", () => { + it("it gets a breakpoint for an original source", () => { + const sourceId = "server1.conn1.child1/source1/originalSource"; + const matchingBreakpoints = { + id1: makeMockBreakpoint(makeMockSource(undefined, sourceId), 1), + }; + + const otherBreakpoints = { + id2: makeMockBreakpoint(makeMockSource(undefined, "not-this-source"), 1), + }; + + const data = { + ...matchingBreakpoints, + ...otherBreakpoints, + }; + + const breakpoints = initializeStateWith(data); + const allBreakpoints = Object.values(matchingBreakpoints); + const sourceBreakpoints = getBreakpointsForSource( + { breakpoints }, + sourceId + ); + + expect(sourceBreakpoints).toEqual(allBreakpoints); + expect(sourceBreakpoints[0] === allBreakpoints[0]).toBe(true); + }); + + it("it gets a breakpoint for a generated source", () => { + const generatedSourceId = "random-source"; + const matchingBreakpoints = { + id1: { + ...makeMockBreakpoint(makeMockSource(undefined, generatedSourceId), 1), + location: { line: 1, sourceId: "original-source-id-1" }, + }, + }; + + const otherBreakpoints = { + id2: { + ...makeMockBreakpoint(makeMockSource(undefined, "not-this-source"), 1), + location: { line: 1, sourceId: "original-source-id-2" }, + }, + }; + + const data = { + ...matchingBreakpoints, + ...otherBreakpoints, + }; + + const breakpoints = initializeStateWith(data); + + const allBreakpoints = Object.values(matchingBreakpoints); + const sourceBreakpoints = getBreakpointsForSource( + { breakpoints }, + generatedSourceId + ); + + expect(sourceBreakpoints).toEqual(allBreakpoints); + }); +}); diff --git a/devtools/client/debugger/src/reducers/tests/quick-open.spec.js b/devtools/client/debugger/src/reducers/tests/quick-open.spec.js new file mode 100644 index 0000000000..cd91b040be --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/quick-open.spec.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import update, { initialQuickOpenState } from "../quick-open"; +import { + getQuickOpenEnabled, + getQuickOpenQuery, + getQuickOpenType, +} from "../../selectors/quick-open"; +import { + setQuickOpenQuery, + openQuickOpen, + closeQuickOpen, +} from "../../actions/quick-open"; + +describe("quickOpen reducer", () => { + test("initial state", () => { + const state = update(undefined, { type: "FAKE" }); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual(""); + expect(getQuickOpenType({ quickOpen: state })).toEqual("sources"); + }); + + test("opens the quickOpen modal", () => { + const state = update(initialQuickOpenState(), openQuickOpen()); + expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(true); + }); + + test("closes the quickOpen modal", () => { + let state = update(initialQuickOpenState(), openQuickOpen()); + expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(true); + state = update(initialQuickOpenState(), closeQuickOpen()); + expect(getQuickOpenEnabled({ quickOpen: state })).toEqual(false); + }); + + test("leaves query alone on open if not provided", () => { + const state = update(initialQuickOpenState(), openQuickOpen()); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual(""); + expect(getQuickOpenType({ quickOpen: state })).toEqual("sources"); + }); + + test("set query on open if provided", () => { + const state = update(initialQuickOpenState(), openQuickOpen("@")); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual("@"); + expect(getQuickOpenType({ quickOpen: state })).toEqual("functions"); + }); + + test("clear query on close", () => { + const state = update(initialQuickOpenState(), closeQuickOpen()); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual(""); + expect(getQuickOpenType({ quickOpen: state })).toEqual("sources"); + }); + + test("sets the query to the provided string", () => { + const state = update(initialQuickOpenState(), setQuickOpenQuery("test")); + expect(getQuickOpenQuery({ quickOpen: state })).toEqual("test"); + expect(getQuickOpenType({ quickOpen: state })).toEqual("sources"); + }); +}); diff --git a/devtools/client/debugger/src/reducers/tests/ui.spec.js b/devtools/client/debugger/src/reducers/tests/ui.spec.js new file mode 100644 index 0000000000..0be451429f --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/ui.spec.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { prefs } from "../../utils/prefs"; +import update, { initialUIState } from "../ui"; + +describe("ui reducer", () => { + it("toggle framework grouping to false", () => { + const state = initialUIState(); + const value = false; + const updatedState = update(state, { + type: "TOGGLE_FRAMEWORK_GROUPING", + value, + }); + expect(updatedState.frameworkGroupingOn).toBe(value); + expect(prefs.frameworkGroupingOn).toBe(value); + }); + + it("toggle framework grouping to true", () => { + const state = initialUIState(); + const value = true; + const updatedState = update(state, { + type: "TOGGLE_FRAMEWORK_GROUPING", + value, + }); + expect(updatedState.frameworkGroupingOn).toBe(value); + expect(prefs.frameworkGroupingOn).toBe(value); + }); +}); diff --git a/devtools/client/debugger/src/reducers/threads.js b/devtools/client/debugger/src/reducers/threads.js new file mode 100644 index 0000000000..0373ace150 --- /dev/null +++ b/devtools/client/debugger/src/reducers/threads.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Threads reducer + * @module reducers/threads + */ + +export function initialThreadsState() { + return { + threads: [], + }; +} + +export default function update(state = initialThreadsState(), action) { + switch (action.type) { + case "INSERT_THREAD": + return { + ...state, + threads: [...state.threads, action.newThread], + }; + + case "REMOVE_THREAD": + return { + ...state, + threads: state.threads.filter( + thread => action.threadActorID != thread.actor + ), + }; + case "UPDATE_SERVICE_WORKER_STATUS": + const { thread, status } = action; + return { + ...state, + threads: state.threads.map(t => { + if (t.actor == thread) { + return { ...t, serviceWorkerStatus: status }; + } + return t; + }), + }; + + default: + return state; + } +} diff --git a/devtools/client/debugger/src/reducers/ui.js b/devtools/client/debugger/src/reducers/ui.js new file mode 100644 index 0000000000..5f16ee5d38 --- /dev/null +++ b/devtools/client/debugger/src/reducers/ui.js @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/* eslint complexity: ["error", 35]*/ + +/** + * UI reducer + * @module reducers/ui + */ + +import { prefs, features } from "../utils/prefs"; + +export const initialUIState = () => ({ + selectedPrimaryPaneTab: "sources", + activeSearch: null, + startPanelCollapsed: prefs.startPanelCollapsed, + endPanelCollapsed: prefs.endPanelCollapsed, + frameworkGroupingOn: prefs.frameworkGroupingOn, + highlightedLineRange: undefined, + conditionalPanelLocation: null, + isLogPoint: false, + orientation: "horizontal", + viewport: null, + cursorPosition: null, + inlinePreviewEnabled: features.inlinePreview, + editorWrappingEnabled: prefs.editorWrapping, + javascriptEnabled: true, +}); + +function update(state = initialUIState(), action) { + switch (action.type) { + case "TOGGLE_ACTIVE_SEARCH": { + return { ...state, activeSearch: action.value }; + } + + case "TOGGLE_FRAMEWORK_GROUPING": { + prefs.frameworkGroupingOn = action.value; + return { ...state, frameworkGroupingOn: action.value }; + } + + case "TOGGLE_INLINE_PREVIEW": { + features.inlinePreview = action.value; + return { ...state, inlinePreviewEnabled: action.value }; + } + + case "TOGGLE_EDITOR_WRAPPING": { + prefs.editorWrapping = action.value; + return { ...state, editorWrappingEnabled: action.value }; + } + + case "TOGGLE_JAVASCRIPT_ENABLED": { + return { ...state, javascriptEnabled: action.value }; + } + + case "TOGGLE_SOURCE_MAPS_ENABLED": { + prefs.clientSourceMapsEnabled = action.value; + return { ...state }; + } + + case "SET_ORIENTATION": { + return { ...state, orientation: action.orientation }; + } + + case "TOGGLE_PANE": { + if (action.position == "start") { + prefs.startPanelCollapsed = action.paneCollapsed; + return { ...state, startPanelCollapsed: action.paneCollapsed }; + } + + prefs.endPanelCollapsed = action.paneCollapsed; + return { ...state, endPanelCollapsed: action.paneCollapsed }; + } + + case "HIGHLIGHT_LINES": + const { start, end, sourceId } = action.location; + let lineRange; + + // Lines are one-based so the check below is fine. + if (start && end && sourceId) { + lineRange = { start, end, sourceId }; + } + + return { ...state, highlightedLineRange: lineRange }; + + case "CLOSE_QUICK_OPEN": + case "CLEAR_HIGHLIGHT_LINES": + return { ...state, highlightedLineRange: undefined }; + + case "OPEN_CONDITIONAL_PANEL": + return { + ...state, + conditionalPanelLocation: action.location, + isLogPoint: action.log, + }; + + case "CLOSE_CONDITIONAL_PANEL": + return { ...state, conditionalPanelLocation: null }; + + case "SET_PRIMARY_PANE_TAB": + return { ...state, selectedPrimaryPaneTab: action.tabName }; + + case "CLOSE_PROJECT_SEARCH": { + if (state.activeSearch === "project") { + return { ...state, activeSearch: null }; + } + return state; + } + + case "SET_VIEWPORT": { + return { ...state, viewport: action.viewport }; + } + + case "SET_CURSOR_POSITION": { + return { ...state, cursorPosition: action.cursorPosition }; + } + + case "NAVIGATE": { + return { ...state, activeSearch: null, highlightedLineRange: {} }; + } + + default: { + return state; + } + } +} + +export default update; |