diff options
Diffstat (limited to 'devtools/client/debugger/src/reducers')
25 files changed, 4645 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..2ab6871e5f --- /dev/null +++ b/devtools/client/debugger/src/reducers/ast.js @@ -0,0 +1,136 @@ +/* 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/>. */ + +// @flow + +/** + * Ast reducer + * @module reducers/ast + */ + +import { makeBreakpointId } from "../utils/breakpoint"; + +import type { SymbolDeclarations } from "../workers/parser"; + +import type { Source, SourceLocation } from "../types"; +import type { Action, DonePromiseAction } from "../actions/types"; + +type EmptyLinesType = number[]; + +export type LoadedSymbols = SymbolDeclarations; +export type Symbols = LoadedSymbols | {| loading: true |}; + +export type EmptyLinesMap = { [k: string]: EmptyLinesType }; +export type SymbolsMap = { [k: string]: Symbols }; + +export type SourceMetaDataType = { + framework: ?string, +}; + +export type SourceMetaDataMap = { [k: string]: SourceMetaDataType }; + +export type ASTState = { + +symbols: SymbolsMap, + +inScopeLines: { [string]: Array<number> }, +}; + +export function initialASTState(): ASTState { + return { + symbols: {}, + inScopeLines: {}, + }; +} + +function update(state: ASTState = initialASTState(), action: Action): ASTState { + switch (action.type) { + case "SET_SYMBOLS": { + const { sourceId } = action; + if (action.status === "start") { + return { + ...state, + symbols: { ...state.symbols, [sourceId]: { loading: true } }, + }; + } + + const value = ((action: any): DonePromiseAction).value; + return { + ...state, + 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; + } + } +} + +// NOTE: we'd like to have the app state fully typed +// https://github.com/firefox-devtools/debugger/blob/master/src/reducers/sources.js#L179-L185 +type OuterState = { ast: ASTState }; + +export function getSymbols(state: OuterState, source: ?Source): ?Symbols { + if (!source) { + return null; + } + + return state.ast.symbols[source.id] || null; +} + +export function hasSymbols(state: OuterState, source: Source): boolean { + const symbols = getSymbols(state, source); + + if (!symbols) { + return false; + } + + return !symbols.loading; +} + +export function getFramework(state: OuterState, source: Source): ?string { + const symbols = getSymbols(state, source); + if (symbols && !symbols.loading) { + return symbols.framework; + } +} + +export function isSymbolsLoading(state: OuterState, source: ?Source): boolean { + const symbols = getSymbols(state, source); + if (!symbols) { + return false; + } + + return symbols.loading; +} + +export function getInScopeLines(state: OuterState, location: SourceLocation) { + return state.ast.inScopeLines[makeBreakpointId(location)]; +} + +export function hasInScopeLines( + state: OuterState, + location: SourceLocation +): boolean { + return !!getInScopeLines(state, location); +} + +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..e3675f2cdb --- /dev/null +++ b/devtools/client/debugger/src/reducers/async-requests.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Async request reducer + * @module reducers/async-request + */ + +const initialAsyncRequestState = []; + +function update(state: string[] = initialAsyncRequestState, action: any) { + 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): string[]); + } + + 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..a1d74e83e5 --- /dev/null +++ b/devtools/client/debugger/src/reducers/breakpoints.js @@ -0,0 +1,246 @@ +/* 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/>. */ + +// @flow + +/** + * Breakpoints reducer + * @module reducers/breakpoints + */ + +import { isGeneratedId } from "devtools-source-map"; +import { isEqual } from "lodash"; + +import { makeBreakpointId } from "../utils/breakpoint"; + +// eslint-disable-next-line max-len +import { getBreakpointsList as getBreakpointsListSelector } from "../selectors/breakpoints"; + +import type { + XHRBreakpoint, + Breakpoint, + BreakpointId, + SourceId, + SourceLocation, +} from "../types"; +import type { Action } from "../actions/types"; + +export type BreakpointsMap = { [BreakpointId]: Breakpoint }; +export type XHRBreakpointsList = $ReadOnlyArray<XHRBreakpoint>; + +export type BreakpointsState = { + breakpoints: BreakpointsMap, + xhrBreakpoints: XHRBreakpointsList, + breakpointsDisabled: boolean, +}; + +export function initialBreakpointsState( + xhrBreakpoints?: XHRBreakpointsList = [] +): BreakpointsState { + return { + breakpoints: {}, + xhrBreakpoints, + breakpointsDisabled: false, + }; +} + +function update( + state: BreakpointsState = initialBreakpointsState(), + action: Action +): BreakpointsState { + 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_BREAKPOINTS": { + return { ...state, breakpoints: {} }; + } + + case "NAVIGATE": { + return initialBreakpointsState(state.xhrBreakpoints); + } + + 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); + } + } + + 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 => !isEqual(bp, breakpoint)), + }; +} + +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 }): BreakpointsState { + const id = makeBreakpointId(breakpoint.location); + const breakpoints = { ...state.breakpoints, [id]: breakpoint }; + return { ...state, breakpoints }; +} + +function removeBreakpoint(state, { location }): BreakpointsState { + const id = makeBreakpointId(location); + const breakpoints = { ...state.breakpoints }; + delete breakpoints[id]; + return { ...state, breakpoints }; +} + +function isMatchingLocation(location1, location2) { + return isEqual(location1, location2); +} + +// Selectors +// TODO: these functions should be moved out of the reducer + +type OuterState = { breakpoints: BreakpointsState }; + +export function getBreakpointsMap(state: OuterState): BreakpointsMap { + return state.breakpoints.breakpoints; +} + +export function getBreakpointsList(state: OuterState): Breakpoint[] { + return getBreakpointsListSelector((state: any)); +} + +export function getBreakpointCount(state: OuterState): number { + return getBreakpointsList(state).length; +} + +export function getBreakpoint( + state: OuterState, + location: ?SourceLocation +): ?Breakpoint { + if (!location) { + return undefined; + } + + const breakpoints = getBreakpointsMap(state); + return breakpoints[makeBreakpointId(location)]; +} + +export function getBreakpointsDisabled(state: OuterState): boolean { + const breakpoints = getBreakpointsList(state); + return breakpoints.every(breakpoint => breakpoint.disabled); +} + +export function getBreakpointsForSource( + state: OuterState, + sourceId: SourceId, + line: ?number +): Breakpoint[] { + if (!sourceId) { + return []; + } + + const isGeneratedSource = isGeneratedId(sourceId); + const breakpoints = getBreakpointsList(state); + return breakpoints.filter(bp => { + const location = isGeneratedSource ? bp.generatedLocation : bp.location; + return location.sourceId === sourceId && (!line || line == location.line); + }); +} + +export function getBreakpointForLocation( + state: OuterState, + location: ?SourceLocation +): ?Breakpoint { + if (!location) { + return undefined; + } + + const isGeneratedSource = isGeneratedId(location.sourceId); + return getBreakpointsList(state).find(bp => { + const loc = isGeneratedSource ? bp.generatedLocation : bp.location; + return isMatchingLocation(loc, location); + }); +} + +export function getHiddenBreakpoint(state: OuterState): ?Breakpoint { + const breakpoints = getBreakpointsList(state); + return breakpoints.find(bp => bp.options.hidden); +} + +export function hasLogpoint( + state: OuterState, + location: ?SourceLocation +): ?string { + const breakpoint = getBreakpoint(state, location); + return breakpoint?.options.logValue; +} + +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..1b8140e497 --- /dev/null +++ b/devtools/client/debugger/src/reducers/event-listeners.js @@ -0,0 +1,81 @@ +/* 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/>. */ + +// @flow + +import { prefs } from "../utils/prefs"; + +import type { State } from "./types"; +import type { + EventListenerAction, + EventListenerActiveList, + EventListenerCategoryList, + EventListenerExpandedList, +} from "../actions/types"; + +export type EventListenersState = {| + // XXX: The `active` property is expected by the thread-utils module at + // devtools/client/shared/thread-utils.js . If the name is updated here, + // thread-utils.js should be updated at the same time. + +active: EventListenerActiveList, + +categories: EventListenerCategoryList, + +expanded: EventListenerExpandedList, + +logEventBreakpoints: boolean, +|}; + +export function initialEventListenerState(): EventListenersState { + return { + active: [], + categories: [], + expanded: [], + logEventBreakpoints: prefs.logEventBreakpoints, + }; +} + +function update( + state: EventListenersState = initialEventListenerState(), + action: EventListenerAction +) { + 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 function getActiveEventListeners(state: State): EventListenerActiveList { + return state.eventListenerBreakpoints.active; +} + +export function getEventListenerBreakpointTypes( + state: State +): EventListenerCategoryList { + return state.eventListenerBreakpoints.categories; +} + +export function getEventListenerExpanded( + state: State +): EventListenerExpandedList { + return state.eventListenerBreakpoints.expanded; +} + +export function shouldLogEventBreakpoints(state: State) { + return state.eventListenerBreakpoints.logEventBreakpoints; +} + +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..d60e34343b --- /dev/null +++ b/devtools/client/debugger/src/reducers/exceptions.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/>. */ + +// @flow + +/** + * Exceptions reducer + * @module reducers/exceptionss + */ + +import { createSelector } from "reselect"; +import { getSelectedSource, getSourceActorsForSource } from "../selectors"; + +import type { Exception, SourceActorId, SourceActor } from "../types"; + +import type { Action } from "../actions/types"; +import type { Selector, State } from "./types"; + +export type ExceptionMap = { [SourceActorId]: Exception[] }; + +export type ExceptionState = { + exceptions: ExceptionMap, +}; + +export function initialExceptionsState() { + return { + exceptions: {}, + }; +} + +function update( + state: ExceptionState = initialExceptionsState(), + action: Action +): ExceptionState { + 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], + }, + }; +} + +// Selectors +export function getExceptionsMap(state: State): ExceptionMap { + return state.exceptions.exceptions; +} + +export const getSelectedSourceExceptions: Selector< + Exception[] +> = createSelector( + getSelectedSourceActors, + getExceptionsMap, + (sourceActors: Array<SourceActor>, exceptions) => { + const sourceExceptions = []; + + sourceActors.forEach(sourceActor => { + const actorId = sourceActor.id; + + if (exceptions[actorId]) { + sourceExceptions.push(...exceptions[actorId]); + } + }); + + return sourceExceptions; + } +); + +function getSelectedSourceActors(state) { + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + return []; + } + return getSourceActorsForSource(state, selectedSource.id); +} + +export function hasException( + state: State, + line: number, + column: number +): boolean { + return !!getSelectedException(state, line, column); +} + +export function getSelectedException( + state: State, + line: number, + column: number +): ?Exception { + const sourceExceptions = getSelectedSourceExceptions(state); + + if (!sourceExceptions) { + return; + } + + return sourceExceptions.find( + sourceExc => + sourceExc.lineNumber === line && sourceExc.columnNumber === column + ); +} + +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..9e657a3d42 --- /dev/null +++ b/devtools/client/debugger/src/reducers/expressions.js @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +/** + * Expressions reducer + * @module reducers/expressions + */ + +import { omit, zip } from "lodash"; + +import { createSelector } from "reselect"; +import { prefs } from "../utils/prefs"; + +import type { Expression } from "../types"; +import type { Selector, State } from "../reducers/types"; +import type { Action } from "../actions/types"; + +type AutocompleteMatches = { [string]: string[] }; +export type ExpressionState = { + expressions: Expression[], + expressionError: boolean, + autocompleteMatches: AutocompleteMatches, + currentAutocompleteInput: string | null, +}; + +export const initialExpressionState = () => ({ + expressions: restoreExpressions(), + expressionError: false, + autocompleteMatches: {}, + currentAutocompleteInput: null, +}); + +function update( + state: ExpressionState = initialExpressionState(), + action: Action +): ExpressionState { + 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 zip(inputs, results).reduce( + (_state, [input, result]) => + updateExpressionInList(_state, input, { + input, + value: result, + 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(): Expression[] { + const exprs = prefs.expressions; + if (exprs.length == 0) { + return []; + } + + return exprs; +} + +function storeExpressions({ expressions }): void { + prefs.expressions = expressions.map(expression => omit(expression, "value")); +} + +function appendExpressionToList( + state: ExpressionState, + value: any +): ExpressionState { + const newState = { ...state, expressions: [...state.expressions, value] }; + + storeExpressions(newState); + return newState; +} + +function updateExpressionInList( + state: ExpressionState, + key: string, + value: any +): ExpressionState { + 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: ExpressionState, + input: string +): ExpressionState { + 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; +} + +const getExpressionsWrapper = state => state.expressions; + +export const getExpressions: Selector<Array<Expression>> = createSelector( + getExpressionsWrapper, + expressions => expressions.expressions +); + +export const getAutocompleteMatches: Selector<AutocompleteMatches> = createSelector( + getExpressionsWrapper, + expressions => expressions.autocompleteMatches +); + +export function getExpression(state: State, input: string): ?Expression { + return getExpressions(state).find(exp => exp.input == input); +} + +export function getAutocompleteMatchset(state: State) { + const input = state.expressions.currentAutocompleteInput; + if (input) { + return getAutocompleteMatches(state)[input]; + } +} + +export const getExpressionError: Selector<boolean> = createSelector( + getExpressionsWrapper, + expressions => expressions.expressionError +); + +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..fbddf6d12b --- /dev/null +++ b/devtools/client/debugger/src/reducers/file-search.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/>. */ + +// @flow + +/** + * File Search reducer + * @module reducers/fileSearch + */ + +import { prefs } from "../utils/prefs"; + +import type { Action } from "../actions/types"; + +export type Modifiers = { + caseSensitive: boolean, + wholeWord: boolean, + regexMatch: boolean, +}; + +export type MatchedLocations = { + line: number, + ch: number, +}; + +export type SearchResults = { + matches: Array<MatchedLocations>, + matchIndex: number, + index: number, + count: number, +}; + +export type FileSearchState = { + searchResults: SearchResults, + query: string, + modifiers: Modifiers, +}; + +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: FileSearchState = initialFileSearchState(), + action: Action +): FileSearchState { + 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; + } + } +} + +// NOTE: we'd like to have the app state fully typed +// https://github.com/firefox-devtools/debugger/blob/master/src/reducers/sources.js#L179-L185 +type OuterState = { fileSearch: FileSearchState }; + +export function getFileSearchQuery(state: OuterState): string { + return state.fileSearch.query; +} + +export function getFileSearchModifiers(state: OuterState): Modifiers { + return state.fileSearch.modifiers; +} + +export function getFileSearchResults(state: OuterState): SearchResults { + return state.fileSearch.searchResults; +} + +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..6000f81053 --- /dev/null +++ b/devtools/client/debugger/src/reducers/index.js @@ -0,0 +1,86 @@ +/* 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/>. */ + +// @flow + +/** + * Reducer index + * @module reducers/index + */ + +import expressions, { initialExpressionState } from "./expressions"; +import sourceActors from "./source-actors"; +import sources, { initialSourcesState } from "./sources"; +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 sourceTree, { initialSourcesTreeState } from "./source-tree"; +import threads, { initialThreadsState } from "./threads"; +import eventListenerBreakpoints, { + initialEventListenerState, +} from "./event-listeners"; +import exceptions, { initialExceptionsState } from "./exceptions"; + +import type { SourceActorsState } from "./source-actors"; + +// $FlowIgnore +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +import { createInitial } from "../utils/resource"; + +export function initialState() { + return { + sources: initialSourcesState(), + expressions: initialExpressionState(), + sourceActors: (createInitial(): SourceActorsState), + tabs: initialTabState(), + breakpoints: initialBreakpointsState(), + pendingBreakpoints: {}, + asyncRequests: [], + pause: initialPauseState(), + ui: initialUIState(), + fileSearch: initialFileSearchState(), + ast: initialASTState(), + projectTextSearch: initialProjectTextSearchState(), + quickOpen: initialQuickOpenState(), + sourceTree: initialSourcesTreeState(), + threads: initialThreadsState(), + objectInspector: objectInspector.reducer.initialOIState(), + eventListenerBreakpoints: initialEventListenerState(), + preview: initialPreviewState(), + exceptions: initialExceptionsState(), + }; +} + +export default { + expressions, + sourceActors, + sources, + tabs, + breakpoints, + pendingBreakpoints, + asyncRequests, + pause, + ui, + fileSearch, + ast, + projectTextSearch, + quickOpen, + sourceTree, + 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..bada00cbfc --- /dev/null +++ b/devtools/client/debugger/src/reducers/moz.build @@ -0,0 +1,28 @@ +# 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-tree.js", + "sources.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..fd2954025f --- /dev/null +++ b/devtools/client/debugger/src/reducers/pause.js @@ -0,0 +1,711 @@ +/* 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/>. */ + +// @flow +/* eslint complexity: ["error", 35]*/ + +/** + * Pause reducer + * @module reducers/pause + */ + +import { isGeneratedId } from "devtools-source-map"; +import { prefs } from "../utils/prefs"; +import { getSelectedSourceId } from "./sources"; +import { getSelectedFrame } from "../selectors/pause"; + +import type { OriginalScope } from "../utils/pause/mapScopes"; +import type { Action } from "../actions/types"; +import type { State } from "./types"; +import type { + Why, + Scope, + SourceId, + ChromeFrame, + FrameId, + MappedLocation, + ThreadId, + Context, + ThreadContext, + Previews, + SourceLocation, + HighlightedCalls, +} from "../types"; + +export type Command = null | "stepOver" | "stepIn" | "stepOut" | "resume"; + +// Pause state associated with an individual thread. +type ThreadPauseState = { + why: ?Why, + isWaitingOnBreak: boolean, + frames: ?(any[]), + framesLoading: boolean, + frameScopes: { + generated: { + [FrameId]: { + pending: boolean, + scope: Scope, + }, + }, + original: { + [FrameId]: { + pending: boolean, + scope: OriginalScope, + }, + }, + mappings: { + [FrameId]: { + [string]: string | null, + }, + }, + }, + selectedFrameId: ?string, + + // Scope items that have been expanded in the current pause. + expandedScopes: Set<string>, + + // Scope items that were expanded in the last pause. This is separate from + // expandedScopes so that (a) the scope pane's ObjectInspector does not depend + // on the current expanded scopes and we don't have to re-render the entire + // ObjectInspector when an element is expanded or collapsed, and (b) so that + // the expanded scopes are regenerated when we pause at a new location and we + // don't have to worry about pruning obsolete scope entries. + lastExpandedScopes: string[], + + command: Command, + lastCommand: Command, + wasStepping: boolean, + previousLocation: ?MappedLocation, + inlinePreview: { + [FrameId]: Object, + }, + highlightedCalls: ?HighlightedCalls, +}; + +// Pause state describing all threads. +export type PauseState = { + cx: Context, + threadcx: ThreadContext, + threads: { [ThreadId]: ThreadPauseState }, + skipPausing: boolean, + mapScopes: boolean, + shouldPauseOnExceptions: boolean, + shouldPauseOnCaughtExceptions: boolean, + previewLocation: ?SourceLocation, +}; + +export function initialPauseState(thread: ThreadId = "UnknownThread") { + return { + cx: { + navigateCounter: 0, + }, + threadcx: { + navigateCounter: 0, + thread, + isPaused: false, + pauseCounter: 0, + }, + previewLocation: null, + highlightedCalls: null, + threads: {}, + skipPausing: prefs.skipPausing, + mapScopes: prefs.mapScopes, + shouldPauseOnExceptions: prefs.pauseOnExceptions, + shouldPauseOnCaughtExceptions: prefs.pauseOnCaughtExceptions, + }; +} + +const resumedPauseState = { + frames: null, + framesLoading: false, + frameScopes: { + generated: {}, + original: {}, + mappings: {}, + }, + selectedFrameId: null, + why: null, + inlinePreview: {}, + highlightedCalls: null, +}; + +const createInitialPauseState = () => ({ + ...resumedPauseState, + isWaitingOnBreak: false, + command: null, + lastCommand: null, + previousLocation: null, + expandedScopes: new Set(), + lastExpandedScopes: [], +}); + +function getThreadPauseState(state: PauseState, thread: ThreadId) { + // 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: PauseState = initialPauseState(), + action: Action +): PauseState { + // 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, + isPaused: !!threadState().frames, + pauseCounter: state.threadcx.pauseCounter + 1, + }, + }; + } + + case "PAUSED": { + const { thread, frame, why } = action; + + state = { + ...state, + previewLocation: null, + threadcx: { + ...state.threadcx, + pauseCounter: state.threadcx.pauseCounter + 1, + thread, + isPaused: true, + }, + }; + return updateThreadState({ + isWaitingOnBreak: false, + selectedFrameId: frame ? frame.id : undefined, + frames: frame ? [frame] : undefined, + framesLoading: true, + frameScopes: { ...resumedPauseState.frameScopes }, + why, + }); + } + + case "FETCHED_FRAMES": { + const { frames } = action; + return updateThreadState({ frames, framesLoading: false }); + } + + case "PREVIEW_PAUSED_LOCATION": { + return { ...state, previewLocation: action.location }; + } + + case "CLEAR_PREVIEW_PAUSED_LOCATION": { + return { ...state, previewLocation: null }; + } + + 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 "CONNECT": + return { + ...initialPauseState(action.mainThreadActorID), + }; + + 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, + lastCommand: 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, + isPaused: false, + }, + }; + } + return updateThreadState({ + ...resumedPauseState, + wasStepping: !!action.wasStepping, + expandedScopes: new Set(), + lastExpandedScopes: [...threadState().expandedScopes], + }); + } + + 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, + isPaused: false, + }, + 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, + }); + } + } + + 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, + }; +} + +// Selectors + +export function getContext(state: State) { + return state.pause.cx; +} + +export function getThreadContext(state: State) { + return state.pause.threadcx; +} + +export function getPauseReason(state: State, thread: ThreadId): ?Why { + return getThreadPauseState(state.pause, thread).why; +} + +export function getPauseCommand(state: State, thread: ThreadId): Command { + return getThreadPauseState(state.pause, thread).command; +} + +export function wasStepping(state: State, thread: ThreadId): boolean { + return getThreadPauseState(state.pause, thread).wasStepping; +} + +export function isStepping(state: State, thread: ThreadId) { + return ["stepIn", "stepOver", "stepOut"].includes( + getPauseCommand(state, thread) + ); +} + +export function getCurrentThread(state: State) { + return getThreadContext(state).thread; +} + +export function getIsPaused(state: State, thread: ThreadId) { + return !!getThreadPauseState(state.pause, thread).frames; +} + +export function getPreviousPauseFrameLocation(state: State, thread: ThreadId) { + return getThreadPauseState(state.pause, thread).previousLocation; +} + +export function isEvaluatingExpression(state: State, thread: ThreadId) { + return getThreadPauseState(state.pause, thread).command === "expression"; +} + +export function getIsWaitingOnBreak(state: State, thread: ThreadId) { + return getThreadPauseState(state.pause, thread).isWaitingOnBreak; +} + +export function getShouldPauseOnExceptions(state: State) { + return state.pause.shouldPauseOnExceptions; +} + +export function getShouldPauseOnCaughtExceptions(state: State) { + return state.pause.shouldPauseOnCaughtExceptions; +} + +export function getFrames(state: State, thread: ThreadId) { + const { frames, framesLoading } = getThreadPauseState(state.pause, thread); + return framesLoading ? null : frames; +} + +export function getCurrentThreadFrames(state: State) { + const { frames, framesLoading } = getThreadPauseState( + state.pause, + getCurrentThread(state) + ); + return framesLoading ? null : frames; +} + +function getGeneratedFrameId(frameId: string): string { + if (frameId.includes("-originalFrame")) { + // The mapFrames can add original stack frames -- get generated frameId. + return frameId.substr(0, frameId.lastIndexOf("-originalFrame")); + } + return frameId; +} + +export function getGeneratedFrameScope( + state: State, + thread: ThreadId, + frameId: ?string +) { + if (!frameId) { + return null; + } + + return getFrameScopes(state, thread).generated[getGeneratedFrameId(frameId)]; +} + +export function getOriginalFrameScope( + state: State, + thread: ThreadId, + sourceId: ?SourceId, + frameId: ?string +): ?{ + pending: boolean, + +scope: OriginalScope | Scope, +} { + if (!frameId || !sourceId) { + return null; + } + + const isGenerated = isGeneratedId(sourceId); + const original = getFrameScopes(state, thread).original[ + getGeneratedFrameId(frameId) + ]; + + if (!isGenerated && original && (original.pending || original.scope)) { + return original; + } + + return null; +} + +export function getFrameScopes(state: State, thread: ThreadId) { + return getThreadPauseState(state.pause, thread).frameScopes; +} + +export function getSelectedFrameBindings(state: State, thread: ThreadId) { + const scopes = getFrameScopes(state, thread); + const selectedFrameId = getSelectedFrameId(state, thread); + if (!scopes || !selectedFrameId) { + return null; + } + + const frameScope = scopes.generated[selectedFrameId]; + if (!frameScope || frameScope.pending) { + return; + } + + let currentScope = frameScope.scope; + let frameBindings = []; + while (currentScope && currentScope.type != "object") { + if (currentScope.bindings) { + const bindings = Object.keys(currentScope.bindings.variables); + const args = [].concat( + ...currentScope.bindings.arguments.map(argument => + Object.keys(argument) + ) + ); + + frameBindings = [...frameBindings, ...bindings, ...args]; + } + currentScope = currentScope.parent; + } + + return frameBindings; +} + +export function getFrameScope( + state: State, + thread: ThreadId, + sourceId: ?SourceId, + frameId: ?string +): ?{ + pending: boolean, + +scope: OriginalScope | Scope, +} { + return ( + getOriginalFrameScope(state, thread, sourceId, frameId) || + getGeneratedFrameScope(state, thread, frameId) + ); +} + +export function getSelectedScope(state: State, thread: ThreadId) { + const sourceId = getSelectedSourceId(state); + const frameId = getSelectedFrameId(state, thread); + + const frameScope = getFrameScope(state, thread, sourceId, frameId); + if (!frameScope) { + return null; + } + + return frameScope.scope || null; +} + +export function getSelectedOriginalScope(state: State, thread: ThreadId) { + const sourceId = getSelectedSourceId(state); + const frameId = getSelectedFrameId(state, thread); + return getOriginalFrameScope(state, thread, sourceId, frameId); +} + +export function getSelectedGeneratedScope(state: State, thread: ThreadId) { + const frameId = getSelectedFrameId(state, thread); + return getGeneratedFrameScope(state, thread, frameId); +} + +export function getSelectedScopeMappings( + state: State, + thread: ThreadId +): { + [string]: string | null, +} | null { + const frameId = getSelectedFrameId(state, thread); + if (!frameId) { + return null; + } + + return getFrameScopes(state, thread).mappings[frameId]; +} + +export function getSelectedFrameId(state: State, thread: ThreadId) { + return getThreadPauseState(state.pause, thread).selectedFrameId; +} + +export function isTopFrameSelected(state: State, thread: ThreadId) { + const selectedFrameId = getSelectedFrameId(state, thread); + const topFrame = getTopFrame(state, thread); + return selectedFrameId == topFrame?.id; +} + +export function getTopFrame(state: State, thread: ThreadId) { + const frames = getFrames(state, thread); + return frames?.[0]; +} + +export function getSkipPausing(state: State) { + return state.pause.skipPausing; +} + +export function getHighlightedCalls(state: State, thread: ThreadId) { + return getThreadPauseState(state.pause, thread).highlightedCalls; +} + +export function isMapScopesEnabled(state: State) { + return state.pause.mapScopes; +} + +export function getInlinePreviews( + state: State, + thread: ThreadId, + frameId: string +): Previews { + return getThreadPauseState(state.pause, thread).inlinePreview[ + getGeneratedFrameId(frameId) + ]; +} + +export function getSelectedInlinePreviews(state: State) { + const thread = getCurrentThread(state); + const frameId = getSelectedFrameId(state, thread); + if (!frameId) { + return null; + } + + return getInlinePreviews(state, thread, frameId); +} + +export function getInlinePreviewExpression( + state: State, + thread: ThreadId, + frameId: string, + line: number, + expression: string +) { + const previews = getThreadPauseState(state.pause, thread).inlinePreview[ + getGeneratedFrameId(frameId) + ]; + return previews?.[line]?.[expression]; +} + +// NOTE: currently only used for chrome +export function getChromeScopes(state: State, thread: ThreadId) { + const frame: ?ChromeFrame = (getSelectedFrame(state, thread): any); + return frame?.scopeChain; +} + +export function getLastExpandedScopes(state: State, thread: ThreadId) { + return getThreadPauseState(state.pause, thread).lastExpandedScopes; +} + +export function getPausePreviewLocation(state: State) { + return state.pause.previewLocation; +} + +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..9b7f87ff21 --- /dev/null +++ b/devtools/client/debugger/src/reducers/pending-breakpoints.js @@ -0,0 +1,102 @@ +/* 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/>. */ + +// @flow + +/** + * Pending breakpoints reducer + * @module reducers/pending-breakpoints + */ + +import { + createPendingBreakpoint, + makePendingLocationId, +} from "../utils/breakpoint"; + +import { isPrettyURL } from "../utils/source"; + +import type { SourcesState } from "./sources"; +import type { PendingBreakpoint, Source } from "../types"; +import type { Action } from "../actions/types"; + +export type PendingBreakpointsState = { [string]: PendingBreakpoint }; + +function update(state: PendingBreakpointsState = {}, action: 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 "REMOVE_BREAKPOINTS": { + return {}; + } + } + + return state; +} + +function setBreakpoint(state, { breakpoint }) { + if (breakpoint.options.hidden) { + return state; + } + const location = + !breakpoint.location.sourceUrl || isPrettyURL(breakpoint.location.sourceUrl) + ? breakpoint.generatedLocation + : breakpoint.location; + const locationId = makePendingLocationId(location); + const pendingBreakpoint = createPendingBreakpoint(breakpoint); + + return { ...state, [locationId]: pendingBreakpoint }; +} + +function removeBreakpoint(state, { location }) { + const locationId = makePendingLocationId(location); + state = { ...state }; + + delete state[locationId]; + return state; +} + +// Selectors +// TODO: these functions should be moved out of the reducer + +type OuterState = { + pendingBreakpoints: PendingBreakpointsState, + sources: SourcesState, +}; + +export function getPendingBreakpoints(state: OuterState) { + return state.pendingBreakpoints; +} + +export function getPendingBreakpointList( + state: OuterState +): PendingBreakpoint[] { + return (Object.values(getPendingBreakpoints(state)): any); +} + +export function getPendingBreakpointsForSource( + state: OuterState, + source: Source +): PendingBreakpoint[] { + return getPendingBreakpointList(state).filter(pendingBreakpoint => { + return ( + pendingBreakpoint.location.sourceUrl === source.url || + pendingBreakpoint.generatedLocation.sourceUrl == source.url + ); + }); +} + +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..7b11d581b2 --- /dev/null +++ b/devtools/client/debugger/src/reducers/preview.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/>. */ + +// @flow + +import type { AstLocation } from "../workers/parser"; + +import type { Action } from "../actions/types"; +import type { Grip, Exception, OINode } from "../types"; + +export type Preview = {| + expression: string, + resultGrip: Grip | null, + root: OINode, + properties: Array<Grip>, + location: AstLocation, + cursorPos: any, + tokenPos: AstLocation, + target: HTMLDivElement, + exception: ?Exception, +|}; + +export type PreviewState = { + +preview: ?Preview, + previewCount: number, +}; + +export function initialPreviewState(): PreviewState { + return { + preview: null, + previewCount: 0, + }; +} + +function update( + state: PreviewState = initialPreviewState(), + action: Action +): PreviewState { + 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; +} + +// NOTE: we'd like to have the app state fully typed +// https://github.com/firefox-devtools/debugger/blob/master/src/reducers/sources.js#L179-L185 +type OuterState = { preview: PreviewState }; + +export function getPreview(state: OuterState): ?Preview { + return state.preview.preview; +} + +export function getPreviewCount(state: OuterState): number { + return state.preview.previewCount; +} + +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..a1ad6012cc --- /dev/null +++ b/devtools/client/debugger/src/reducers/project-text-search.js @@ -0,0 +1,112 @@ +/* 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/>. */ + +// @flow +// @format + +/** + * Project text search reducer + * @module reducers/project-text-search + */ + +import type { Action } from "../actions/types"; +import type { Cancellable, SourceId } from "../types"; + +export type Search = { + +sourceId: SourceId, + +filepath: string, + +matches: any[], +}; + +export type SearchOperation = Cancellable; + +export type StatusType = + | "INITIAL" + | "FETCHING" + | "CANCELLED" + | "DONE" + | "ERROR"; +export const statusType = { + initial: "INITIAL", + fetching: "FETCHING", + cancelled: "CANCELLED", + done: "DONE", + error: "ERROR", +}; + +export type ResultList = Search[]; +export type ProjectTextSearchState = { + +query: string, + +ongoingSearch: ?SearchOperation, + +results: ResultList, + +status: StatusType, +}; + +export function initialProjectTextSearchState(): ProjectTextSearchState { + return { + query: "", + results: [], + ongoingSearch: null, + status: statusType.initial, + }; +} + +function update( + state: ProjectTextSearchState = initialProjectTextSearchState(), + action: Action +): ProjectTextSearchState { + 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; +} + +type OuterState = { projectTextSearch: ProjectTextSearchState }; + +export function getTextSearchOperation(state: OuterState) { + return state.projectTextSearch.ongoingSearch; +} + +export function getTextSearchResults(state: OuterState) { + return state.projectTextSearch.results; +} + +export function getTextSearchStatus(state: OuterState) { + return state.projectTextSearch.status; +} + +export function getTextSearchQuery(state: OuterState) { + return state.projectTextSearch.query; +} + +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..11f7072646 --- /dev/null +++ b/devtools/client/debugger/src/reducers/quick-open.js @@ -0,0 +1,71 @@ +/* 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/>. */ + +// @flow + +/** + * Quick Open reducer + * @module reducers/quick-open + */ + +import { parseQuickOpenQuery } from "../utils/quick-open"; +import type { Action } from "../actions/types"; + +export type QuickOpenType = "sources" | "functions" | "goto" | "gotoSource"; + +export type QuickOpenState = { + enabled: boolean, + query: string, + searchType: QuickOpenType, +}; + +export const initialQuickOpenState = (): QuickOpenState => ({ + enabled: false, + query: "", + searchType: "sources", +}); + +export default function update( + state: QuickOpenState = initialQuickOpenState(), + action: Action +): QuickOpenState { + 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; + } +} + +type OuterState = { + quickOpen: QuickOpenState, +}; + +export function getQuickOpenEnabled(state: OuterState): boolean { + return state.quickOpen.enabled; +} + +export function getQuickOpenQuery(state: OuterState): string { + return state.quickOpen.query; +} + +export function getQuickOpenType(state: OuterState): QuickOpenType { + return state.quickOpen.searchType; +} 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..98423328bd --- /dev/null +++ b/devtools/client/debugger/src/reducers/source-actors.js @@ -0,0 +1,296 @@ +/* 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/>. */ + +// @flow + +import type { Action } from "../actions/types"; +import type { SourceId, ThreadId, URL } from "../types"; +import { + asSettled, + type AsyncValue, + type SettledValue, +} from "../utils/async-value"; +import { + createInitial, + insertResources, + updateResources, + removeResources, + hasResource, + getResource, + getMappedResource, + makeWeakQuery, + makeIdQuery, + makeReduceAllQuery, + type Resource, + type ResourceState, + type WeakQuery, + type IdQuery, + type ReduceAllQuery, +} from "../utils/resource"; + +import { asyncActionAsValue } from "../actions/utils/middleware/promise"; +import type { + SourceActorBreakpointColumnsAction, + SourceActorBreakableLinesAction, +} from "../actions/types/SourceActorAction"; + +export opaque type SourceActorId: string = string; +export type SourceActor = {| + +id: SourceActorId, + +actor: string, + +thread: ThreadId, + +source: SourceId, + + +isBlackBoxed: boolean, + + // The URL that the sourcemap should be loaded relative to. + +sourceMapBaseURL: URL | null, + + // The URL of the sourcemap for this source if there is one. + +sourceMapURL: URL | null, + + // The URL of the actor itself. If the source was from an "eval" or other + // string-based source, this will not be known. + +url: URL | null, + + // The debugger's Debugger.Source API provides type information for the + // cause of this source's creation. + +introductionType: string | null, +|}; + +type SourceActorResource = Resource<{ + ...SourceActor, + + // The list of breakpoint positions on each line of the file. + breakpointPositions: Map<number, AsyncValue<Array<number>>>, + + // The list of lines that contain breakpoints. + breakableLines: AsyncValue<Array<number>> | null, +}>; +export type SourceActorsState = ResourceState<SourceActorResource>; +export type SourceActorOuterState = { sourceActors: SourceActorsState }; + +export const initial: SourceActorsState = createInitial(); + +export default function update( + state: SourceActorsState = initial, + action: Action +): SourceActorsState { + switch (action.type) { + case "INSERT_SOURCE_ACTORS": { + const { items } = action; + state = insertResources( + state, + items.map(item => ({ + ...item, + breakpointPositions: new Map(), + breakableLines: null, + })) + ); + break; + } + case "REMOVE_SOURCE_ACTORS": { + state = removeResources(state, action.items); + break; + } + + case "NAVIGATE": { + state = initial; + break; + } + + case "SET_SOURCE_ACTOR_BREAKPOINT_COLUMNS": + state = updateBreakpointColumns(state, action); + 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: SourceActorsState, + id: SourceActorId +): SourceActorsState { + if (!hasResource(state, id)) { + return state; + } + + return updateResources(state, [ + { + id, + sourceMapURL: "", + }, + ]); +} + +function updateBreakpointColumns( + state: SourceActorsState, + action: SourceActorBreakpointColumnsAction +): SourceActorsState { + const { sourceId, line } = action; + const value = asyncActionAsValue(action); + + if (!hasResource(state, sourceId)) { + return state; + } + + const breakpointPositions = new Map( + getResource(state, sourceId).breakpointPositions + ); + breakpointPositions.set(line, value); + + return updateResources(state, [{ id: sourceId, breakpointPositions }]); +} + +function updateBreakableLines( + state: SourceActorsState, + action: SourceActorBreakableLinesAction +): SourceActorsState { + const value = asyncActionAsValue(action); + const { sourceId } = action; + + if (!hasResource(state, sourceId)) { + return state; + } + + return updateResources(state, [{ id: sourceId, breakableLines: value }]); +} + +export function resourceAsSourceActor({ + breakpointPositions, + breakableLines, + ...sourceActor +}: SourceActorResource): SourceActor { + return sourceActor; +} + +// Because we are using an opaque type for our source actor IDs, these +// functions are required to convert back and forth in order to get a string +// version of the IDs. That should be super rarely used, but it means that +// we can very easily see where we're relying on the string version of IDs. +export function stringToSourceActorId(s: string): SourceActorId { + return s; +} + +export function hasSourceActor( + state: SourceActorOuterState, + id: SourceActorId +): boolean { + return hasResource(state.sourceActors, id); +} + +export function getSourceActor( + state: SourceActorOuterState, + id: SourceActorId +): SourceActor { + return getMappedResource(state.sourceActors, id, resourceAsSourceActor); +} + +/** + * Get all of the source actors for a set of IDs. Caches based on the identity + * of "ids" when possible. + */ +const querySourceActorsById: IdQuery< + SourceActorResource, + SourceActor +> = makeIdQuery(resourceAsSourceActor); + +export function getSourceActors( + state: SourceActorOuterState, + ids: Array<SourceActorId> +): Array<SourceActor> { + return querySourceActorsById(state.sourceActors, ids); +} + +const querySourcesByThreadID: ReduceAllQuery< + SourceActorResource, + { [ThreadId]: Array<SourceActor> } +> = makeReduceAllQuery(resourceAsSourceActor, actors => { + return actors.reduce((acc, actor) => { + acc[actor.thread] = acc[actor.thread] || []; + acc[actor.thread].push(actor); + return acc; + }, {}); +}); +export function getSourceActorsForThread( + state: SourceActorOuterState, + ids: ThreadId | Array<ThreadId> +): Array<SourceActor> { + const sourcesByThread = querySourcesByThreadID(state.sourceActors); + + let sources = []; + for (const id of Array.isArray(ids) ? ids : [ids]) { + sources = sources.concat(sourcesByThread[id] || []); + } + return sources; +} + +const queryThreadsBySourceObject: ReduceAllQuery< + SourceActorResource, + { [SourceId]: Array<ThreadId> } +> = makeReduceAllQuery( + actor => ({ thread: actor.thread, source: actor.source }), + actors => + actors.reduce((acc, { source, thread }) => { + let sourceThreads = acc[source]; + if (!sourceThreads) { + sourceThreads = []; + acc[source] = sourceThreads; + } + + sourceThreads.push(thread); + return acc; + }, {}) +); + +export function getAllThreadsBySource( + state: SourceActorOuterState +): { [SourceId]: Array<ThreadId> } { + return queryThreadsBySourceObject(state.sourceActors); +} + +export function getSourceActorBreakableLines( + state: SourceActorOuterState, + id: SourceActorId +): SettledValue<Array<number>> | null { + const { breakableLines } = getResource(state.sourceActors, id); + + return asSettled(breakableLines); +} + +export function getSourceActorBreakpointColumns( + state: SourceActorOuterState, + id: SourceActorId, + line: number +): SettledValue<Array<number>> | null { + const { breakpointPositions } = getResource(state.sourceActors, id); + + return asSettled(breakpointPositions.get(line) || null); +} + +export const getBreakableLinesForSourceActors: WeakQuery< + SourceActorResource, + Array<SourceActorId>, + Array<number> +> = makeWeakQuery({ + filter: (state, ids) => ids, + map: ({ breakableLines }) => breakableLines, + reduce: items => + Array.from( + items.reduce((acc, item) => { + if (item && item.state === "fulfilled") { + acc = acc.concat(item.value); + } + return acc; + }, []) + ), +}); diff --git a/devtools/client/debugger/src/reducers/source-tree.js b/devtools/client/debugger/src/reducers/source-tree.js new file mode 100644 index 0000000000..0d8ca49e81 --- /dev/null +++ b/devtools/client/debugger/src/reducers/source-tree.js @@ -0,0 +1,58 @@ +/* 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/>. */ + +// @flow + +/** + * Source tree reducer + * @module reducers/source-tree + */ + +import type { SourceTreeAction, FocusItem } from "../actions/types"; + +export type SourceTreeState = { + expanded: Set<string>, + focusedItem: ?FocusItem, +}; + +export function initialSourcesTreeState(): SourceTreeState { + return { + expanded: new Set(), + focusedItem: null, + }; +} + +export default function update( + state: SourceTreeState = initialSourcesTreeState(), + action: SourceTreeAction +): SourceTreeState { + switch (action.type) { + case "SET_EXPANDED_STATE": + return updateExpanded(state, action); + + case "SET_FOCUSED_SOURCE_ITEM": + return { ...state, focusedItem: action.item }; + } + + return state; +} + +function updateExpanded(state, action) { + return { + ...state, + expanded: new Set(action.expanded), + }; +} + +type OuterState = { + sourceTree: SourceTreeState, +}; + +export function getExpandedState(state: OuterState) { + return state.sourceTree.expanded; +} + +export function getFocusedSourceItem(state: OuterState): ?FocusItem { + return state.sourceTree.focusedItem; +} diff --git a/devtools/client/debugger/src/reducers/sources.js b/devtools/client/debugger/src/reducers/sources.js new file mode 100644 index 0000000000..f75d9f6893 --- /dev/null +++ b/devtools/client/debugger/src/reducers/sources.js @@ -0,0 +1,1153 @@ +/* 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/>. */ + +// @flow + +/** + * Sources reducer + * @module reducers/sources + */ + +import { createSelector } from "reselect"; +import { + getPrettySourceURL, + underRoot, + getRelativeUrl, + isGenerated, + isOriginal as isOriginalSource, + getPlainUrl, + isPretty, + isJavaScript, +} from "../utils/source"; +import { + createInitial, + insertResources, + updateResources, + hasResource, + getResource, + getMappedResource, + getResourceIds, + memoizeResourceShallow, + makeShallowQuery, + makeReduceAllQuery, + makeMapWithArgs, + type Resource, + type ResourceState, + type ReduceAllQuery, + type ShallowQuery, +} from "../utils/resource"; +import { stripQuery } from "../utils/url"; + +import { findPosition } from "../utils/breakpoint/breakpointPositions"; +import { + pending, + fulfilled, + rejected, + asSettled, + isFulfilled, +} from "../utils/async-value"; + +import type { AsyncValue, SettledValue } from "../utils/async-value"; +import { originalToGeneratedId } from "devtools-source-map"; +import { prefs } from "../utils/prefs"; + +import { + hasSourceActor, + getSourceActor, + getSourceActors, + getAllThreadsBySource, + getBreakableLinesForSourceActors, + type SourceActorId, + type SourceActorOuterState, +} from "./source-actors"; +import { getAllThreads } from "./threads"; +import type { + DisplaySource, + Source, + SourceId, + SourceActor, + SourceLocation, + SourceContent, + SourceWithContent, + ThreadId, + Thread, + MappedLocation, + BreakpointPosition, + BreakpointPositions, + URL, +} from "../types"; + +import type { + PendingSelectedLocation, + Selector, + State as AppState, +} from "./types"; + +import type { Action, DonePromiseAction, FocusItem } from "../actions/types"; +import type { LoadSourceAction } from "../actions/types/SourceAction"; +import { uniq } from "lodash"; + +export type SourcesMap = { [SourceId]: Source }; +export type SourcesMapByThread = { + [ThreadId]: { [SourceId]: DisplaySource }, +}; + +export type BreakpointPositionsMap = { [SourceId]: BreakpointPositions }; +type SourceActorMap = { [SourceId]: Array<SourceActorId> }; + +type UrlsMap = { [string]: SourceId[] }; +type PlainUrlsMap = { [string]: string[] }; + +export type SourceBase = {| + +id: SourceId, + +url: URL, + +isBlackBoxed: boolean, + +isPrettyPrinted: boolean, + +relativeUrl: URL, + +extensionName: ?string, + +isExtension: boolean, + +isWasm: boolean, + +isOriginal: boolean, +|}; + +export type SourceResource = Resource<{ + ...SourceBase, + content: AsyncValue<SourceContent> | null, +}>; +export type SourceResourceState = ResourceState<SourceResource>; + +type IdsList = Array<SourceId>; + +export type SourcesState = { + epoch: number, + + // All known sources. + sources: SourceResourceState, + + breakpointPositions: BreakpointPositionsMap, + breakableLines: { [SourceId]: Array<number> }, + + // A link between each source object and the source actor they wrap over. + actors: SourceActorMap, + + // All sources associated with a given URL. When using source maps, multiple + // sources can have the same URL. + urls: UrlsMap, + + // All full URLs belonging to a given plain (query string stripped) URL. + // Query strings are only shown in the Sources tab if they are required for + // disambiguation. + plainUrls: PlainUrlsMap, + + sourcesWithUrls: IdsList, + + pendingSelectedLocation?: PendingSelectedLocation, + selectedLocation: ?SourceLocation, + projectDirectoryRoot: string, + projectDirectoryRootName: string, + chromeAndExtensionsEnabled: boolean, + focusedItem: ?FocusItem, + tabsBlackBoxed: any, +}; + +export function initialSourcesState( + state: ?{ tabsBlackBoxed: string[] } +): SourcesState { + return { + sources: createInitial(), + urls: {}, + plainUrls: {}, + sourcesWithUrls: [], + content: {}, + actors: {}, + breakpointPositions: {}, + breakableLines: {}, + epoch: 1, + selectedLocation: undefined, + pendingSelectedLocation: prefs.pendingSelectedLocation, + projectDirectoryRoot: prefs.projectDirectoryRoot, + projectDirectoryRootName: prefs.projectDirectoryRootName, + chromeAndExtensionsEnabled: prefs.chromeAndExtensionsEnabled, + focusedItem: null, + tabsBlackBoxed: state?.tabsBlackBoxed ?? [], + }; +} + +function update( + state: SourcesState = initialSourcesState(), + action: Action +): SourcesState { + let location = null; + + switch (action.type) { + case "ADD_SOURCE": + return addSources(state, [action.source]); + + case "ADD_SOURCES": + return addSources(state, action.sources); + + case "INSERT_SOURCE_ACTORS": + return insertSourceActors(state, action); + + case "REMOVE_SOURCE_ACTORS": + return removeSourceActors(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 "LOAD_SOURCE_TEXT": + return updateLoadedState(state, action); + + case "BLACKBOX_SOURCES": + if (action.status === "done") { + const { shouldBlackBox } = action; + const { sources } = action.value; + + state = updateBlackBoxListSources(state, sources, shouldBlackBox); + return updateBlackboxFlagSources(state, sources, shouldBlackBox); + } + break; + + case "BLACKBOX": + if (action.status === "done") { + const { id, url } = action.source; + const { isBlackBoxed } = ((action: any): DonePromiseAction).value; + state = updateBlackBoxList(state, url, isBlackBoxed); + return updateBlackboxFlag(state, id, isBlackBoxed); + } + break; + + case "SET_PROJECT_DIRECTORY_ROOT": + const { url, name } = action; + return updateProjectDirectoryRoot(state, url, name); + + 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), + epoch: state.epoch + 1, + }; + + case "SET_FOCUSED_SOURCE_ITEM": + return { ...state, focusedItem: action.item }; + } + + return state; +} + +export const resourceAsSourceBase = memoizeResourceShallow( + ({ content, ...source }: SourceResource): SourceBase => source +); + +const resourceAsSourceWithContent = memoizeResourceShallow( + ({ content, ...source }: SourceResource): SourceWithContent => ({ + ...source, + content: asSettled(content), + }) +); + +/* + * Add sources to the sources store + * - Add the source to the sources store + * - Add the source URL to the urls map + */ +function addSources(state: SourcesState, sources: SourceBase[]): SourcesState { + const originalState = state; + + state = { + ...state, + urls: { ...state.urls }, + plainUrls: { ...state.plainUrls }, + }; + + state.sources = insertResources( + state.sources, + sources.map(source => ({ + ...source, + content: null, + })) + ); + + for (const source of sources) { + // 1. Update the source url map + const existing = state.urls[source.url] || []; + if (!existing.includes(source.id)) { + state.urls[source.url] = [...existing, source.id]; + } + + // 2. Update the plain url map + if (source.url) { + const plainUrl = getPlainUrl(source.url); + const existingPlainUrls = state.plainUrls[plainUrl] || []; + if (!existingPlainUrls.includes(source.url)) { + state.plainUrls[plainUrl] = [...existingPlainUrls, source.url]; + } + + // NOTE: we only want to copy the list once + if (originalState.sourcesWithUrls === state.sourcesWithUrls) { + state.sourcesWithUrls = [...state.sourcesWithUrls]; + } + + state.sourcesWithUrls.push(source.id); + } + } + + state = updateRootRelativeValues(state, sources); + + return state; +} + +function insertSourceActors(state: SourcesState, action): SourcesState { + const { items } = action; + state = { + ...state, + actors: { ...state.actors }, + }; + + for (const sourceActor of items) { + state.actors[sourceActor.source] = [ + ...(state.actors[sourceActor.source] || []), + sourceActor.id, + ]; + } + + const scriptActors = items.filter( + item => item.introductionType === "scriptElement" + ); + if (scriptActors.length > 0) { + 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; +} + +/* + * Update sources when the worker list changes. + * - filter source actor lists so that missing threads no longer appear + * - NOTE: we do not remove sources for destroyed threads + */ +function removeSourceActors(state: SourcesState, action): SourcesState { + const { items } = action; + + const actors = new Set(items.map(item => item.id)); + const sources = new Set(items.map(item => item.source)); + + state = { + ...state, + actors: { ...state.actors }, + }; + + for (const source of sources) { + state.actors[source] = state.actors[source].filter(id => !actors.has(id)); + } + + return state; +} + +/* + * Update sources when the project directory root changes + */ +function updateProjectDirectoryRoot( + state: SourcesState, + root: string, + name: string +) { + // Only update prefs when projectDirectoryRoot isn't a thread actor, + // because when debugger is reopened, thread actor will change. See bug 1596323. + if (actorType(root) !== "thread") { + prefs.projectDirectoryRoot = root; + prefs.projectDirectoryRootName = name; + } + + return updateRootRelativeValues(state, undefined, root, name); +} + +/* Checks if a path is a thread actor or not + * e.g returns 'thread' for "server0.conn1.child1/workerTarget42/thread1" + */ +function actorType(actor: string): ?string { + const match = actor.match(/\/([a-z]+)\d+/); + return match ? match[1] : null; +} + +function updateRootRelativeValues( + state: SourcesState, + sources?: $ReadOnlyArray<Source>, + projectDirectoryRoot?: string = state.projectDirectoryRoot, + projectDirectoryRootName?: string = state.projectDirectoryRootName +): SourcesState { + const wrappedIdsOrIds: $ReadOnlyArray<Source> | Array<string> = sources + ? sources + : getResourceIds(state.sources); + + state = { + ...state, + projectDirectoryRoot, + projectDirectoryRootName, + }; + + const relativeURLUpdates = wrappedIdsOrIds.map(wrappedIdOrId => { + const id = + typeof wrappedIdOrId === "string" ? wrappedIdOrId : wrappedIdOrId.id; + const source = getResource(state.sources, id); + + return { + id, + relativeUrl: getRelativeUrl(source, state.projectDirectoryRoot), + }; + }); + + state.sources = updateResources(state.sources, relativeURLUpdates); + + return state; +} + +/* + * Update a source's loaded text content. + */ +function updateLoadedState( + state: SourcesState, + action: LoadSourceAction +): SourcesState { + const { sourceId } = 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 || !hasResource(state.sources, sourceId)) { + 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, + }); + } + + return { + ...state, + sources: updateResources(state.sources, [ + { + id: sourceId, + content, + }, + ]), + }; +} + +/* + * Update a source when its state changes + * e.g. the text was loaded, it was blackboxed + */ +function updateBlackboxFlag( + state: SourcesState, + sourceId: SourceId, + isBlackBoxed: boolean +): SourcesState { + // If there is no existing version of the source, it means that we probably + // ended up here as a result of an async action, and the sources were cleared + // between the action starting and the source being updated. + if (!hasResource(state.sources, sourceId)) { + // TODO: We may want to consider throwing here once we have a better + // handle on async action flow control. + return state; + } + + return { + ...state, + sources: updateResources(state.sources, [ + { + id: sourceId, + isBlackBoxed, + }, + ]), + }; +} + +function updateBlackboxFlagSources( + state: SourcesState, + sources: Source[], + shouldBlackBox: boolean +): SourcesState { + const sourcesToUpdate = []; + + for (const source of sources) { + if (!hasResource(state.sources, source.id)) { + // TODO: We may want to consider throwing here once we have a better + // handle on async action flow control. + continue; + } + + sourcesToUpdate.push({ + id: source.id, + isBlackBoxed: shouldBlackBox, + }); + } + state.sources = updateResources(state.sources, sourcesToUpdate); + + return state; +} + +function updateBlackboxTabs(tabs, url: URL, isBlackBoxed: boolean): void { + const i = tabs.indexOf(url); + if (i >= 0) { + if (!isBlackBoxed) { + tabs.splice(i, 1); + } + } else if (isBlackBoxed) { + tabs.push(url); + } +} + +function updateBlackBoxList( + state: SourcesState, + url: URL, + isBlackBoxed: boolean +): SourcesState { + const tabs = [...state.tabsBlackBoxed]; + updateBlackboxTabs(tabs, url, isBlackBoxed); + return { ...state, tabsBlackBoxed: tabs }; +} + +function updateBlackBoxListSources( + state: SourcesState, + sources, + shouldBlackBox +): SourcesState { + const tabs = [...state.tabsBlackBoxed]; + + sources.forEach(source => { + updateBlackboxTabs(tabs, source.url, shouldBlackBox); + }); + return { ...state, tabsBlackBoxed: tabs }; +} + +// Selectors + +// Unfortunately, it's really hard to make these functions accept just +// the state that we care about and still type it with Flow. The +// problem is that we want to re-export all selectors from a single +// module for the UI, and all of those selectors should take the +// top-level app state, so we'd have to "wrap" them to automatically +// pick off the piece of state we're interested in. It's impossible +// (right now) to type those wrapped functions. +type OuterState = { sources: SourcesState }; + +const getSourcesState = (state: OuterState) => state.sources; + +export function getSourceThreads( + state: OuterState & SourceActorOuterState, + source: Source +): ThreadId[] { + return uniq( + getSourceActors(state, state.sources.actors[source.id]).map( + actor => actor.thread + ) + ); +} + +export function getSourceInSources( + sources: SourceResourceState, + id: string +): ?Source { + return hasResource(sources, id) + ? getMappedResource(sources, id, resourceAsSourceBase) + : null; +} + +export function getSource(state: OuterState, id: SourceId): ?Source { + return getSourceInSources(getSources(state), id); +} + +export function getSourceFromId(state: OuterState, id: string): Source { + const source = getSource(state, id); + if (!source) { + throw new Error(`source ${id} does not exist`); + } + return source; +} + +export function getSourceByActorId( + state: OuterState & SourceActorOuterState, + actorId: SourceActorId +): ?Source { + if (!hasSourceActor(state, actorId)) { + return null; + } + + return getSource(state, getSourceActor(state, actorId).source); +} + +export function getSourcesByURLInSources( + sources: SourceResourceState, + urls: UrlsMap, + url: URL +): Source[] { + if (!url || !urls[url]) { + return []; + } + return urls[url].map(id => + getMappedResource(sources, id, resourceAsSourceBase) + ); +} + +export function getSourcesByURL(state: OuterState, url: URL): Source[] { + return getSourcesByURLInSources(getSources(state), getUrls(state), url); +} + +export function getSourceByURL(state: OuterState, url: URL): ?Source { + const foundSources = getSourcesByURL(state, url); + return foundSources ? foundSources[0] : null; +} + +export function getSpecificSourceByURLInSources( + sources: SourceResourceState, + urls: UrlsMap, + url: URL, + isOriginal: boolean +): ?Source { + const foundSources = getSourcesByURLInSources(sources, urls, url); + if (foundSources) { + return foundSources.find(source => isOriginalSource(source) == isOriginal); + } + return null; +} + +export function getSpecificSourceByURL( + state: OuterState, + url: URL, + isOriginal: boolean +): ?Source { + return getSpecificSourceByURLInSources( + getSources(state), + getUrls(state), + url, + isOriginal + ); +} + +export function getOriginalSourceByURL(state: OuterState, url: URL): ?Source { + return getSpecificSourceByURL(state, url, true); +} + +export function getGeneratedSourceByURL(state: OuterState, url: URL): ?Source { + return getSpecificSourceByURL(state, url, false); +} + +export function getGeneratedSource( + state: OuterState, + source: ?Source +): ?Source { + if (!source) { + return null; + } + + if (isGenerated(source)) { + return source; + } + + return getSourceFromId(state, originalToGeneratedId(source.id)); +} + +export function getGeneratedSourceById( + state: OuterState, + sourceId: SourceId +): Source { + const generatedSourceId = originalToGeneratedId(sourceId); + return getSourceFromId(state, generatedSourceId); +} + +export function getPendingSelectedLocation(state: OuterState) { + return state.sources.pendingSelectedLocation; +} + +export function getPrettySource(state: OuterState, id: ?string) { + if (!id) { + return; + } + + const source = getSource(state, id); + if (!source) { + return; + } + + return getOriginalSourceByURL(state, getPrettySourceURL(source.url)); +} + +export function hasPrettySource(state: OuterState, id: string) { + return !!getPrettySource(state, id); +} + +export function getSourcesUrlsInSources( + state: OuterState, + url: ?URL +): string[] { + if (!url) { + return []; + } + + const plainUrl = getPlainUrl(url); + return getPlainUrls(state)[plainUrl] || []; +} + +export function getHasSiblingOfSameName(state: OuterState, source: ?Source) { + if (!source) { + return false; + } + + return getSourcesUrlsInSources(state, source.url).length > 1; +} + +const querySourceList: ReduceAllQuery< + SourceResource, + Array<Source> +> = makeReduceAllQuery(resourceAsSourceBase, sources => sources.slice()); + +export function getSources(state: OuterState): SourceResourceState { + return state.sources.sources; +} + +export function getSourcesEpoch(state: OuterState) { + return state.sources.epoch; +} + +export function getUrls(state: OuterState) { + return state.sources.urls; +} + +export function getPlainUrls(state: OuterState) { + return state.sources.plainUrls; +} + +export function getSourceList(state: OuterState): Source[] { + return querySourceList(getSources(state)); +} + +export function getDisplayedSourcesList( + state: OuterState & SourceActorOuterState & AppState +): Source[] { + return ((Object.values(getDisplayedSources(state)): any).flatMap( + Object.values + ): any); +} + +export function getExtensionNameBySourceUrl( + state: OuterState, + url: URL +): ?string { + const match = getSourceList(state).find( + source => source.url && source.url.startsWith(url) + ); + if (match && match.extensionName) { + return match.extensionName; + } +} + +export function getSourceCount(state: OuterState): number { + return getSourceList(state).length; +} + +export const getSelectedLocation: Selector<?SourceLocation> = createSelector( + getSourcesState, + sources => sources.selectedLocation +); + +export const getSelectedSource: Selector<?Source> = createSelector( + getSelectedLocation, + getSources, + ( + selectedLocation: ?SourceLocation, + sources: SourceResourceState + ): ?Source => { + if (!selectedLocation) { + return; + } + + return getSourceInSources(sources, selectedLocation.sourceId); + } +); + +type GSSWC = Selector<?SourceWithContent>; +export const getSelectedSourceWithContent: GSSWC = createSelector( + getSelectedLocation, + getSources, + ( + selectedLocation: ?SourceLocation, + sources: SourceResourceState + ): SourceWithContent | null => { + const source = + selectedLocation && + getSourceInSources(sources, selectedLocation.sourceId); + return source + ? getMappedResource(sources, source.id, resourceAsSourceWithContent) + : null; + } +); +export function getSourceWithContent( + state: OuterState, + id: SourceId +): SourceWithContent { + return getMappedResource( + state.sources.sources, + id, + resourceAsSourceWithContent + ); +} +export function getSourceContent( + state: OuterState, + id: SourceId +): SettledValue<SourceContent> | null { + const { content } = getResource(state.sources.sources, id); + return asSettled(content); +} + +export function getSelectedSourceId(state: OuterState) { + const source = getSelectedSource((state: any)); + return source?.id; +} + +export function getProjectDirectoryRoot(state: OuterState): string { + return state.sources.projectDirectoryRoot; +} + +export function getProjectDirectoryRootName(state: OuterState): string { + return state.sources.projectDirectoryRootName; +} + +const queryAllDisplayedSources: ShallowQuery< + SourceResource, + {| + sourcesWithUrls: IdsList, + projectDirectoryRoot: string, + chromeAndExtensionsEnabled: boolean, + debuggeeIsWebExtension: boolean, + threads: Array<Thread>, + |}, + Array<SourceId> +> = makeShallowQuery({ + filter: (_, { sourcesWithUrls }) => sourcesWithUrls, + map: makeMapWithArgs( + ( + resource, + ident, + { + projectDirectoryRoot, + chromeAndExtensionsEnabled, + debuggeeIsWebExtension, + threads, + } + ) => ({ + id: resource.id, + displayed: + underRoot(resource, projectDirectoryRoot, threads) && + (!resource.isExtension || + chromeAndExtensionsEnabled || + debuggeeIsWebExtension), + }) + ), + reduce: items => + items.reduce((acc, { id, displayed }) => { + if (displayed) { + acc.push(id); + } + return acc; + }, []), +}); + +function getAllDisplayedSources(state: OuterState & AppState): Array<SourceId> { + return queryAllDisplayedSources(state.sources.sources, { + sourcesWithUrls: state.sources.sourcesWithUrls, + projectDirectoryRoot: state.sources.projectDirectoryRoot, + chromeAndExtensionsEnabled: state.sources.chromeAndExtensionsEnabled, + debuggeeIsWebExtension: state.threads.isWebExtension, + threads: getAllThreads(state), + }); +} + +type GetDisplayedSourceIDsSelector = ( + OuterState & SourceActorOuterState +) => { [ThreadId]: Set<SourceId> }; +const getDisplayedSourceIDs: GetDisplayedSourceIDsSelector = createSelector( + getAllThreadsBySource, + getAllDisplayedSources, + (threadsBySource, displayedSources) => { + const sourceIDsByThread = {}; + + for (const sourceId of displayedSources) { + const threads = + threadsBySource[sourceId] || + threadsBySource[originalToGeneratedId(sourceId)] || + []; + + for (const thread of threads) { + if (!sourceIDsByThread[thread]) { + sourceIDsByThread[thread] = new Set(); + } + sourceIDsByThread[thread].add(sourceId); + } + } + return sourceIDsByThread; + } +); + +type GetDisplayedSourcesSelector = ( + OuterState & SourceActorOuterState +) => SourcesMapByThread; +export const getDisplayedSources: GetDisplayedSourcesSelector = createSelector( + state => state.sources.sources, + getDisplayedSourceIDs, + (sources, idsByThread) => { + const result = {}; + + for (const thread of Object.keys(idsByThread)) { + const entriesByNoQueryURL = Object.create(null); + + for (const id of idsByThread[thread]) { + if (!result[thread]) { + result[thread] = {}; + } + const source = getResource(sources, id); + + const entry = { + ...source, + displayURL: source.url, + }; + result[thread][id] = entry; + + const noQueryURL = stripQuery(entry.displayURL); + if (!entriesByNoQueryURL[noQueryURL]) { + entriesByNoQueryURL[noQueryURL] = []; + } + entriesByNoQueryURL[noQueryURL].push(entry); + } + + // If the URL does not compete with another without the query string, + // we exclude the query string when rendering the source URL to keep the + // UI more easily readable. + for (const noQueryURL in entriesByNoQueryURL) { + const entries = entriesByNoQueryURL[noQueryURL]; + if (entries.length === 1) { + entries[0].displayURL = noQueryURL; + } + } + } + + return result; + } +); + +export function getSourceActorsForSource( + state: OuterState & SourceActorOuterState, + id: SourceId +): Array<SourceActor> { + const actors = state.sources.actors[id]; + if (!actors) { + return []; + } + + return getSourceActors(state, actors); +} + +export function isSourceWithMap( + state: OuterState & SourceActorOuterState, + id: SourceId +): boolean { + return getSourceActorsForSource(state, id).some( + sourceActor => sourceActor.sourceMapURL + ); +} + +export function canPrettyPrintSource( + state: OuterState & SourceActorOuterState, + id: SourceId +): boolean { + const source: SourceWithContent = getSourceWithContent(state, id); + if ( + !source || + isPretty(source) || + isOriginalSource(source) || + (prefs.clientSourceMapsEnabled && isSourceWithMap(state, id)) + ) { + return false; + } + + const sourceContent = + source.content && isFulfilled(source.content) ? source.content.value : null; + + if (!sourceContent || !isJavaScript(source, sourceContent)) { + return false; + } + + return true; +} + +export function getBreakpointPositions( + state: OuterState +): BreakpointPositionsMap { + return state.sources.breakpointPositions; +} + +export function getBreakpointPositionsForSource( + state: OuterState, + sourceId: SourceId +): ?BreakpointPositions { + const positions = getBreakpointPositions(state); + return positions?.[sourceId]; +} + +export function hasBreakpointPositions( + state: OuterState, + sourceId: SourceId +): boolean { + return !!getBreakpointPositionsForSource(state, sourceId); +} + +export function getBreakpointPositionsForLine( + state: OuterState, + sourceId: SourceId, + line: number +): ?Array<BreakpointPosition> { + const positions = getBreakpointPositionsForSource(state, sourceId); + return positions?.[line]; +} + +export function hasBreakpointPositionsForLine( + state: OuterState, + sourceId: SourceId, + line: number +): boolean { + return !!getBreakpointPositionsForLine(state, sourceId, line); +} + +export function getBreakpointPositionsForLocation( + state: OuterState, + location: SourceLocation +): ?MappedLocation { + const { sourceId } = location; + const positions = getBreakpointPositionsForSource(state, sourceId); + return findPosition(positions, location); +} + +export function getBreakableLines( + state: OuterState & SourceActorOuterState, + sourceId: SourceId +): ?Array<number> { + if (!sourceId) { + return null; + } + const source = getSource(state, sourceId); + if (!source) { + return null; + } + + if (isOriginalSource(source)) { + return state.sources.breakableLines[sourceId]; + } + + // We pull generated file breakable lines directly from the source actors + // so that breakable lines can be added as new source actors on HTML loads. + return getBreakableLinesForSourceActors( + state.sourceActors, + state.sources.actors[sourceId] + ); +} + +export const getSelectedBreakableLines: Selector<Set<number>> = createSelector( + state => { + const sourceId = getSelectedSourceId(state); + return sourceId && getBreakableLines(state, sourceId); + }, + breakableLines => new Set(breakableLines || []) +); + +export function isSourceLoadingOrLoaded( + state: OuterState, + sourceId: SourceId +): boolean { + const { content } = getResource(state.sources.sources, sourceId); + return content !== null; +} + +export function getBlackBoxList(state: OuterState): string[] { + return state.sources.tabsBlackBoxed; +} + +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..2deaea665a --- /dev/null +++ b/devtools/client/debugger/src/reducers/tabs.js @@ -0,0 +1,308 @@ +/* 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/>. */ + +// @flow + +/** + * Tabs reducer + * @module reducers/tabs + */ + +import { createSelector } from "reselect"; +import { isOriginalId } from "devtools-source-map"; +import move from "lodash-move"; + +import { isSimilarTab, persistTabs } from "../utils/tabs"; +import { makeShallowQuery } from "../utils/resource"; +import { getPrettySourceURL } from "../utils/source"; + +import { + getSource, + getSpecificSourceByURL, + getSources, + resourceAsSourceBase, +} from "./sources"; + +import type { Source, SourceId, URL } from "../types"; +import type { Action } from "../actions/types"; +import type { Selector, State } from "./types"; +import type { SourceBase } from "./sources"; + +export type PersistedTab = {| + url: URL, + framework?: string | null, + isOriginal: boolean, + sourceId: SourceId, +|}; + +export type VisibleTab = {| ...Tab, sourceId: SourceId |}; + +export type Tab = PersistedTab | VisibleTab; + +export type TabList = Tab[]; + +export type TabsSources = $ReadOnlyArray<SourceBase>; + +export type TabsState = { + tabs: TabList, +}; + +export function initialTabState(): TabsState { + return { tabs: [] }; +} + +function resetTabState(state): TabsState { + const tabs = persistTabs(state.tabs); + return { tabs }; +} + +function update( + state: TabsState = initialTabState(), + action: Action +): TabsState { + switch (action.type) { + case "ADD_TAB": + case "UPDATE_TAB": + return updateTabList(state, action); + + 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 "ADD_SOURCE": + return addVisibleTabs(state, [action.source]); + + case "ADD_SOURCES": + return addVisibleTabs(state, action.sources); + + case "SET_SELECTED_LOCATION": { + return addSelectedSource(state, action.source); + } + + case "NAVIGATE": { + return resetTabState(state); + } + + default: + return state; + } +} + +/** + * Gets the next tab to select when a tab closes. Heuristics: + * 1. if the selected tab is available, it remains selected + * 2. if it is gone, the next available tab to the left should be active + * 3. if the first tab is active and closed, select the second tab + * + * @memberof reducers/tabs + * @static + */ +export function getNewSelectedSourceId(state: State, tabList: TabList): string { + const { selectedLocation } = state.sources; + const availableTabs = state.tabs.tabs; + if (!selectedLocation) { + return ""; + } + + const selectedTab = getSource(state, selectedLocation.sourceId); + if (!selectedTab) { + return ""; + } + + const matchingTab = availableTabs.find(tab => + isSimilarTab(tab, selectedTab.url, isOriginalId(selectedLocation.sourceId)) + ); + + if (matchingTab) { + const { sources } = state.sources; + if (!sources) { + return ""; + } + + const selectedSource = getSpecificSourceByURL( + state, + selectedTab.url, + selectedTab.isOriginal + ); + + if (selectedSource) { + return selectedSource.id; + } + + return ""; + } + + const tabUrls = tabList.map(tab => tab.url); + const leftNeighborIndex = Math.max(tabUrls.indexOf(selectedTab.url) - 1, 0); + const lastAvailbleTabIndex = availableTabs.length - 1; + const newSelectedTabIndex = Math.min(leftNeighborIndex, lastAvailbleTabIndex); + const availableTab = availableTabs[newSelectedTabIndex]; + + if (availableTab) { + const tabSource = getSpecificSourceByURL( + state, + availableTab.url, + availableTab.isOriginal + ); + + if (tabSource) { + return tabSource.id; + } + } + + return ""; +} + +function matchesSource(tab: VisibleTab, source: Source): boolean { + return tab.sourceId === source.id || matchesUrl(tab, source); +} + +function matchesUrl(tab: Tab, source: Source): boolean { + return tab.url === source.url && tab.isOriginal == isOriginalId(source.id); +} + +function addSelectedSource(state: TabsState, source: Source) { + if ( + state.tabs + .filter(({ sourceId }) => sourceId) + .map(({ sourceId }) => sourceId) + .includes(source.id) + ) { + return state; + } + + const isOriginal = isOriginalId(source.id); + return updateTabList(state, { + url: source.url, + isOriginal, + framework: null, + sourceId: source.id, + }); +} + +function addVisibleTabs(state: TabsState, sources) { + const tabCount = state.tabs.filter(({ sourceId }) => sourceId).length; + const tabs = state.tabs + .map(tab => { + const source = sources.find(src => matchesUrl(tab, src)); + if (!source) { + return tab; + } + return { ...tab, sourceId: source.id }; + }) + .filter(tab => tab.sourceId); + + if (tabs.length == tabCount) { + return state; + } + + return { tabs }; +} + +function removeSourceFromTabList(state: TabsState, { source }): TabsState { + const { tabs } = state; + const newTabs = tabs.filter(tab => !matchesSource(tab, source)); + return { tabs: newTabs }; +} + +function removeSourcesFromTabList(state: TabsState, { sources }) { + const { tabs } = state; + + const newTabs = sources.reduce( + (tabList, source) => tabList.filter(tab => !matchesSource(tab, source)), + tabs + ); + + return { tabs: newTabs }; +} + +/** + * Adds the new source to the tab list if it is not already there + * @memberof reducers/tabs + * @static + */ +function updateTabList( + state: TabsState, + { url, framework = null, sourceId, isOriginal = false } +): TabsState { + 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, + framework, + sourceId, + isOriginal, + }; + tabs = [newTab, ...tabs]; + } else if (framework) { + tabs[currentIndex].framework = framework; + } + + return { ...state, tabs }; +} + +function moveTabInList( + state: TabsState, + { url, tabIndex: newIndex } +): TabsState { + let { tabs } = state; + const currentIndex = tabs.findIndex(tab => tab.url == url); + tabs = move(tabs, currentIndex, newIndex); + return { tabs }; +} + +function moveTabInListBySourceId( + state: TabsState, + { sourceId, tabIndex: newIndex } +): TabsState { + let { tabs } = state; + const currentIndex = tabs.findIndex(tab => tab.sourceId == sourceId); + tabs = move(tabs, currentIndex, newIndex); + return { tabs }; +} + +// Selectors + +export const getTabs = (state: State): TabList => state.tabs.tabs; + +export const getSourceTabs: Selector<VisibleTab[]> = createSelector( + state => state.tabs, + ({ tabs }) => tabs.filter(tab => tab.sourceId) +); + +export const getSourcesForTabs: Selector<TabsSources> = state => { + const tabs = getSourceTabs(state); + const sources = getSources(state); + return querySourcesForTabs(sources, tabs); +}; + +const querySourcesForTabs = makeShallowQuery({ + filter: (_, tabs) => tabs.map(({ sourceId }) => sourceId), + map: resourceAsSourceBase, + reduce: items => items, +}); + +export function tabExists(state: State, sourceId: SourceId): boolean { + return !!getSourceTabs(state).find(tab => tab.sourceId == sourceId); +} + +export function hasPrettyTab(state: State, sourceUrl: URL): boolean { + const prettyUrl = getPrettySourceURL(sourceUrl); + return !!getSourceTabs(state).find(tab => tab.url === prettyUrl); +} + +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..0618badb90 --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/breakpoints.spec.js @@ -0,0 +1,81 @@ +/* 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/>. */ + +// @flow +declare var describe: (name: string, func: () => void) => void; +declare var it: (desc: string, func: () => void) => void; +declare var expect: (value: any) => any; + +import { + getBreakpointsForSource, + initialBreakpointsState, +} from "../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..4479708be4 --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/quick-open.spec.js @@ -0,0 +1,64 @@ +/* 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/>. */ + +// @flow +declare var describe: (name: string, func: () => void) => void; +declare var test: (desc: string, func: () => void) => void; +declare var expect: (value: any) => any; + +import update, { + initialQuickOpenState, + getQuickOpenEnabled, + getQuickOpenQuery, + getQuickOpenType, +} from "../quick-open"; +import { + setQuickOpenQuery, + openQuickOpen, + closeQuickOpen, +} from "../../actions/quick-open"; + +describe("quickOpen reducer", () => { + test("initial state", () => { + const state = update(undefined, ({ type: "FAKE" }: any)); + 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/sources.spec.js b/devtools/client/debugger/src/reducers/tests/sources.spec.js new file mode 100644 index 0000000000..d0436b6f76 --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/sources.spec.js @@ -0,0 +1,127 @@ +/* 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/>. */ + +// @flow +declare var describe: (name: string, func: () => void) => void; +declare var it: (desc: string, func: () => void) => void; +declare var expect: (value: any) => any; + +import update, { initialSourcesState, getDisplayedSources } from "../sources"; +import { initialThreadsState } from "../threads"; +import updateSourceActors from "../source-actors"; +import type { SourceActor } from "../../types"; +import { prefs } from "../../utils/prefs"; +import { makeMockSource, mockcx, makeMockState } from "../../utils/test-mockup"; +import { getResourceIds } from "../../utils/resource"; + +const extensionSource = { + ...makeMockSource(), + id: "extensionId", + url: "http://example.com/script.js", +}; + +const firefoxExtensionSource = { + ...makeMockSource(), + id: "firefoxExtension", + url: "moz-extension://id/js/content.js", + isExtension: true, +}; + +const chromeExtensionSource = { + ...makeMockSource(), + id: "chromeExtension", + isExtension: true, + url: "chrome-extension://id/js/content.js", +}; + +const mockedSources = [ + extensionSource, + firefoxExtensionSource, + chromeExtensionSource, +]; + +const mockSourceActors: Array<SourceActor> = ([ + { + id: "extensionId-actor", + actor: "extensionId-actor", + source: "extensionId", + thread: "foo", + }, + { + id: "firefoxExtension-actor", + actor: "firefoxExtension-actor", + source: "firefoxExtension", + thread: "foo", + }, + { + id: "chromeExtension-actor", + actor: "chromeExtension-actor", + source: "chromeExtension", + thread: "foo", + }, +]: any); + +describe("sources reducer", () => { + it("should work", () => { + let state = initialSourcesState(); + state = update(state, { + type: "ADD_SOURCE", + cx: mockcx, + source: makeMockSource(), + }); + expect(getResourceIds(state.sources)).toHaveLength(1); + }); +}); + +describe("sources selectors", () => { + it("should return all extensions when chrome preference enabled", () => { + prefs.chromeAndExtensionsEnabled = true; + let state = initialSourcesState(); + state = { + sources: update(state, { + type: "ADD_SOURCES", + cx: mockcx, + sources: mockedSources, + }), + sourceActors: undefined, + }; + const insertAction = { + type: "INSERT_SOURCE_ACTORS", + items: mockSourceActors, + }; + state = makeMockState({ + sources: update(state.sources, insertAction), + sourceActors: updateSourceActors(state.sourceActors, insertAction), + threads: initialThreadsState(), + }); + const threadSources = getDisplayedSources(state); + expect(Object.values(threadSources.foo)).toHaveLength(3); + }); + + it("should omit all extensions when chrome preference enabled", () => { + prefs.chromeAndExtensionsEnabled = false; + let state = initialSourcesState(); + state = { + sources: update(state, { + type: "ADD_SOURCES", + cx: mockcx, + sources: mockedSources, + }), + sourceActors: undefined, + }; + + const insertAction = { + type: "INSERT_SOURCE_ACTORS", + items: mockSourceActors, + }; + + state = makeMockState({ + sources: update(state.sources, insertAction), + sourceActors: updateSourceActors(state.sourceActors, insertAction), + threads: initialThreadsState(), + }); + const threadSources = getDisplayedSources(state); + expect(Object.values(threadSources.foo)).toHaveLength(1); + }); +}); 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..a335f0206b --- /dev/null +++ b/devtools/client/debugger/src/reducers/tests/ui.spec.js @@ -0,0 +1,35 @@ +/* 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/>. */ + +// @flow +declare var describe: (name: string, func: () => void) => void; +declare var it: (desc: string, func: () => void) => void; +declare var expect: (value: any) => any; + +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..81810595a0 --- /dev/null +++ b/devtools/client/debugger/src/reducers/threads.js @@ -0,0 +1,115 @@ +/* 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/>. */ + +// @flow + +/** + * Threads reducer + * @module reducers/threads + */ + +import { sortBy } from "lodash"; +import { createSelector } from "reselect"; + +import type { Selector, State } from "./types"; +import type { Thread, ThreadList, Worker } from "../types"; +import type { Action } from "../actions/types"; + +export type ThreadsState = { + threads: ThreadList, + traits: Object, + isWebExtension: boolean, +}; + +export function initialThreadsState(): ThreadsState { + return { + threads: [], + traits: {}, + isWebExtension: false, + }; +} + +export default function update( + state: ThreadsState = initialThreadsState(), + action: Action +): ThreadsState { + switch (action.type) { + case "CONNECT": + return { + ...state, + traits: action.traits, + isWebExtension: action.isWebExtension, + }; + case "INSERT_THREAD": + return { + ...state, + threads: [...state.threads, action.newThread], + }; + + case "REMOVE_THREAD": + const { oldThread } = action; + return { + ...state, + threads: state.threads.filter( + thread => oldThread.actor != 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; + } +} + +export const getWorkerCount = (state: State) => getThreads(state).length; + +export function getWorkerByThread(state: State, thread: string): ?Worker { + return getThreads(state).find(worker => worker.actor == thread); +} + +function isMainThread(thread: Thread) { + return thread.isTopLevel; +} + +export function getMainThread(state: State): ?Thread { + return state.threads.threads.find(isMainThread); +} + +export function getDebuggeeUrl(state: State): string { + return getMainThread(state)?.url || ""; +} + +export const getThreads: Selector<Thread[]> = createSelector( + state => state.threads.threads, + threads => threads.filter(thread => !isMainThread(thread)) +); + +export const getAllThreads: Selector<Thread[]> = createSelector( + getMainThread, + getThreads, + (mainThread, threads) => + [mainThread, ...sortBy(threads, thread => thread.name)].filter(Boolean) +); + +export function getThread(state: State, threadActor: string) { + return getAllThreads(state).find(thread => thread.actor === threadActor); +} + +// checks if a path begins with a thread actor +// e.g "server1.conn0.child1/workerTarget22/context1/dbg-workers.glitch.me" +export function startsWithThreadActor(state: State, path: string): ?string { + const threadActors = getAllThreads(state).map(t => t.actor); + const match = path.match(new RegExp(`(${threadActors.join("|")})\/(.*)`)); + return match?.[1]; +} diff --git a/devtools/client/debugger/src/reducers/types.js b/devtools/client/debugger/src/reducers/types.js new file mode 100644 index 0000000000..6dc25ea54f --- /dev/null +++ b/devtools/client/debugger/src/reducers/types.js @@ -0,0 +1,71 @@ +/* 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/>. */ + +/** + * Types reducer + * @module reducers/types + */ + +// @flow + +import type { ASTState } from "./ast"; +import type { BreakpointsState } from "./breakpoints"; +import type { ExpressionState } from "./expressions"; +import type { ThreadsState } from "./threads"; +import type { FileSearchState } from "./file-search"; +import type { PauseState } from "./pause"; +import type { PreviewState } from "./preview"; +import type { PendingBreakpointsState } from "../selectors"; +import type { ProjectTextSearchState } from "./project-text-search"; +import type { SourcesState } from "./sources"; +import type { SourceActorsState } from "./source-actors"; +import type { TabsState } from "./tabs"; +import type { UIState } from "./ui"; +import type { QuickOpenState } from "./quick-open"; +import type { SourceTreeState } from "./source-tree"; +import type { EventListenersState } from "./event-listeners"; +import type { ExceptionState } from "./exceptions"; +import type { URL } from "../types"; + +export type State = { + ast: ASTState, + breakpoints: BreakpointsState, + exceptions: ExceptionState, + expressions: ExpressionState, + eventListenerBreakpoints: EventListenersState, + threads: ThreadsState, + fileSearch: FileSearchState, + pause: PauseState, + preview: PreviewState, + pendingBreakpoints: PendingBreakpointsState, + projectTextSearch: ProjectTextSearchState, + sources: SourcesState, + sourceActors: SourceActorsState, + sourceTree: SourceTreeState, + tabs: TabsState, + ui: UIState, + quickOpen: QuickOpenState, +}; + +export type Selector<T> = State => T; + +export type PendingSelectedLocation = { + url: URL, + line?: number, + column?: number, +}; + +export type { + SourcesMap, + SourcesMapByThread, + SourceBase, + SourceResourceState, + SourceResource, +} from "./sources"; +export type { ActiveSearchType, OrientationType } from "./ui"; +export type { BreakpointsMap, XHRBreakpointsList } from "./breakpoints"; +export type { Command } from "./pause"; +export type { LoadedSymbols, Symbols } from "./ast"; +export type { Preview } from "./preview"; +export type { Tab, TabList, TabsSources } from "./tabs"; diff --git a/devtools/client/debugger/src/reducers/ui.js b/devtools/client/debugger/src/reducers/ui.js new file mode 100644 index 0000000000..9f71a6d2dd --- /dev/null +++ b/devtools/client/debugger/src/reducers/ui.js @@ -0,0 +1,234 @@ +/* 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/>. */ + +// @flow +/* eslint complexity: ["error", 35]*/ + +/** + * UI reducer + * @module reducers/ui + */ + +import { prefs, features } from "../utils/prefs"; + +import type { Source, SourceLocation, Range } from "../types"; + +import type { Action, panelPositionType } from "../actions/types"; + +export type ActiveSearchType = "project" | "file"; + +export type OrientationType = "horizontal" | "vertical"; + +export type SelectedPrimaryPaneTabType = "sources" | "outline"; + +export type UIState = { + selectedPrimaryPaneTab: SelectedPrimaryPaneTabType, + activeSearch: ?ActiveSearchType, + shownSource: ?Source, + startPanelCollapsed: boolean, + endPanelCollapsed: boolean, + frameworkGroupingOn: boolean, + orientation: OrientationType, + viewport: ?Range, + cursorPosition: ?SourceLocation, + highlightedLineRange?: { + start?: number, + end?: number, + sourceId?: number, + }, + conditionalPanelLocation: null | SourceLocation, + isLogPoint: boolean, + inlinePreviewEnabled: boolean, + editorWrappingEnabled: boolean, + sourceMapsEnabled: boolean, + javascriptEnabled: boolean, +}; + +export const initialUIState = (): UIState => ({ + selectedPrimaryPaneTab: "sources", + activeSearch: null, + shownSource: 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, + sourceMapsEnabled: prefs.clientSourceMapsEnabled, + javascriptEnabled: true, +}); + +function update(state: UIState = initialUIState(), action: Action): UIState { + 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, sourceMapsEnabled: action.value }; + } + + case "SET_ORIENTATION": { + return { ...state, orientation: action.orientation }; + } + + case "SHOW_SOURCE": { + return { ...state, shownSource: action.source }; + } + + 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 = {}; + + if (start && end && sourceId) { + lineRange = { start, end, sourceId }; + } + + return { ...state, highlightedLineRange: lineRange }; + + case "CLOSE_QUICK_OPEN": + case "CLEAR_HIGHLIGHT_LINES": + return { ...state, highlightedLineRange: {} }; + + 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; + } + } +} + +// NOTE: we'd like to have the app state fully typed +// https://github.com/firefox-devtools/debugger/blob/master/src/reducers/sources.js#L179-L185 +type OuterState = { ui: UIState }; + +export function getSelectedPrimaryPaneTab( + state: OuterState +): SelectedPrimaryPaneTabType { + return state.ui.selectedPrimaryPaneTab; +} + +export function getActiveSearch(state: OuterState): ?ActiveSearchType { + return state.ui.activeSearch; +} + +export function getFrameworkGroupingState(state: OuterState): boolean { + return state.ui.frameworkGroupingOn; +} + +export function getShownSource(state: OuterState): ?Source { + return state.ui.shownSource; +} + +export function getPaneCollapse( + state: OuterState, + position: panelPositionType +): boolean { + if (position == "start") { + return state.ui.startPanelCollapsed; + } + + return state.ui.endPanelCollapsed; +} + +export function getHighlightedLineRange(state: OuterState) { + return state.ui.highlightedLineRange; +} + +export function getConditionalPanelLocation( + state: OuterState +): null | SourceLocation { + return state.ui.conditionalPanelLocation; +} + +export function getLogPointStatus(state: OuterState): boolean { + return state.ui.isLogPoint; +} + +export function getOrientation(state: OuterState): OrientationType { + return state.ui.orientation; +} + +export function getViewport(state: OuterState) { + return state.ui.viewport; +} + +export function getCursorPosition(state: OuterState) { + return state.ui.cursorPosition; +} + +export function getInlinePreview(state: OuterState) { + return state.ui.inlinePreviewEnabled; +} + +export function getEditorWrapping(state: OuterState) { + return state.ui.editorWrappingEnabled; +} + +export default update; |