diff options
Diffstat (limited to 'devtools/client/debugger/src/reducers/sources-tree.js')
-rw-r--r-- | devtools/client/debugger/src/reducers/sources-tree.js | 585 |
1 files changed, 585 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/reducers/sources-tree.js b/devtools/client/debugger/src/reducers/sources-tree.js new file mode 100644 index 0000000000..3658b0b059 --- /dev/null +++ b/devtools/client/debugger/src/reducers/sources-tree.js @@ -0,0 +1,585 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +/** + * Sources tree reducer + * + * A Source Tree is composed of: + * + * - Thread Items To designate targets/threads. + * These are the roots of the Tree if no project directory is selected. + * + * - Group Items To designates the different domains used in the website. + * These are direct children of threads and may contain directory or source items. + * + * - Directory Items To designate all the folders. + * Note that each every folder has an items. The Source Tree React component is doing the magic to coallesce folders made of only one sub folder. + * + * - Source Items To designate sources. + * They are the leaves of the Tree. (we should not have empty directories.) + */ + +const IGNORED_URLS = ["debugger eval code", "XStringBundle"]; +const IGNORED_EXTENSIONS = ["css", "svg", "png"]; +import { isPretty } from "../utils/source"; +import { prefs } from "../utils/prefs"; + +export function initialSourcesTreeState() { + return { + // List of all Thread Tree Items. + // All other item types are children of these and aren't store in + // the reducer as top level objects. + threadItems: [], + + // List of `uniquePath` of Tree Items that are expanded. + // This should be all but Source Tree Items. + expanded: new Set(), + + // Reference to the currently focused Tree Item. + // It can be any type of Tree Item. + focusedItem: null, + + // Project root set from the Source Tree. + // This focuses the source tree on a subset of sources. + // This is a `uniquePath`, where ${thread} is replaced by "top-level" + // when we picked an item from the main thread. This allows to preserve + // the root selection on page reload. + projectDirectoryRoot: prefs.projectDirectoryRoot, + + // The name is displayed in Source Tree header + projectDirectoryRootName: prefs.projectDirectoryRootName, + + // Reports if the top level target is a web extension. + // If so, we should display all web extension sources. + isWebExtension: false, + + /** + * Boolean, to be set to true in order to display WebExtension's content scripts + * that are applied to the current page we are debugging. + * + * Covered by: browser_dbg-content-script-sources.js + * Bound to: devtools.chrome.enabled + * + */ + chromeAndExtensionsEnabled: prefs.chromeAndExtensionsEnabled, + }; +} + +// eslint-disable-next-line complexity +export default function update(state = initialSourcesTreeState(), action) { + switch (action.type) { + case "ADD_ORIGINAL_SOURCES": { + const { generatedSourceActor } = action; + const validOriginalSources = action.originalSources.filter(source => + isSourceVisibleInSourceTree( + source, + state.chromeAndExtensionsEnabled, + state.isWebExtension + ) + ); + if (!validOriginalSources.length) { + return state; + } + let changed = false; + // Fork the array only once for all the sources + const threadItems = [...state.threadItems]; + for (const source of validOriginalSources) { + changed |= addSource(threadItems, source, generatedSourceActor); + } + if (changed) { + return { + ...state, + threadItems, + }; + } + return state; + } + case "INSERT_SOURCE_ACTORS": { + // With this action, we only cover generated sources. + // (i.e. we need something else for sourcemapped/original sources) + // But we do want to process source actors in order to be able to display + // distinct Source Tree Items for sources with the same URL loaded in distinct thread. + // (And may be also later be able to highlight the many sources with the same URL loaded in a given thread) + const newSourceActors = action.sourceActors.filter(sourceActor => + isSourceVisibleInSourceTree( + sourceActor.sourceObject, + state.chromeAndExtensionsEnabled, + state.isWebExtension + ) + ); + if (!newSourceActors.length) { + return state; + } + let changed = false; + // Fork the array only once for all the sources + const threadItems = [...state.threadItems]; + for (const sourceActor of newSourceActors) { + // We mostly wanted to read the thread of the SourceActor, + // most of the interesting attributes are on the Source Object. + changed |= addSource( + threadItems, + sourceActor.sourceObject, + sourceActor + ); + } + if (changed) { + return { + ...state, + threadItems, + }; + } + return state; + } + + case "INSERT_THREAD": + state = { ...state }; + addThread(state, action.newThread); + return state; + + case "REMOVE_THREAD": { + const { threadActorID } = action; + const index = state.threadItems.findIndex(item => { + return item.threadActorID == threadActorID; + }); + + if (index == -1) { + return state; + } + + // Also clear focusedItem and expanded items related + // to this thread. These fields store uniquePath which starts + // with the thread actor ID. + let { focusedItem } = state; + if (focusedItem && focusedItem.uniquePath.startsWith(threadActorID)) { + focusedItem = null; + } + const expanded = new Set(); + for (const path of state.expanded) { + if (!path.startsWith(threadActorID)) { + expanded.add(path); + } + } + + const threadItems = [...state.threadItems]; + threadItems.splice(index, 1); + return { + ...state, + threadItems, + focusedItem, + expanded, + }; + } + + case "SET_EXPANDED_STATE": + return updateExpanded(state, action); + + case "SET_FOCUSED_SOURCE_ITEM": + return { ...state, focusedItem: action.item }; + + case "SET_PROJECT_DIRECTORY_ROOT": + const { url, name } = action; + return updateProjectDirectoryRoot(state, url, name); + + case "BLACKBOX_WHOLE_SOURCES": + case "BLACKBOX_SOURCE_RANGES": { + const sources = action.sources || [action.source]; + return updateBlackbox(state, sources, true); + } + + case "UNBLACKBOX_WHOLE_SOURCES": { + const sources = action.sources || [action.source]; + return updateBlackbox(state, sources, false); + } + } + return state; +} + +function addThread(state, thread) { + const threadActorID = thread.actor; + // When processing the top level target, + // see if we are debugging an extension. + if (thread.isTopLevel) { + state.isWebExtension = thread.isWebExtension; + } + let threadItem = state.threadItems.find(item => { + return item.threadActorID == threadActorID; + }); + if (!threadItem) { + threadItem = createThreadTreeItem(threadActorID); + state.threadItems = [...state.threadItems, threadItem]; + } else { + // We force updating the list to trigger mapStateToProps + // as the getSourcesTreeSources selector is awaiting for the `thread` attribute + // which we will set here. + state.threadItems = [...state.threadItems]; + } + // Inject the reducer thread object on Thread Tree Items + // (this is handy shortcut to have access to from React components) + // (this is also used by sortThreadItems to sort the thread as a Tree in the Browser Toolbox) + threadItem.thread = thread; + state.threadItems.sort(sortThreadItems); +} + +function updateBlackbox(state, sources, shouldBlackBox) { + const threadItems = [...state.threadItems]; + + for (const source of sources) { + for (const threadItem of threadItems) { + const sourceTreeItem = findSourceInThreadItem(source, threadItem); + if (sourceTreeItem) { + sourceTreeItem.isBlackBoxed = shouldBlackBox; + } + } + } + return { ...state, threadItems }; +} + +function updateExpanded(state, action) { + // We receive the full list of all expanded items + // (not only the one added/removed) + return { + ...state, + expanded: new Set(action.expanded), + }; +} + +/** + * Update the project directory root + */ +function updateProjectDirectoryRoot(state, root, name) { + // Only persists root within the top level target. + // Otherwise the thread actor ID will change on page reload and we won't match anything + if (!root || root.startsWith("top-level")) { + prefs.projectDirectoryRoot = root; + prefs.projectDirectoryRootName = name; + } + + return { + ...state, + projectDirectoryRoot: root, + projectDirectoryRootName: name, + }; +} + +function isSourceVisibleInSourceTree( + source, + chromeAndExtensionsEnabled, + debuggeeIsWebExtension +) { + return ( + !!source.url && + !IGNORED_EXTENSIONS.includes(source.displayURL.fileExtension) && + !IGNORED_URLS.includes(source.url) && + !isPretty(source) && + // Only accept web extension sources when the chrome pref is enabled (to allows showing content scripts), + // or when we are debugging an extension + (!source.isExtension || + chromeAndExtensionsEnabled || + debuggeeIsWebExtension) + ); +} + +function addSource(threadItems, source, sourceActor) { + // Ensure creating or fetching the related Thread Item + let threadItem = threadItems.find(item => { + return item.threadActorID == sourceActor.thread; + }); + if (!threadItem) { + threadItem = createThreadTreeItem(sourceActor.thread); + // Note that threadItems will be cloned once to force a state update + // by the callsite of `addSourceActor` + threadItems.push(threadItem); + threadItems.sort(sortThreadItems); + } + + // Then ensure creating or fetching the related Group Item + // About `source` versus `sourceActor`: + const { displayURL } = source; + const { group } = displayURL; + + let groupItem = threadItem.children.find(item => { + return item.groupName == group; + }); + + if (!groupItem) { + groupItem = createGroupTreeItem(group, threadItem, source); + // Copy children in order to force updating react in case we picked + // this directory as a project root + threadItem.children = [...threadItem.children, groupItem]; + // As we add a new item, re-sort the groups in this thread + threadItem.children.sort(sortItems); + } + + // Then ensure creating or fetching all possibly nested Directory Item(s) + const { path } = displayURL; + const parentPath = path.substring(0, path.lastIndexOf("/")); + const directoryItem = addOrGetParentDirectory(groupItem, parentPath); + + // Check if a previous source actor registered this source. + // It happens if we load the same url multiple times, or, + // for inline sources (=HTML pages with inline scripts). + const existing = directoryItem.children.find(item => { + return item.type == "source" && item.source == source; + }); + if (existing) { + return false; + } + + // Finaly, create the Source Item and register it in its parent Directory Item + const sourceItem = createSourceTreeItem(source, sourceActor, directoryItem); + // Copy children in order to force updating react in case we picked + // this directory as a project root + directoryItem.children = [...directoryItem.children, sourceItem]; + // Re-sort the items in this directory + directoryItem.children.sort(sortItems); + + return true; +} +/** + * Find all the source items in tree + * @param {Object} item - Current item node in the tree + * @param {Function} callback + */ +function findSourceInThreadItem(source, threadItem) { + const { displayURL } = source; + const { group, path } = displayURL; + const groupItem = threadItem.children.find(item => { + return item.groupName == group; + }); + if (!groupItem) return null; + + const parentPath = path.substring(0, path.lastIndexOf("/")); + const directoryItem = groupItem._allGroupDirectoryItems.find(item => { + return item.type == "directory" && item.path == parentPath; + }); + if (!directoryItem) return null; + + return directoryItem.children.find(item => { + return item.type == "source" && item.source == source; + }); +} + +function sortItems(a, b) { + if (a.type == "directory" && b.type == "source") { + return -1; + } else if (b.type == "directory" && a.type == "source") { + return 1; + } else if (a.type == "directory" && b.type == "directory") { + return a.path.localeCompare(b.path); + } else if (a.type == "source" && b.type == "source") { + return a.source.displayURL.filename.localeCompare( + b.source.displayURL.filename + ); + } + return 0; +} + +function sortThreadItems(a, b) { + // Jest tests aren't emitting the necessary actions to populate the thread attributes. + // Ignore sorting for them. + if (!a.thread || !b.thread) { + return 0; + } + + // Top level target is always listed first + if (a.thread.isTopLevel) { + return -1; + } else if (b.thread.isTopLevel) { + return 1; + } + + // Process targets should come next and after that frame targets + if (a.thread.targetType == "process" && b.thread.targetType == "frame") { + return -1; + } else if ( + a.thread.targetType == "frame" && + b.thread.targetType == "process" + ) { + return 1; + } + + // And we display the worker targets last. + if ( + a.thread.targetType.endsWith("worker") && + !b.thread.targetType.endsWith("worker") + ) { + return 1; + } else if ( + !a.thread.targetType.endsWith("worker") && + b.thread.targetType.endsWith("worker") + ) { + return -1; + } + + // Order the process targets by their process ids + if (a.thread.processID > b.thread.processID) { + return 1; + } else if (a.thread.processID < b.thread.processID) { + return 0; + } + + // Order the frame targets and the worker targets by their target name + if (a.thread.targetType == "frame" && b.thread.targetType == "frame") { + return a.thread.name.localeCompare(b.thread.name); + } else if ( + a.thread.targetType.endsWith("worker") && + b.thread.targetType.endsWith("worker") + ) { + return a.thread.name.localeCompare(b.thread.name); + } + + return 0; +} + +/** + * For a given URL's path, in the given group (i.e. typically a given scheme+domain), + * return the already existing parent directory item, or create it if it doesn't exists. + * Note that it will create all ancestors up to the Group Item. + * + * @param {GroupItem} groupItem + * The Group Item for the group where the path should be displayed. + * @param {String} path + * Path of the directory for which we want a Directory Item. + * @return {GroupItem|DirectoryItem} + * The parent Item where this path should be inserted. + * Note that it may be displayed right under the Group Item if the path is empty. + */ +function addOrGetParentDirectory(groupItem, path) { + // We reached the top of the Tree, so return the Group Item. + if (!path) { + return groupItem; + } + // See if we have this directory already registered by a previous source + const existing = groupItem._allGroupDirectoryItems.find(item => { + return item.type == "directory" && item.path == path; + }); + if (existing) { + return existing; + } + // It doesn't exists, so we will create a new Directory Item. + // But now, lookup recursively for the parent Item for this to-be-create Directory Item + const parentPath = path.substring(0, path.lastIndexOf("/")); + const parentDirectory = addOrGetParentDirectory(groupItem, parentPath); + + // We can now create the new Directory Item and register it in its parent Item. + const directory = createDirectoryTreeItem(path, parentDirectory); + // Copy children in order to force updating react in case we picked + // this directory as a project root + parentDirectory.children = [...parentDirectory.children, directory]; + // Re-sort the items in this directory + parentDirectory.children.sort(sortItems); + + // Also maintain the list of all group items, + // Which helps speedup querying for existing items. + groupItem._allGroupDirectoryItems.push(directory); + + return directory; +} + +/** + * Definition of all Items of a SourceTree + */ +// Highlights the attributes that all Source Tree Item should expose +function createBaseTreeItem({ type, parent, uniquePath, children }) { + return { + // Can be: thread, group, directory or source + type, + // Reference to the parent TreeItem + parent, + // This attribute is used for two things: + // * as a string key identified in the React Tree + // * for project root in order to find the root in the tree + // It is of the form: + // `${ThreadActorID}|${GroupName}|${DirectoryPath}|${SourceID}` + // Group and path/ID are optional. + // `|` is used as separator in order to avoid having this character being used in name/path/IDs. + uniquePath, + // Array of TreeItem, children of this item. + // Will be null for Source Tree Item + children, + }; +} +function createThreadTreeItem(thread) { + return { + ...createBaseTreeItem({ + type: "thread", + // Each thread is considered as an independant root item + parent: null, + uniquePath: thread, + // Children of threads will only be Group Items + children: [], + }), + + // This will be used to set the reducer's thread object. + // This threadActorID attribute isn't meant to be used outside of this selector. + // A `thread` attribute will be exposed from INSERT_THREAD action. + threadActorID: thread, + }; +} +function createGroupTreeItem(groupName, parent, source) { + return { + ...createBaseTreeItem({ + type: "group", + parent, + uniquePath: `${parent.uniquePath}|${groupName}`, + // Children of Group can be Directory and Source items + children: [], + }), + + groupName, + + // When a content script appear in a web page, + // a dedicated group is created for it and should + // be having an extension icon. + isForExtensionSource: source.isExtension, + + // List of all nested items for this group. + // This helps find any nested directory in a given group without having to walk the tree. + // This is meant to be used only by the reducer. + _allGroupDirectoryItems: [], + }; +} +function createDirectoryTreeItem(path, parent) { + // If the parent is a group we want to use '/' as separator + const pathSeparator = parent.type == "directory" ? "/" : "|"; + + // `path` will be the absolute path from the group/domain, + // while we want to append only the directory name in uniquePath. + // Also, we need to strip '/' prefix. + const relativePath = + parent.type == "directory" + ? path.replace(parent.path, "").replace(/^\//, "") + : path; + + return { + ...createBaseTreeItem({ + type: "directory", + parent, + uniquePath: `${parent.uniquePath}${pathSeparator}${relativePath}`, + // Children can be nested Directory or Source items + children: [], + }), + + // This is the absolute path from the "group" + // i.e. the path from the domain name + // For http://mozilla.org/foo/bar folder, + // path will be: + // foo/bar + path, + }; +} +function createSourceTreeItem(source, sourceActor, parent) { + return { + ...createBaseTreeItem({ + type: "source", + parent, + uniquePath: `${parent.uniquePath}|${source.id}`, + // Sources items are leaves of the SourceTree + children: null, + }), + + source, + sourceActor, + }; +} |