diff options
Diffstat (limited to 'devtools/client/debugger/src/components/PrimaryPanes')
11 files changed, 2337 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Outline.css b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css new file mode 100644 index 0000000000..6eb890f2d8 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css @@ -0,0 +1,158 @@ +/* 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-panel .outline { + display: flex; + height: 100%; +} + +.source-outline-panel { + flex: 1; + overflow: auto; +} + +.outline { + overflow-y: hidden; +} + +.outline > div { + width: 100%; + position: relative; +} + +.outline-pane-info { + padding: 0.5em; + width: 100%; + font-style: italic; + text-align: center; + user-select: none; + font-size: 12px; + overflow: hidden; +} + +.outline-list { + margin: 0; + padding: 4px 0; + position: absolute; + top: 25px; + bottom: 25px; + left: 0; + right: 0; + list-style-type: none; + overflow: auto; +} + +.outline-list__class-list { + margin: 0; + padding: 0; + list-style: none; +} + +.outline-list__class-list > .outline-list__element { + padding-inline-start: 2rem; +} + +.outline-list__class-list .function-signature .function-name { + color: var(--theme-highlight-green); +} + +.outline-list .function-signature .paren { + color: inherit; +} + +.outline-list__class h2 { + font-weight: normal; + font-size: 1em; + padding: 3px 0; + padding-inline-start: 10px; + color: var(--blue-55); + margin: 0; +} + +.outline-list__class:not(:first-child) h2 { + margin-top: 12px; +} + +.outline-list h2:hover { + background: var(--theme-toolbar-background-hover); +} + +.theme-dark .outline-list h2 { + color: var(--theme-highlight-blue); +} + +.outline-list h2 .keyword { + color: var(--theme-highlight-red); +} + +.outline-list__class h2.focused { + background: var(--theme-selection-background); +} + +.outline-list__class h2.focused, +.outline-list__class h2.focused .keyword { + color: var(--theme-selection-color); +} + +.outline-list__element { + padding: 3px 10px 3px 10px; + cursor: default; + white-space: nowrap; +} + +.outline-list > .outline-list__element { + padding-inline-start: 1rem; +} + +.outline-list__element-icon { + padding-inline-end: 0.4rem; +} + +.outline-list__element:hover { + background: var(--theme-toolbar-background-hover); +} + +.outline-list__element.focused { + background: var(--theme-selection-background); +} + +.outline-list__element.focused .outline-list__element-icon, +.outline-list__element.focused .function-signature * { + color: var(--theme-selection-color); +} + +.outline-footer { + display: flex; + box-sizing: border-box; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 25px; + background: var(--theme-body-background); + border-top: 1px solid var(--theme-splitter-color); + opacity: 1; + z-index: 1; + user-select: none; +} + +.outline-footer button { + color: var(--theme-body-color); + + /* Since the buttons are on the bottom left edge, we need to adjust the outline so + it's not off-screen */ + outline-offset: -2px; + + &.active { + background: var(--theme-selection-background); + color: var(--theme-selection-color); + + &:focus-visible { + /* When the button is active, it has a similar background color than the outline color + so we put the focus box-shadow inside the element to make the focus indicator visible */ + box-shadow: inset var(--theme-outline-box-shadow); + } + } +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Outline.js b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js new file mode 100644 index 0000000000..79ebf7a38e --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js @@ -0,0 +1,388 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "devtools/client/shared/vendor/react"; +import { + div, + ul, + li, + span, + h2, + button, +} from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import { containsPosition, positionAfter } from "../../utils/ast"; +import { createLocation } from "../../utils/location"; + +import actions from "../../actions/index"; +import { + getSelectedLocation, + getCursorPosition, + getSelectedSourceTextContent, +} from "../../selectors/index"; + +import OutlineFilter from "./OutlineFilter"; +import PreviewFunction from "../shared/PreviewFunction"; + +import { isFulfilled } from "../../utils/async-value"; + +const classnames = require("resource://devtools/client/shared/classnames.js"); +const { + score: fuzzaldrinScore, +} = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.js"); + +// Set higher to make the fuzzaldrin filter more specific +const FUZZALDRIN_FILTER_THRESHOLD = 15000; + +/** + * Check whether the name argument matches the fuzzy filter argument + */ +const filterOutlineItem = (name, filter) => { + if (!filter) { + return true; + } + + if (filter.length === 1) { + // when filter is a single char just check if it starts with the char + return filter.toLowerCase() === name.toLowerCase()[0]; + } + return fuzzaldrinScore(name, filter) > FUZZALDRIN_FILTER_THRESHOLD; +}; + +// Checks if an element is visible inside its parent element +function isVisible(element, parent) { + const parentRect = parent.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + const parentTop = parentRect.top; + const parentBottom = parentRect.bottom; + const elTop = elementRect.top; + const elBottom = elementRect.bottom; + + return parentTop < elTop && parentBottom > elBottom; +} + +export class Outline extends Component { + constructor(props) { + super(props); + this.focusedElRef = null; + this.state = { filter: "", focusedItem: null, symbols: null }; + } + + static get propTypes() { + return { + alphabetizeOutline: PropTypes.bool.isRequired, + cursorPosition: PropTypes.object, + flashLineRange: PropTypes.func.isRequired, + onAlphabetizeClick: PropTypes.func.isRequired, + selectLocation: PropTypes.func.isRequired, + selectedLocation: PropTypes.object.isRequired, + getFunctionSymbols: PropTypes.func.isRequired, + getClassSymbols: PropTypes.func.isRequired, + canFetchSymbols: PropTypes.bool, + }; + } + + componentDidMount() { + if (!this.props.canFetchSymbols) { + return; + } + this.getClassAndFunctionSymbols(); + } + + componentDidUpdate(prevProps) { + const { cursorPosition, selectedLocation, canFetchSymbols } = this.props; + if (cursorPosition && cursorPosition !== prevProps.cursorPosition) { + this.setFocus(cursorPosition); + } + + if ( + this.focusedElRef && + !isVisible(this.focusedElRef, this.refs.outlineList) + ) { + this.focusedElRef.scrollIntoView({ block: "center" }); + } + + // Lets make sure the source text has been loaded and is different + if (canFetchSymbols && prevProps.selectedLocation !== selectedLocation) { + this.getClassAndFunctionSymbols(); + } + } + + async getClassAndFunctionSymbols() { + const { selectedLocation, getFunctionSymbols, getClassSymbols } = + this.props; + + const functions = await getFunctionSymbols(selectedLocation); + const classes = await getClassSymbols(selectedLocation); + + this.setState({ symbols: { functions, classes } }); + } + + async setFocus(cursorPosition) { + const { symbols } = this.state; + + let classes = []; + let functions = []; + + if (symbols) { + ({ classes, functions } = symbols); + } + + // Find items that enclose the selected location + const enclosedItems = [...classes, ...functions].filter( + ({ name, location }) => containsPosition(location, cursorPosition) + ); + + if (!enclosedItems.length) { + this.setState({ focusedItem: null }); + return; + } + + // Find the closest item to the selected location to focus + const closestItem = enclosedItems.reduce((item, closest) => + positionAfter(item.location, closest.location) ? item : closest + ); + + this.setState({ focusedItem: closestItem }); + } + + selectItem(selectedItem) { + const { selectedLocation, selectLocation } = this.props; + if (!selectedLocation || !selectedItem) { + return; + } + + selectLocation( + createLocation({ + source: selectedLocation.source, + line: selectedItem.location.start.line, + column: selectedItem.location.start.column, + }) + ); + + this.setState({ focusedItem: selectedItem }); + } + + onContextMenu(event, func) { + event.stopPropagation(); + event.preventDefault(); + + const { symbols } = this.state; + this.props.showOutlineContextMenu(event, func, symbols); + } + + updateFilter = filter => { + this.setState({ filter: filter.trim() }); + }; + + renderPlaceholder() { + const placeholderMessage = this.props.selectedLocation + ? L10N.getStr("outline.noFunctions") + : L10N.getStr("outline.noFileSelected"); + return div( + { + className: "outline-pane-info", + }, + placeholderMessage + ); + } + + renderLoading() { + return div( + { + className: "outline-pane-info", + }, + L10N.getStr("loadingText") + ); + } + + renderFunction(func) { + const { focusedItem } = this.state; + const { name, location, parameterNames } = func; + const isFocused = focusedItem === func; + return li( + { + key: `${name}:${location.start.line}:${location.start.column}`, + className: classnames("outline-list__element", { + focused: isFocused, + }), + ref: el => { + if (isFocused) { + this.focusedElRef = el; + } + }, + onClick: () => this.selectItem(func), + onContextMenu: e => this.onContextMenu(e, func), + }, + span( + { + className: "outline-list__element-icon", + }, + "λ" + ), + React.createElement(PreviewFunction, { + func: { + name, + parameterNames, + }, + }) + ); + } + + renderClassHeader(klass) { + return div( + null, + span( + { + className: "keyword", + }, + "class" + ), + " ", + klass + ); + } + + renderClassFunctions(klass, functions) { + const { symbols } = this.state; + + if (!symbols || klass == null || !functions.length) { + return null; + } + + const { focusedItem } = this.state; + const classFunc = functions.find(func => func.name === klass); + const classFunctions = functions.filter(func => func.klass === klass); + const classInfo = symbols.classes.find(c => c.name === klass); + + const item = classFunc || classInfo; + const isFocused = focusedItem === item; + + return li( + { + className: "outline-list__class", + ref: el => { + if (isFocused) { + this.focusedElRef = el; + } + }, + key: klass, + }, + h2( + { + className: classnames({ + focused: isFocused, + }), + onClick: () => this.selectItem(item), + }, + classFunc + ? this.renderFunction(classFunc) + : this.renderClassHeader(klass) + ), + ul( + { + className: "outline-list__class-list", + }, + classFunctions.map(func => this.renderFunction(func)) + ) + ); + } + + renderFunctions(functions) { + const { filter } = this.state; + let classes = [...new Set(functions.map(({ klass }) => klass))]; + const namedFunctions = functions.filter( + ({ name, klass }) => + filterOutlineItem(name, filter) && !klass && !classes.includes(name) + ); + const classFunctions = functions.filter( + ({ name, klass }) => filterOutlineItem(name, filter) && !!klass + ); + + if (this.props.alphabetizeOutline) { + const sortByName = (a, b) => (a.name < b.name ? -1 : 1); + namedFunctions.sort(sortByName); + classes = classes.sort(); + classFunctions.sort(sortByName); + } + return ul( + { + ref: "outlineList", + className: "outline-list devtools-monospace", + dir: "ltr", + }, + namedFunctions.map(func => this.renderFunction(func)), + classes.map(klass => this.renderClassFunctions(klass, classFunctions)) + ); + } + + renderFooter() { + return div( + { + className: "outline-footer", + }, + button( + { + onClick: this.props.onAlphabetizeClick, + className: this.props.alphabetizeOutline ? "active" : "", + }, + L10N.getStr("outline.sortLabel") + ) + ); + } + + render() { + const { selectedLocation } = this.props; + const { filter, symbols } = this.state; + + if (!selectedLocation) { + return this.renderPlaceholder(); + } + + if (!symbols) { + return this.renderLoading(); + } + + const { functions } = symbols; + + if (functions.length === 0) { + return this.renderPlaceholder(); + } + + return div( + { + className: "outline", + }, + div( + null, + React.createElement(OutlineFilter, { + filter: filter, + updateFilter: this.updateFilter, + }), + this.renderFunctions(functions), + this.renderFooter() + ) + ); + } +} + +const mapStateToProps = state => { + const selectedSourceTextContent = getSelectedSourceTextContent(state); + return { + selectedLocation: getSelectedLocation(state), + canFetchSymbols: + selectedSourceTextContent && isFulfilled(selectedSourceTextContent), + cursorPosition: getCursorPosition(state), + }; +}; + +export default connect(mapStateToProps, { + selectLocation: actions.selectLocation, + showOutlineContextMenu: actions.showOutlineContextMenu, + getFunctionSymbols: actions.getFunctionSymbols, + getClassSymbols: actions.getClassSymbols, +})(Outline); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css new file mode 100644 index 0000000000..787e527490 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css @@ -0,0 +1,23 @@ +/* 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/. */ + +.outline-filter { + border: 1px solid var(--theme-splitter-color); + border-top: 0px; +} + +.outline-filter-input { + height: 24px; + width: 100%; + background-color: var(--theme-sidebar-background); + color: var(--theme-body-color); + font-size: inherit; + user-select: text; + outline-offset: -2px; +} + +.outline-filter-input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js new file mode 100644 index 0000000000..12f6fed2b7 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { Component } from "devtools/client/shared/vendor/react"; +import { + form, + div, + input, +} from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +const classnames = require("resource://devtools/client/shared/classnames.js"); + +export default class OutlineFilter extends Component { + state = { focused: false }; + + static get propTypes() { + return { + filter: PropTypes.string.isRequired, + updateFilter: PropTypes.func.isRequired, + }; + } + + setFocus = shouldFocus => { + this.setState({ focused: shouldFocus }); + }; + + onChange = e => { + this.props.updateFilter(e.target.value); + }; + + onKeyDown = e => { + if (e.key === "Escape" && this.props.filter !== "") { + // use preventDefault to override toggling the split-console which is + // also bound to the ESC key + e.preventDefault(); + this.props.updateFilter(""); + } else if (e.key === "Enter") { + // We must prevent the form submission from taking any action + // https://github.com/firefox-devtools/debugger/pull/7308 + e.preventDefault(); + } + }; + + render() { + const { focused } = this.state; + return div( + { + className: "outline-filter", + }, + form( + null, + input({ + className: classnames("outline-filter-input devtools-filterinput", { + focused, + }), + onFocus: () => this.setFocus(true), + onBlur: () => this.setFocus(false), + placeholder: L10N.getStr("outline.placeholder"), + value: this.props.filter, + type: "text", + onChange: this.onChange, + onKeyDown: this.onKeyDown, + }) + ) + ); + } +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css new file mode 100644 index 0000000000..eb11149a79 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css @@ -0,0 +1,227 @@ +/* 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/>. */ + +.search-container { + position: absolute; + top: var(--editor-header-height); + left: 0; + width: calc(100% - 1px); + height: calc(100% - var(--editor-header-height)); + display: flex; + flex-direction: column; + z-index: 20; + overflow-y: hidden; + + /* Using the same colors as the Netmonitor's --table-selection-background-hover */ + --search-result-background-hover: rgba(209, 232, 255, 0.8); +} + +.theme-dark .search-container { + --search-result-background-hover: rgba(53, 59, 72, 1); +} + +.project-text-search { + flex-grow: 1; + display: flex; + flex-direction: column; + overflow-y: hidden; + height: 100%; +} + +.project-text-search .result { + display: contents; + cursor: default; + line-height: 16px; + font-size: 11px; + font-family: var(--monospace-font-family); +} + +.project-text-search .result:hover > * { + background-color: var(--search-result-background-hover); +} + +.project-text-search .result .line-number { + grid-column: 1; + padding-block: 1px; + padding-inline-start: 4px; + padding-inline-end: 6px; + text-align: end; + color: var(--theme-text-color-alt); +} + +.unavailable-source { + white-space: pre; + + .tooltip-panel { + padding: 1em; + } +} + +.project-text-search .result .line-value { + grid-column: 2; + padding-block: 1px; + padding-inline-end: 4px; + text-overflow: ellipsis; + overflow-x: hidden; + outline-offset: -2px; +} + +.project-text-search .result .query-match { + border-bottom: 1px solid var(--theme-contrast-border); + color: var(--theme-contrast-color); + background-color: var(--theme-contrast-background); +} + +.project-text-search .result.focused .query-match { + border-bottom: none; + color: var(--theme-selection-background); + background-color: var(--theme-selection-color); +} + +.project-text-search .tree-indent { + display: none; +} + +.project-search-results-toolbar { + display: grid; + grid-template-columns: 1fr auto; + background-color: var(--theme-accordion-header-background); + border-bottom: 1px solid var(--theme-splitter-color); + padding: 2px 8px; + align-items: center; + gap: 4px; +} + + +.project-text-search .refresh-btn { + background-color: transparent; + padding: 2px; + display: grid; + --size: 16px; + --highlight-size: 5px; + --remain-size: calc(var(--size) - var(--highlight-size)); + width: var(--size); + aspect-ratio: 1; + box-sizing: content-box; + grid-template-rows: var(--highlight-size) var(--remain-size); + grid-template-columns: var(--remain-size) var(--highlight-size); + + &.devtools-button:focus-visible { + outline: var(--theme-focus-outline); + } + + &.highlight::after { + content: ""; + display: block; + grid-row: 1 / 2; + grid-column: 2 / 3; + height: 5px; + width: 5px; + background-color: var(--blue-40); + border-radius: 100%; + outline: 1px solid var(--theme-sidebar-background); + z-index: 1; + } + + .img { + grid-row: 1 / -1; + grid-column: 1 / -1; + transition: rotate 0.2s; + width: 14px; height: 14px; + + .highlight & { + rotate: 0.75turn; + } + } +} + +.project-text-search .no-result-msg { + color: var(--theme-text-color-inactive); + font-size: 24px; + padding: 4px 15px; + max-width: 100%; + overflow-wrap: break-word; + hyphens: auto; +} + +.project-text-search .file-result { + grid-column: 1/3; + display: flex; + align-items: center; + width: 100%; + min-height: 24px; + padding: 2px 4px; + font-weight: bold; + font-size: 12px; + line-height: 16px; + cursor: default; +} + +.project-text-search .file-result .img { + margin-inline: 2px; +} + +.project-text-search .file-result .img.file { + margin-inline-end: 4px; +} + +.project-text-search .file-path { + flex: 0 1 auto; + padding-inline-end: 4px; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-text-search .file-path:empty { + display: none; +} + +.project-text-search .search-field { + display: flex; + align-self: stretch; + flex-grow: 1; + width: 100%; + border-bottom: none; +} + +.project-text-search .tree { + overflow-x: hidden; + overflow-y: auto; + height: 100%; + display: grid; + min-width: 100%; + white-space: nowrap; + user-select: none; + align-content: start; + /* Align the second column to the search input's text value */ + grid-template-columns: minmax(40px, auto) 1fr; + padding-top: 4px; +} + +/* Fake padding-bottom using a pseudo-element because Gecko doesn't render the + padding-bottom in a scroll container */ +.project-text-search .tree::after { + content: ""; + display: block; + height: 4px; +} + +.project-text-search .tree .tree-node { + display: contents; +} + +/* Focus values */ + +.project-text-search .file-result.focused, +.project-text-search .result.focused .line-value, +.project-text-search .result.focused .line-number { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} + +.project-text-search .file-result.focused .img { + background-color: currentColor; +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js new file mode 100644 index 0000000000..68b08aed2b --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js @@ -0,0 +1,480 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "devtools/client/shared/vendor/react"; +import { + button, + div, + span, +} from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import actions from "../../actions/index"; + +import { getEditor } from "../../utils/editor/index"; +import { searchKeys } from "../../constants"; + +import { getRelativePath } from "../../utils/sources-tree/utils"; +import { getFormattedSourceId } from "../../utils/source"; +import { + getProjectSearchQuery, + getNavigateCounter, +} from "../../selectors/index"; + +import SearchInput from "../shared/SearchInput"; +import AccessibleImage from "../shared/AccessibleImage"; + +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const classnames = require("resource://devtools/client/shared/classnames.js"); +const Tree = require("resource://devtools/client/shared/components/Tree.js"); +const { debounce } = require("resource://devtools/shared/debounce.js"); +const { throttle } = require("resource://devtools/shared/throttle.js"); + +const { + HTMLTooltip, +} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js"); + +export const statusType = { + initial: "INITIAL", + fetching: "FETCHING", + cancelled: "CANCELLED", + done: "DONE", + error: "ERROR", +}; + +function getFilePath(item, index) { + return item.type === "RESULT" + ? `${item.location.source.id}-${index || "$"}` + : `${item.location.source.id}-${item.location.line}-${ + item.location.column + }-${index || "$"}`; +} + +export class ProjectSearch extends Component { + constructor(props) { + super(props); + + this.state = { + // We may restore a previous state when changing tabs in the primary panes, + // or when restoring primary panes from collapse. + query: this.props.query || "", + + inputFocused: false, + focusedItem: null, + expanded: new Set(), + results: [], + navigateCounter: null, + status: statusType.done, + }; + // Use throttle for updating results in order to prevent delaying showing result until the end of the search + this.onUpdatedResults = throttle(this.onUpdatedResults.bind(this), 100); + // Use debounce for input processing in order to wait for the end of user input edition before triggerring the search + this.doSearch = debounce(this.doSearch.bind(this), 100); + this.doSearch(); + } + + static get propTypes() { + return { + doSearchForHighlight: PropTypes.func.isRequired, + query: PropTypes.string.isRequired, + results: PropTypes.array.isRequired, + searchSources: PropTypes.func.isRequired, + selectSpecificLocationOrSameUrl: PropTypes.func.isRequired, + status: PropTypes.oneOf([ + "INITIAL", + "FETCHING", + "CANCELED", + "DONE", + "ERROR", + ]).isRequired, + modifiers: PropTypes.object, + toggleProjectSearchModifier: PropTypes.func, + }; + } + + async doSearch() { + // Cancel any previous async ongoing search + if (this.searchAbortController) { + this.searchAbortController.abort(); + } + + if (!this.state.query) { + this.setState({ status: statusType.done }); + return; + } + + this.setState({ + status: statusType.fetching, + results: [], + navigateCounter: this.props.navigateCounter, + }); + + // Setup an AbortController whose main goal is to be able to cancel the asynchronous + // operation done by the `searchSources` action. + // This allows allows the React Component to receive partial updates + // to render results as they are available. + this.searchAbortController = new AbortController(); + + await this.props.searchSources( + this.state.query, + this.onUpdatedResults, + this.searchAbortController.signal + ); + } + + onUpdatedResults(results, done, signal) { + // debounce may delay the execution after this search has been cancelled + if (signal.aborted) { + return; + } + + this.setState({ + results, + status: done ? statusType.done : statusType.fetching, + }); + } + + selectMatchItem = async matchItem => { + const foundMatchingSource = + await this.props.selectSpecificLocationOrSameUrl(matchItem.location); + // When we reload, or if the source's target has been destroyed, + // we may no longer have the source available in the reducer. + // In such case `selectSpecificLocationOrSameUrl` will return false. + if (!foundMatchingSource) { + // When going over results via the key arrows and Enter, we may display many tooltips at once. + if (this.tooltip) { + this.tooltip.hide(); + } + // Go down to line-number otherwise HTMLTooltip's call to getBoundingClientRect would return (0, 0) position for the tooltip + const element = document.querySelector( + ".project-text-search .tree-node.focused .result .line-number" + ); + const tooltip = new HTMLTooltip(element.ownerDocument, { + className: "unavailable-source", + type: "arrow", + }); + tooltip.panel.textContent = L10N.getStr( + "projectTextSearch.sourceNoLongerAvailable" + ); + tooltip.setContentSize({ height: "auto" }); + tooltip.show(element); + this.tooltip = tooltip; + return; + } + this.props.doSearchForHighlight( + this.state.query, + getEditor(), + matchItem.location.line, + matchItem.location.column + ); + }; + + highlightMatches = lineMatch => { + const { value, matchIndex, match } = lineMatch; + const len = match.length; + return span( + { + className: "line-value", + }, + span( + { + className: "line-match", + key: 0, + }, + value.slice(0, matchIndex) + ), + span( + { + className: "query-match", + key: 1, + }, + value.substr(matchIndex, len) + ), + span( + { + className: "line-match", + key: 2, + }, + value.slice(matchIndex + len, value.length) + ) + ); + }; + + getResultCount = () => + this.state.results.reduce((count, file) => count + file.matches.length, 0); + + onKeyDown = e => { + if (e.key === "Escape") { + return; + } + + e.stopPropagation(); + + this.setState({ focusedItem: null }); + this.doSearch(); + }; + + onHistoryScroll = query => { + this.setState({ query }); + this.doSearch(); + }; + + // This can be called by Tree when manually selecting node via arrow keys and Enter. + onActivate = item => { + if (item && item.type === "MATCH") { + this.selectMatchItem(item); + } + }; + + onFocus = item => { + if (this.state.focusedItem !== item) { + this.setState({ + focusedItem: item, + }); + } + }; + + inputOnChange = e => { + const inputValue = e.target.value; + this.setState({ query: inputValue }); + this.doSearch(); + }; + + renderFile = (file, focused, expanded) => { + const matchesLength = file.matches.length; + const matches = ` (${matchesLength} match${matchesLength > 1 ? "es" : ""})`; + return div( + { + className: classnames("file-result", { + focused, + }), + key: file.location.source.id, + }, + React.createElement(AccessibleImage, { + className: classnames("arrow", { + expanded, + }), + }), + React.createElement(AccessibleImage, { + className: "file", + }), + span( + { + className: "file-path", + }, + file.location.source.url + ? getRelativePath(file.location.source.url) + : getFormattedSourceId(file.location.source.id) + ), + span( + { + className: "matches-summary", + }, + matches + ) + ); + }; + + renderMatch = (match, focused) => { + return div( + { + className: classnames("result", { + focused, + }), + onClick: () => this.selectMatchItem(match), + }, + span( + { + className: "line-number", + key: match.location.line, + }, + match.location.line + ), + this.highlightMatches(match) + ); + }; + + renderItem = (item, depth, focused, _, expanded) => { + if (item.type === "RESULT") { + return this.renderFile(item, focused, expanded); + } + return this.renderMatch(item, focused); + }; + + renderRefreshButton() { + if (!this.state.query) { + return null; + } + + // Highlight the refresh button when the current search results + // are based on the previous document. doSearch will save the "navigate counter" + // into state, while props will report the current "navigate counter". + // The "navigate counter" is incremented each time we navigate to a new page. + const highlight = + this.state.navigateCounter != null && + this.state.navigateCounter != this.props.navigateCounter; + return button( + { + className: classnames("refresh-btn devtools-button", { + highlight, + }), + title: highlight + ? L10N.getStr("projectTextSearch.refreshButtonTooltipOnNavigation") + : L10N.getStr("projectTextSearch.refreshButtonTooltip"), + onClick: this.doSearch, + }, + React.createElement(AccessibleImage, { + className: "refresh", + }) + ); + } + + renderResultsToolbar() { + if (!this.state.query) { + return null; + } + return div( + { className: "project-search-results-toolbar" }, + span({ className: "results-count" }, this.renderSummary()), + this.renderRefreshButton() + ); + } + + renderResults() { + const { status, results } = this.state; + if (!this.state.query) { + return null; + } + if (results.length) { + return React.createElement(Tree, { + getRoots: () => results, + getChildren: file => file.matches || [], + autoExpandAll: true, + autoExpandDepth: 1, + autoExpandNodeChildrenLimit: 100, + getParent: item => null, + getPath: getFilePath, + renderItem: this.renderItem, + focused: this.state.focusedItem, + onFocus: this.onFocus, + onActivate: this.onActivate, + isExpanded: item => { + return this.state.expanded.has(item); + }, + onExpand: item => { + const { expanded } = this.state; + expanded.add(item); + this.setState({ + expanded, + }); + }, + onCollapse: item => { + const { expanded } = this.state; + expanded.delete(item); + this.setState({ + expanded, + }); + }, + getKey: getFilePath, + }); + } + const msg = + status === statusType.fetching + ? L10N.getStr("loadingText") + : L10N.getStr("projectTextSearch.noResults"); + return div( + { + className: "no-result-msg absolute-center", + }, + msg + ); + } + + renderSummary = () => { + if (this.state.query === "") { + return ""; + } + const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2"); + const count = this.getResultCount(); + if (count === 0) { + return ""; + } + return PluralForm.get(count, resultsSummaryString).replace("#1", count); + }; + + shouldShowErrorEmoji() { + return !this.getResultCount() && this.state.status === statusType.done; + } + + renderInput() { + const { status } = this.state; + return React.createElement(SearchInput, { + query: this.state.query, + count: this.getResultCount(), + placeholder: L10N.getStr("projectTextSearch.placeholder"), + size: "small", + showErrorEmoji: this.shouldShowErrorEmoji(), + isLoading: status === statusType.fetching, + onChange: this.inputOnChange, + onFocus: () => + this.setState({ + inputFocused: true, + }), + onBlur: () => + this.setState({ + inputFocused: false, + }), + onKeyDown: this.onKeyDown, + onHistoryScroll: this.onHistoryScroll, + showClose: false, + showExcludePatterns: true, + excludePatternsLabel: L10N.getStr( + "projectTextSearch.excludePatterns.label" + ), + excludePatternsPlaceholder: L10N.getStr( + "projectTextSearch.excludePatterns.placeholder" + ), + ref: "searchInput", + showSearchModifiers: true, + searchKey: searchKeys.PROJECT_SEARCH, + onToggleSearchModifier: this.doSearch, + }); + } + + render() { + return div( + { + className: "search-container", + }, + div( + { + className: "project-text-search", + }, + div( + { + className: "header", + }, + this.renderInput() + ), + this.renderResultsToolbar(), + this.renderResults() + ) + ); + } +} + +ProjectSearch.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = state => ({ + query: getProjectSearchQuery(state), + navigateCounter: getNavigateCounter(state), +}); + +export default connect(mapStateToProps, { + searchSources: actions.searchSources, + selectSpecificLocationOrSameUrl: actions.selectSpecificLocationOrSameUrl, + doSearchForHighlight: actions.doSearchForHighlight, +})(ProjectSearch); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Sources.css b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css new file mode 100644 index 0000000000..68c28655be --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css @@ -0,0 +1,244 @@ +/* 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-panel { + background-color: var(--theme-sidebar-background); + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + position: relative; + + & * { + user-select: none; + } + + /* Tabs header */ + & .tabs-navigation { + height: var(--editor-header-height) !important; + + & .tabs-menu { + /* override margin set by the Tabs component */ + margin: 0 !important; + } + + & .tab { + flex: 1; + overflow: hidden; + display: inline-flex; + align-items: center; + } + + & [role="tab"] { + padding: 4px 8px; + flex: 1; + } + } +} + + + +/***********************/ +/* Souces Panel layout */ +/***********************/ + +.sources-list { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.sources-list .sources-clear-root-container { + grid-area: custom-root; +} + +.sources-list :is(.tree, .no-sources-message) { + grid-area: sources-tree-or-empty-message; +} + +/****************/ +/* Custom root */ +/****************/ + +.sources-clear-root { + padding: 4px 8px; + width: 100%; + text-align: start; + white-space: nowrap; + color: inherit; + display: flex; + border-bottom: 1px solid var(--theme-splitter-color); +} + +.sources-clear-root .home { + background-color: var(--theme-icon-dimmed-color); +} + +.sources-clear-root .breadcrumb { + width: 5px; + margin: 0 2px 0 6px; + vertical-align: bottom; + background: var(--theme-text-color-alt); +} + +.sources-clear-root-label { + margin-left: 5px; + line-height: 16px; +} + +/*****************/ +/* Sources tree */ +/*****************/ + +.sources-list .tree { + flex-grow: 1; + padding: 4px 0; + user-select: none; + + white-space: nowrap; + overflow: auto; + min-width: 100%; + + display: grid; + grid-template-columns: 1fr; + align-content: start; + + line-height: 1.4em; +} + +.sources-list .tree .node { + display: flex; + align-items: center; + width: 100%; + padding-block: 8px; + padding-inline: 6px 8px; +} + +.sources-list .tree .tree-node:not(.focused):hover { + background: var(--theme-toolbar-background-hover); +} + +.sources-list .tree button { + display: block; +} + +.sources-list .tree .node { + padding: 2px 3px; + position: relative; +} + +.sources-list .tree .node.focused { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} + +html:not([dir="rtl"]) .sources-list .tree .node > div { + margin-left: 10px; +} + +html[dir="rtl"] .sources-list .tree .node > div { + margin-right: 10px; +} + +.sources-list .tree-node button { + position: fixed; +} + +.sources-list .img { + margin-inline-end: 4px; +} + +.sources-list .tree .focused .img { + --icon-color: #ffffff; + background-color: var(--icon-color); + fill: var(--icon-color); +} + +/* Use the same width as .img.arrow */ +.sources-list .tree .img.no-arrow { + width: 10px; + visibility: hidden; +} + +.sources-list .tree .label .suffix { + font-style: italic; + font-size: 0.9em; + color: var(--theme-comment); +} + +.sources-list .tree .focused .label .suffix { + color: inherit; +} + +.theme-dark .source-list .node.focused { + background-color: var(--theme-tab-toolbar-background); +} + +.sources-list .tree .blackboxed { + color: #806414; +} + +.sources-list .img.blackBox { + mask-size: 13px; + background-color: #806414; +} + +.sources-list .tree .label { + display: inline-block; + line-height: 16px; +} + +.source-list-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + justify-content: center; + text-align: center; + min-height: var(--editor-footer-height); + flex-shrink: 0; + border-block-start: 1px solid var(--theme-warning-border); + user-select: none; + padding: 3px 10px; + color: var(--theme-warning-color); + background-color: var(--theme-warning-background); +} + +.source-list-footer .devtools-togglebutton { + background-color: var(--theme-toolbar-hover); +} + +.source-list-footer .devtools-togglebutton:hover { + background-color: var(--theme-toolbar-hover); + cursor: pointer; +} + + +/* Removes start margin when a custom root is used */ +.sources-list-custom-root + .tree + > .tree-node[data-expandable="false"][aria-level="0"] { + padding-inline-start: 4px; +} + +.sources-list .tree-node[data-expandable="false"] .tree-indent:last-of-type { + margin-inline-end: 0; +} + + +/*****************/ +/* No Sources */ +/*****************/ + +.no-sources-message { + display: flex; + justify-content: center; + align-items: center; + font-style: italic; + text-align: center; + padding: 0.5em; + font-size: 12px; + user-select: none; +} diff --git a/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js new file mode 100644 index 0000000000..286e673706 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js @@ -0,0 +1,352 @@ +/* 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/>. */ + +// Dependencies +import React, { + Component, + Fragment, +} from "devtools/client/shared/vendor/react"; +import { + div, + button, + span, + footer, +} from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +// Selectors +import { + getMainThreadHost, + getExpandedState, + getProjectDirectoryRoot, + getProjectDirectoryRootName, + getSourcesTreeSources, + getFocusedSourceItem, + getHideIgnoredSources, +} from "../../selectors/index"; + +// Actions +import actions from "../../actions/index"; + +// Components +import SourcesTreeItem from "./SourcesTreeItem"; +import AccessibleImage from "../shared/AccessibleImage"; + +const classnames = require("resource://devtools/client/shared/classnames.js"); +const Tree = require("resource://devtools/client/shared/components/Tree.js"); + +function shouldAutoExpand(item, mainThreadHost) { + // There is only one case where we want to force auto expand, + // when we are on the group of the page's domain. + return item.type == "group" && item.groupName === mainThreadHost; +} + +class SourcesTree extends Component { + constructor(props) { + super(props); + + this.state = {}; + } + + static get propTypes() { + return { + mainThreadHost: PropTypes.string.isRequired, + expanded: PropTypes.object.isRequired, + focusItem: PropTypes.func.isRequired, + focused: PropTypes.object, + projectRoot: PropTypes.string.isRequired, + selectSource: PropTypes.func.isRequired, + setExpandedState: PropTypes.func.isRequired, + rootItems: PropTypes.object.isRequired, + clearProjectDirectoryRoot: PropTypes.func.isRequired, + projectRootName: PropTypes.string.isRequired, + setHideOrShowIgnoredSources: PropTypes.func.isRequired, + hideIgnoredSources: PropTypes.bool.isRequired, + }; + } + + selectSourceItem = item => { + this.props.selectSource(item.source, item.sourceActor); + }; + + onFocus = item => { + this.props.focusItem(item); + }; + + onActivate = item => { + if (item.type == "source") { + this.selectSourceItem(item); + } + }; + + onExpand = (item, shouldIncludeChildren) => { + this.setExpanded(item, true, shouldIncludeChildren); + }; + + onCollapse = (item, shouldIncludeChildren) => { + this.setExpanded(item, false, shouldIncludeChildren); + }; + + setExpanded = (item, isExpanded, shouldIncludeChildren) => { + // Note that setExpandedState relies on us to clone this Set + // which is going to be store as-is in the reducer. + const expanded = new Set(this.props.expanded); + + let changed = false; + const expandItem = i => { + const key = this.getKey(i); + if (isExpanded) { + changed |= !expanded.has(key); + expanded.add(key); + } else { + changed |= expanded.has(key); + expanded.delete(key); + } + }; + expandItem(item); + + if (shouldIncludeChildren) { + let parents = [item]; + while (parents.length) { + const children = []; + for (const parent of parents) { + for (const child of this.getChildren(parent)) { + expandItem(child); + children.push(child); + } + } + parents = children; + } + } + if (changed) { + this.props.setExpandedState(expanded); + } + }; + + isEmpty() { + return !this.getRoots().length; + } + + renderEmptyElement(message) { + return div( + { + key: "empty", + className: "no-sources-message", + }, + message + ); + } + + getRoots = () => { + return this.props.rootItems; + }; + + getKey = item => { + // As this is used as React key in Tree component, + // we need to update the key when switching to a new project root + // otherwise these items won't be updated and will have a buggy padding start. + const { projectRoot } = this.props; + if (projectRoot) { + return projectRoot + item.uniquePath; + } + return item.uniquePath; + }; + + getChildren = item => { + // This is the precial magic that coalesce "empty" folders, + // i.e folders which have only one sub-folder as children. + function skipEmptyDirectories(directory) { + if (directory.type != "directory") { + return directory; + } + if ( + directory.children.length == 1 && + directory.children[0].type == "directory" + ) { + return skipEmptyDirectories(directory.children[0]); + } + return directory; + } + if (item.type == "thread") { + return item.children; + } else if (item.type == "group" || item.type == "directory") { + return item.children.map(skipEmptyDirectories); + } + return []; + }; + + getParent = item => { + if (item.type == "thread") { + return null; + } + const { rootItems } = this.props; + // This is the second magic which skip empty folders + // (See getChildren comment) + function skipEmptyDirectories(directory) { + if ( + directory.type == "group" || + directory.type == "thread" || + rootItems.includes(directory) + ) { + return directory; + } + if ( + directory.children.length == 1 && + directory.children[0].type == "directory" + ) { + return skipEmptyDirectories(directory.parent); + } + return directory; + } + return skipEmptyDirectories(item.parent); + }; + + renderProjectRootHeader() { + const { projectRootName } = this.props; + + if (!projectRootName) { + return null; + } + return div( + { + key: "root", + className: "sources-clear-root-container", + }, + button( + { + className: "sources-clear-root", + onClick: () => this.props.clearProjectDirectoryRoot(), + title: L10N.getStr("removeDirectoryRoot.label"), + }, + React.createElement(AccessibleImage, { + className: "home", + }), + React.createElement(AccessibleImage, { + className: "breadcrumb", + }), + span( + { + className: "sources-clear-root-label", + }, + projectRootName + ) + ) + ); + } + + renderItem = (item, depth, focused, _, expanded) => { + const { mainThreadHost } = this.props; + return React.createElement(SourcesTreeItem, { + item, + depth, + focused, + autoExpand: shouldAutoExpand(item, mainThreadHost), + expanded, + focusItem: this.onFocus, + selectSourceItem: this.selectSourceItem, + setExpanded: this.setExpanded, + getParent: this.getParent, + }); + }; + + renderTree() { + const { expanded, focused } = this.props; + + const treeProps = { + autoExpandAll: false, + autoExpandDepth: 1, + expanded, + focused, + getChildren: this.getChildren, + getParent: this.getParent, + getKey: this.getKey, + getRoots: this.getRoots, + onCollapse: this.onCollapse, + onExpand: this.onExpand, + onFocus: this.onFocus, + isExpanded: item => { + return this.props.expanded.has(this.getKey(item)); + }, + onActivate: this.onActivate, + renderItem: this.renderItem, + preventBlur: true, + }; + return React.createElement(Tree, treeProps); + } + + renderPane(child) { + const { projectRoot } = this.props; + return div( + { + key: "pane", + className: classnames("sources-pane", { + "sources-list-custom-root": !!projectRoot, + }), + }, + child + ); + } + + renderFooter() { + if (this.props.hideIgnoredSources) { + return footer( + { + className: "source-list-footer", + }, + L10N.getStr("ignoredSourcesHidden"), + button( + { + className: "devtools-togglebutton", + onClick: () => this.props.setHideOrShowIgnoredSources(false), + title: L10N.getStr("showIgnoredSources.tooltip.label"), + }, + L10N.getStr("showIgnoredSources") + ) + ); + } + return null; + } + + render() { + const { projectRoot } = this.props; + return div( + { + key: "pane", + className: classnames("sources-list", { + "sources-list-custom-root": !!projectRoot, + }), + }, + this.isEmpty() + ? this.renderEmptyElement(L10N.getStr("noSourcesText")) + : React.createElement( + Fragment, + null, + this.renderProjectRootHeader(), + this.renderTree(), + this.renderFooter() + ) + ); + } +} + +const mapStateToProps = state => { + return { + mainThreadHost: getMainThreadHost(state), + expanded: getExpandedState(state), + focused: getFocusedSourceItem(state), + projectRoot: getProjectDirectoryRoot(state), + rootItems: getSourcesTreeSources(state), + projectRootName: getProjectDirectoryRootName(state), + hideIgnoredSources: getHideIgnoredSources(state), + }; +}; + +export default connect(mapStateToProps, { + selectSource: actions.selectSource, + setExpandedState: actions.setExpandedState, + focusItem: actions.focusItem, + clearProjectDirectoryRoot: actions.clearProjectDirectoryRoot, + setHideOrShowIgnoredSources: actions.setHideOrShowIgnoredSources, +})(SourcesTree); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js new file mode 100644 index 0000000000..fd5ceca46d --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "devtools/client/shared/vendor/react"; +import { div, span } from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import SourceIcon from "../shared/SourceIcon"; +import AccessibleImage from "../shared/AccessibleImage"; + +import { + getGeneratedSourceByURL, + isSourceOverridden, + getHideIgnoredSources, +} from "../../selectors/index"; +import actions from "../../actions/index"; + +import { sourceTypes } from "../../utils/source"; +import { createLocation } from "../../utils/location"; +import { safeDecodeItemName } from "../../utils/sources-tree/utils"; + +const classnames = require("resource://devtools/client/shared/classnames.js"); + +class SourceTreeItem extends Component { + static get propTypes() { + return { + autoExpand: PropTypes.bool.isRequired, + depth: PropTypes.bool.isRequired, + expanded: PropTypes.bool.isRequired, + focusItem: PropTypes.func.isRequired, + focused: PropTypes.bool.isRequired, + hasMatchingGeneratedSource: PropTypes.bool.isRequired, + item: PropTypes.object.isRequired, + selectSourceItem: PropTypes.func.isRequired, + setExpanded: PropTypes.func.isRequired, + getParent: PropTypes.func.isRequired, + isOverridden: PropTypes.bool, + hideIgnoredSources: PropTypes.bool, + }; + } + + componentDidMount() { + const { autoExpand, item } = this.props; + if (autoExpand) { + this.props.setExpanded(item, true, false); + } + } + + onClick = e => { + const { item, focusItem, selectSourceItem } = this.props; + + focusItem(item); + if (item.type == "source") { + selectSourceItem(item); + } + }; + + onContextMenu = event => { + event.stopPropagation(); + event.preventDefault(); + this.props.showSourceTreeItemContextMenu( + event, + this.props.item, + this.props.depth, + this.props.setExpanded, + this.renderItemName() + ); + }; + + renderItemArrow() { + const { item, expanded } = this.props; + return item.type != "source" + ? React.createElement(AccessibleImage, { + className: classnames("arrow", { + expanded, + }), + }) + : span({ + className: "img no-arrow", + }); + } + + renderIcon(item) { + if (item.type == "thread") { + const icon = item.thread.targetType.includes("worker") + ? "worker" + : "window"; + return React.createElement(AccessibleImage, { + className: classnames(icon), + }); + } + if (item.type == "group") { + if (item.groupName === "Webpack") { + return React.createElement(AccessibleImage, { + className: "webpack", + }); + } else if (item.groupName === "Angular") { + return React.createElement(AccessibleImage, { + className: "angular", + }); + } + // Check if the group relates to an extension. + // This happens when a webextension injects a content script. + if (item.isForExtensionSource) { + return React.createElement(AccessibleImage, { + className: "extension", + }); + } + return React.createElement(AccessibleImage, { + className: "globe-small", + }); + } + if (item.type == "directory") { + return React.createElement(AccessibleImage, { + className: "folder", + }); + } + if (item.type == "source") { + const { source, sourceActor } = item; + return React.createElement(SourceIcon, { + location: createLocation({ + source, + sourceActor, + }), + modifier: icon => { + // In the SourceTree, extension files should use the file-extension based icon, + // whereas we use the extension icon in other Components (eg. source tabs and breakpoints pane). + if (icon === "extension") { + return sourceTypes[source.displayURL.fileExtension] || "javascript"; + } + return icon + (this.props.isOverridden ? " override" : ""); + }, + }); + } + return null; + } + renderItemName() { + const { item } = this.props; + + if (item.type == "thread") { + const { thread } = item; + return ( + thread.name + + (thread.serviceWorkerStatus ? ` (${thread.serviceWorkerStatus})` : "") + ); + } + if (item.type == "group") { + return safeDecodeItemName(item.groupName); + } + if (item.type == "directory") { + const parentItem = this.props.getParent(item); + return safeDecodeItemName( + item.path.replace(parentItem.path, "").replace(/^\//, "") + ); + } + if (item.type == "source") { + const { displayURL } = item.source; + const name = + displayURL.filename + (displayURL.search ? displayURL.search : ""); + return safeDecodeItemName(name); + } + + return null; + } + + renderItemTooltip() { + const { item } = this.props; + + if (item.type == "thread") { + return item.thread.name; + } + if (item.type == "group") { + return item.groupName; + } + if (item.type == "directory") { + return item.path; + } + if (item.type == "source") { + return item.source.url; + } + + return null; + } + + render() { + const { item, focused, hasMatchingGeneratedSource, hideIgnoredSources } = + this.props; + + if (hideIgnoredSources && item.isBlackBoxed) { + return null; + } + const suffix = hasMatchingGeneratedSource + ? span( + { + className: "suffix", + }, + L10N.getStr("sourceFooter.mappedSuffix") + ) + : null; + return div( + { + className: classnames("node", { + focused, + blackboxed: item.type == "source" && item.isBlackBoxed, + }), + key: item.path, + onClick: this.onClick, + onContextMenu: this.onContextMenu, + title: this.renderItemTooltip(), + }, + this.renderItemArrow(), + this.renderIcon(item), + span( + { + className: "label", + }, + this.renderItemName(), + suffix + ) + ); + } +} + +function getHasMatchingGeneratedSource(state, source) { + if (!source || !source.isOriginal) { + return false; + } + + return !!getGeneratedSourceByURL(state, source.url); +} + +const mapStateToProps = (state, props) => { + const { item } = props; + if (item.type == "source") { + const { source } = item; + return { + hasMatchingGeneratedSource: getHasMatchingGeneratedSource(state, source), + isOverridden: isSourceOverridden(state, source), + hideIgnoredSources: getHideIgnoredSources(state), + }; + } + return {}; +}; + +export default connect(mapStateToProps, { + showSourceTreeItemContextMenu: actions.showSourceTreeItemContextMenu, +})(SourceTreeItem); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/index.js b/devtools/client/debugger/src/components/PrimaryPanes/index.js new file mode 100644 index 0000000000..a8f6bc9a33 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/index.js @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; + +import actions from "../../actions/index"; +import { getSelectedPrimaryPaneTab } from "../../selectors/index"; +import { prefs } from "../../utils/prefs"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import { primaryPaneTabs } from "../../constants"; + +import Outline from "./Outline"; +import SourcesTree from "./SourcesTree"; +import ProjectSearch from "./ProjectSearch"; + +const { + TabPanel, + Tabs, +} = require("resource://devtools/client/shared/components/tabs/Tabs.js"); + +const tabs = [ + primaryPaneTabs.SOURCES, + primaryPaneTabs.OUTLINE, + primaryPaneTabs.PROJECT_SEARCH, +]; + +class PrimaryPanes extends Component { + constructor(props) { + super(props); + + this.state = { + alphabetizeOutline: prefs.alphabetizeOutline, + }; + } + + static get propTypes() { + return { + projectRootName: PropTypes.string.isRequired, + selectedTab: PropTypes.oneOf(tabs).isRequired, + setPrimaryPaneTab: PropTypes.func.isRequired, + setActiveSearch: PropTypes.func.isRequired, + closeActiveSearch: PropTypes.func.isRequired, + }; + } + + onAlphabetizeClick = () => { + const alphabetizeOutline = !prefs.alphabetizeOutline; + prefs.alphabetizeOutline = alphabetizeOutline; + this.setState({ alphabetizeOutline }); + }; + + onActivateTab = index => { + const tab = tabs.at(index); + this.props.setPrimaryPaneTab(tab); + if (tab == primaryPaneTabs.PROJECT_SEARCH) { + this.props.setActiveSearch(tab); + } else { + this.props.closeActiveSearch(); + } + }; + + render() { + const { selectedTab } = this.props; + return React.createElement( + "aside", + { + className: "tab-panel sources-panel", + }, + React.createElement( + Tabs, + { + activeTab: tabs.indexOf(selectedTab), + onAfterChange: this.onActivateTab, + }, + React.createElement( + TabPanel, + { + id: "sources-tab", + key: `sources-tab${ + selectedTab === primaryPaneTabs.SOURCES ? "-selected" : "" + }`, + className: "tab sources-tab", + title: L10N.getStr("sources.header"), + }, + React.createElement(SourcesTree, null) + ), + React.createElement( + TabPanel, + { + id: "outline-tab", + key: `outline-tab${ + selectedTab === primaryPaneTabs.OUTLINE ? "-selected" : "" + }`, + className: "tab outline-tab", + title: L10N.getStr("outline.header"), + }, + React.createElement(Outline, { + alphabetizeOutline: this.state.alphabetizeOutline, + onAlphabetizeClick: this.onAlphabetizeClick, + }) + ), + React.createElement( + TabPanel, + { + id: "search-tab", + key: `search-tab${ + selectedTab === primaryPaneTabs.PROJECT_SEARCH ? "-selected" : "" + }`, + className: "tab search-tab", + title: L10N.getStr("search.header"), + }, + React.createElement(ProjectSearch, null) + ) + ) + ); + } +} + +const mapStateToProps = state => { + return { + selectedTab: getSelectedPrimaryPaneTab(state), + }; +}; + +const connector = connect(mapStateToProps, { + setPrimaryPaneTab: actions.setPrimaryPaneTab, + setActiveSearch: actions.setActiveSearch, + closeActiveSearch: actions.closeActiveSearch, +}); + +export default connector(PrimaryPanes); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/moz.build b/devtools/client/debugger/src/components/PrimaryPanes/moz.build new file mode 100644 index 0000000000..fc73b7bee7 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/moz.build @@ -0,0 +1,15 @@ +# 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( + "index.js", + "Outline.js", + "OutlineFilter.js", + "ProjectSearch.js", + "SourcesTree.js", + "SourcesTreeItem.js", +) |