diff options
Diffstat (limited to 'devtools/client/debugger/src/components/PrimaryPanes')
13 files changed, 3932 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..cbad0bddc3 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css @@ -0,0 +1,205 @@ +/* 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-tabs { + font-size: 12px; + width: 100%; + background: var(--theme-body-background); + display: flex; + user-select: none; + box-sizing: border-box; + height: var(--editor-header-height); + margin: 0; + padding: 0; + border-bottom: 1px solid var(--theme-splitter-color); +} + +.source-outline-tabs .tab { + align-items: center; + background-color: var(--theme-toolbar-background); + color: var(--theme-toolbar-color); + cursor: default; + display: inline-flex; + flex: 1; + justify-content: center; + overflow: hidden; + padding: 4px 8px; + position: relative; +} + +.source-outline-tabs .tab::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background-color: var(--tab-line-color, transparent); + transition: transform 250ms var(--animation-curve), + opacity 250ms var(--animation-curve); + opacity: 0; + transform: scaleX(0); +} + +.source-outline-tabs .tab.active { + --tab-line-color: var(--tab-line-selected-color); + color: var(--theme-toolbar-selected-color); + border-bottom-color: transparent; +} + +.source-outline-tabs .tab:not(.active):hover { + --tab-line-color: var(--tab-line-hover-color); + background-color: var(--theme-toolbar-hover); +} + +.source-outline-tabs .tab:hover::before, +.source-outline-tabs .tab.active::before { + opacity: 1; + transform: scaleX(1); +} + +.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); +} + +.outline-footer button.active { + background: var(--theme-selection-background); + color: var(--theme-selection-color); +} 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..8e0aa17ca4 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js @@ -0,0 +1,372 @@ +/* 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 "react"; +import PropTypes from "prop-types"; +import { showMenu } from "../../context-menu/menu"; +import { connect } from "../../utils/connect"; +import { score as fuzzaldrinScore } from "fuzzaldrin-plus"; + +import { containsPosition, positionAfter } from "../../utils/ast"; +import { copyToTheClipboard } from "../../utils/clipboard"; +import { findFunctionText } from "../../utils/function"; +import { createLocation } from "../../utils/location"; + +import actions from "../../actions"; +import { + getSelectedLocation, + getSelectedSource, + getSelectedSourceTextContent, + getSymbols, + getCursorPosition, + getContext, +} from "../../selectors"; + +import OutlineFilter from "./OutlineFilter"; +import "./Outline.css"; +import PreviewFunction from "../shared/PreviewFunction"; + +const classnames = require("devtools/client/shared/classnames.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 }; + } + + static get propTypes() { + return { + alphabetizeOutline: PropTypes.bool.isRequired, + cursorPosition: PropTypes.object, + cx: PropTypes.object.isRequired, + flashLineRange: PropTypes.func.isRequired, + getFunctionText: PropTypes.func.isRequired, + onAlphabetizeClick: PropTypes.func.isRequired, + selectLocation: PropTypes.func.isRequired, + selectedSource: PropTypes.object.isRequired, + symbols: PropTypes.object.isRequired, + }; + } + + componentDidUpdate(prevProps) { + const { cursorPosition, symbols } = this.props; + if ( + cursorPosition && + symbols && + cursorPosition !== prevProps.cursorPosition + ) { + this.setFocus(cursorPosition); + } + + if ( + this.focusedElRef && + !isVisible(this.focusedElRef, this.refs.outlineList) + ) { + this.focusedElRef.scrollIntoView({ block: "center" }); + } + } + + setFocus(cursorPosition) { + const { symbols } = this.props; + let classes = []; + let functions = []; + + if (symbols) { + ({ classes, functions } = symbols); + } + + // Find items that enclose the selected location + const enclosedItems = [...classes, ...functions].filter( + ({ name, location }) => + name != "anonymous" && 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 { cx, selectedSource, selectLocation } = this.props; + if (!selectedSource || !selectedItem) { + return; + } + + selectLocation( + cx, + createLocation({ + source: selectedSource, + line: selectedItem.location.start.line, + column: selectedItem.location.start.column, + }) + ); + + this.setState({ focusedItem: selectedItem }); + } + + onContextMenu(event, func) { + event.stopPropagation(); + event.preventDefault(); + + const { selectedSource, flashLineRange, getFunctionText } = this.props; + + if (!selectedSource) { + return; + } + + const sourceLine = func.location.start.line; + const functionText = getFunctionText(sourceLine); + + const copyFunctionItem = { + id: "node-menu-copy-function", + label: L10N.getStr("copyFunction.label"), + accesskey: L10N.getStr("copyFunction.accesskey"), + disabled: !functionText, + click: () => { + flashLineRange({ + start: sourceLine, + end: func.location.end.line, + sourceId: selectedSource.id, + }); + return copyToTheClipboard(functionText); + }, + }; + const menuOptions = [copyFunctionItem]; + showMenu(event, menuOptions); + } + + updateFilter = filter => { + this.setState({ filter: filter.trim() }); + }; + + renderPlaceholder() { + const placeholderMessage = this.props.selectedSource + ? L10N.getStr("outline.noFunctions") + : L10N.getStr("outline.noFileSelected"); + + return <div className="outline-pane-info">{placeholderMessage}</div>; + } + + renderLoading() { + return ( + <div className="outline-pane-info">{L10N.getStr("loadingText")}</div> + ); + } + + 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">λ</span> + <PreviewFunction func={{ name, parameterNames }} /> + </li> + ); + } + + renderClassHeader(klass) { + return ( + <div> + <span className="keyword">class</span> {klass} + </div> + ); + } + + renderClassFunctions(klass, functions) { + const { symbols } = this.props; + + 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)} + </h2> + <ul className="outline-list__class-list"> + {classFunctions.map(func => this.renderFunction(func))} + </ul> + </li> + ); + } + + 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))} + </ul> + ); + } + + renderFooter() { + return ( + <div className="outline-footer"> + <button + onClick={this.props.onAlphabetizeClick} + className={this.props.alphabetizeOutline ? "active" : ""} + > + {L10N.getStr("outline.sortLabel")} + </button> + </div> + ); + } + + render() { + const { symbols, selectedSource } = this.props; + const { filter } = this.state; + + if (!selectedSource) { + return this.renderPlaceholder(); + } + + if (!symbols) { + return this.renderLoading(); + } + + const symbolsToDisplay = symbols.functions.filter( + ({ name }) => name != "anonymous" + ); + + if (symbolsToDisplay.length === 0) { + return this.renderPlaceholder(); + } + + return ( + <div className="outline"> + <div> + <OutlineFilter filter={filter} updateFilter={this.updateFilter} /> + {this.renderFunctions(symbolsToDisplay)} + {this.renderFooter()} + </div> + </div> + ); + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + const symbols = getSymbols(state, getSelectedLocation(state)); + + return { + cx: getContext(state), + symbols, + selectedSource, + cursorPosition: getCursorPosition(state), + getFunctionText: line => { + if (selectedSource) { + const selectedSourceTextContent = getSelectedSourceTextContent(state); + return findFunctionText( + line, + selectedSource, + selectedSourceTextContent, + symbols + ); + } + + return null; + }, + }; +}; + +export default connect(mapStateToProps, { + selectLocation: actions.selectLocation, + flashLineRange: actions.flashLineRange, +})(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..354093fc31 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.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-filter-input.focused { + border: 1px solid var(--theme-highlight-blue); +} + +.outline-filter-input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.theme-dark .outline-filter-input.focused { + border: 1px solid var(--blue-50); +} 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..1d3daed0d9 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js @@ -0,0 +1,63 @@ +/* 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 "react"; +import PropTypes from "prop-types"; +const classnames = require("devtools/client/shared/classnames.js"); + +import "./OutlineFilter.css"; + +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> + <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} + /> + </form> + </div> + ); + } +} 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..f6d5e132ea --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css @@ -0,0 +1,165 @@ +/* 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); +} + +.project-text-search .result .line-value { + grid-column: 2; + padding-block: 1px; + padding-inline-end: 4px; + text-overflow: ellipsis; + overflow-x: hidden; +} + +.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-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..922e266c40 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js @@ -0,0 +1,327 @@ +/* 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 "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import actions from "../../actions"; + +import { getEditor } from "../../utils/editor"; +import { searchKeys } from "../../constants"; + +import { statusType } from "../../reducers/project-text-search"; +import { getRelativePath } from "../../utils/sources-tree/utils"; +import { getFormattedSourceId } from "../../utils/source"; +import { + getProjectSearchResults, + getProjectSearchStatus, + getProjectSearchQuery, + getContext, +} from "../../selectors"; + +import SearchInput from "../shared/SearchInput"; +import AccessibleImage from "../shared/AccessibleImage"; + +const { PluralForm } = require("devtools/shared/plural-form"); +const classnames = require("devtools/client/shared/classnames.js"); +const Tree = require("devtools/client/shared/components/Tree"); + +import "./ProjectSearch.css"; + +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 = { + inputValue: this.props.query || "", + inputFocused: false, + focusedItem: null, + expanded: new Set(), + }; + } + + static get propTypes() { + return { + clearSearch: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + doSearchForHighlight: PropTypes.func.isRequired, + query: PropTypes.string.isRequired, + results: PropTypes.array.isRequired, + searchSources: PropTypes.func.isRequired, + selectSpecificLocation: PropTypes.func.isRequired, + setActiveSearch: PropTypes.func.isRequired, + status: PropTypes.oneOf([ + "INITIAL", + "FETCHING", + "CANCELED", + "DONE", + "ERROR", + ]).isRequired, + modifiers: PropTypes.object, + toggleProjectSearchModifier: PropTypes.func, + }; + } + + componentDidMount() { + const { shortcuts } = this.context; + shortcuts.on("Enter", this.onEnterPress); + } + + componentWillUnmount() { + const { shortcuts } = this.context; + shortcuts.off("Enter", this.onEnterPress); + } + + componentDidUpdate(prevProps) { + // If the query changes in redux, also change it in the UI + if (prevProps.query !== this.props.query) { + this.setState({ inputValue: this.props.query }); + } + } + + doSearch(searchTerm) { + if (searchTerm) { + this.props.searchSources(this.props.cx, searchTerm); + } + } + + selectMatchItem = matchItem => { + this.props.selectSpecificLocation(this.props.cx, matchItem.location); + this.props.doSearchForHighlight( + this.state.inputValue, + 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> + <span className="query-match" key={1}> + {value.substr(matchIndex, len)} + </span> + <span className="line-match" key={2}> + {value.slice(matchIndex + len, value.length)} + </span> + </span> + ); + }; + + getResultCount = () => + this.props.results.reduce((count, file) => count + file.matches.length, 0); + + onKeyDown = e => { + if (e.key === "Escape") { + return; + } + + e.stopPropagation(); + + this.setState({ focusedItem: null }); + this.doSearch(this.state.inputValue); + }; + + onHistoryScroll = query => { + this.setState({ inputValue: query }); + }; + + onEnterPress = () => { + // This is to select a match from the search result. + if (!this.state.focusedItem || this.state.inputFocused) { + return; + } + if (this.state.focusedItem.type === "MATCH") { + this.selectMatchItem(this.state.focusedItem); + } + }; + + onFocus = item => { + if (this.state.focusedItem !== item) { + this.setState({ focusedItem: item }); + } + }; + + inputOnChange = e => { + const inputValue = e.target.value; + const { cx, clearSearch } = this.props; + this.setState({ inputValue }); + if (inputValue === "") { + clearSearch(cx); + } + }; + + 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} + > + <AccessibleImage className={classnames("arrow", { expanded })} /> + <AccessibleImage className="file" /> + <span className="file-path"> + {file.location.source.url + ? getRelativePath(file.location.source.url) + : getFormattedSourceId(file.location.source.id)} + </span> + <span className="matches-summary">{matches}</span> + </div> + ); + }; + + renderMatch = (match, focused) => { + return ( + <div + className={classnames("result", { focused })} + onClick={() => setTimeout(() => this.selectMatchItem(match), 50)} + > + <span className="line-number" key={match.location.line}> + {match.location.line} + </span> + {this.highlightMatches(match)} + </div> + ); + }; + + renderItem = (item, depth, focused, _, expanded) => { + if (item.type === "RESULT") { + return this.renderFile(item, focused, expanded); + } + return this.renderMatch(item, focused); + }; + + renderResults = () => { + const { status, results } = this.props; + if (!this.props.query) { + return null; + } + if (results.length) { + return ( + <Tree + getRoots={() => results} + getChildren={file => file.matches || []} + itemHeight={24} + autoExpandAll={true} + autoExpandDepth={1} + autoExpandNodeChildrenLimit={100} + getParent={item => null} + getPath={getFilePath} + renderItem={this.renderItem} + focused={this.state.focusedItem} + onFocus={this.onFocus} + 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}</div>; + }; + + renderSummary = () => { + if (this.props.query !== "") { + const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2"); + const count = this.getResultCount(); + return PluralForm.get(count, resultsSummaryString).replace("#1", count); + } + return ""; + }; + + shouldShowErrorEmoji() { + return !this.getResultCount() && this.props.status === statusType.done; + } + + renderInput() { + const { status } = this.props; + + return ( + <SearchInput + query={this.state.inputValue} + count={this.getResultCount()} + placeholder={L10N.getStr("projectTextSearch.placeholder")} + size="small" + showErrorEmoji={this.shouldShowErrorEmoji()} + summaryMsg={this.renderSummary()} + 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(this.state.inputValue)} + /> + ); + } + + render() { + return ( + <div className="search-container"> + <div className="project-text-search"> + <div className="header">{this.renderInput()}</div> + {this.renderResults()} + </div> + </div> + ); + } +} + +ProjectSearch.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = state => ({ + cx: getContext(state), + results: getProjectSearchResults(state), + query: getProjectSearchQuery(state), + status: getProjectSearchStatus(state), +}); + +export default connect(mapStateToProps, { + searchSources: actions.searchSources, + clearSearch: actions.clearSearch, + selectSpecificLocation: actions.selectSpecificLocation, + setActiveSearch: actions.setActiveSearch, + 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..e0e251cb47 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css @@ -0,0 +1,219 @@ +/* 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; +} + +.sources-panel * { + user-select: none; +} + +/***********************/ +/* 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); + 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..c570bdd5a0 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js @@ -0,0 +1,510 @@ +/* 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 } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +// Selectors +import { + getSelectedLocation, + getMainThreadHost, + getExpandedState, + getProjectDirectoryRoot, + getProjectDirectoryRootName, + getSourcesTreeSources, + getFocusedSourceItem, + getContext, + getGeneratedSourceByURL, + getBlackBoxRanges, + getHideIgnoredSources, +} from "../../selectors"; + +// Actions +import actions from "../../actions"; + +// Components +import SourcesTreeItem from "./SourcesTreeItem"; +import AccessibleImage from "../shared/AccessibleImage"; + +// Utils +import { getRawSourceURL } from "../../utils/source"; +import { createLocation } from "../../utils/location"; + +const classnames = require("devtools/client/shared/classnames.js"); +const Tree = require("devtools/client/shared/components/Tree"); + +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; +} + +/** + * Get the SourceItem displayed in the SourceTree for a given "tree location". + * + * @param {Object} treeLocation + * An object containing the Source coming from the sources.js reducer and the source actor + * See getTreeLocation(). + * @param {object} rootItems + * Result of getSourcesTreeSources selector, containing all sources sorted in a tree structure. + * items to be displayed in the source tree. + * @return {SourceItem} + * The directory source item where the given source is displayed. + */ +function getSourceItemForTreeLocation(treeLocation, rootItems) { + // Sources without URLs are not visible in the SourceTree + const { source, sourceActor } = treeLocation; + + if (!source.url) { + return null; + } + const { displayURL } = source; + function findSourceInItem(item, path) { + if (item.type == "source") { + if (item.source.url == source.url) { + return item; + } + return null; + } + // Bail out if we the current item doesn't match the source + if (item.type == "thread" && item.threadActorID != sourceActor?.thread) { + return null; + } + if (item.type == "group" && displayURL.group != item.groupName) { + return null; + } + if (item.type == "directory" && !path.startsWith(item.path)) { + return null; + } + // Otherwise, walk down the tree if this ancestor item seems to match + for (const child of item.children) { + const match = findSourceInItem(child, path); + if (match) { + return match; + } + } + + return null; + } + for (const rootItem of rootItems) { + // Note that when we are setting a project root, rootItem + // may no longer be only Thread Item, but also be Group, Directory or Source Items. + const item = findSourceInItem(rootItem, displayURL.path); + if (item) { + return item; + } + } + return null; +} + +class SourcesTree extends Component { + constructor(props) { + super(props); + + this.state = {}; + } + + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + mainThreadHost: PropTypes.string.isRequired, + expanded: PropTypes.object.isRequired, + focusItem: PropTypes.func.isRequired, + focused: PropTypes.object, + projectRoot: PropTypes.string.isRequired, + selectSource: PropTypes.func.isRequired, + selectedTreeLocation: PropTypes.object, + setExpandedState: PropTypes.func.isRequired, + blackBoxRanges: PropTypes.object.isRequired, + rootItems: PropTypes.object.isRequired, + clearProjectDirectoryRoot: PropTypes.func.isRequired, + projectRootName: PropTypes.string.isRequired, + setHideOrShowIgnoredSources: PropTypes.func.isRequired, + hideIgnoredSources: PropTypes.bool.isRequired, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { selectedTreeLocation } = this.props; + + // We might fail to find the source if its thread is registered late, + // so that we should re-search the selected source if state.focused is null. + if ( + nextProps.selectedTreeLocation?.source && + (nextProps.selectedTreeLocation.source != selectedTreeLocation?.source || + (nextProps.selectedTreeLocation.source === + selectedTreeLocation?.source && + nextProps.selectedTreeLocation.sourceActor != + selectedTreeLocation?.sourceActor) || + !this.props.focused) + ) { + const sourceItem = getSourceItemForTreeLocation( + nextProps.selectedTreeLocation, + this.props.rootItems + ); + if (sourceItem) { + // Walk up the tree to expand all ancestor items up to the root of the tree. + const expanded = new Set(this.props.expanded); + let parentDirectory = sourceItem; + while (parentDirectory) { + expanded.add(this.getKey(parentDirectory)); + parentDirectory = this.getParent(parentDirectory); + } + this.props.setExpandedState(expanded); + this.onFocus(sourceItem); + } + } + } + + selectSourceItem = item => { + this.props.selectSource(this.props.cx, 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) => { + const { expanded } = this.props; + 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} + </div> + ); + } + + 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); + }; + + /** + * Computes 4 lists: + * - `sourcesInside`: the list of all Source Items that are + * children of the current item (can be thread/group/directory). + * This include any nested level of children. + * - `sourcesOutside`: all other Source Items. + * i.e. all sources that are in any other folder of any group/thread. + * - `allInsideBlackBoxed`, all sources of `sourcesInside` which are currently + * blackboxed. + * - `allOutsideBlackBoxed`, all sources of `sourcesOutside` which are currently + * blackboxed. + */ + getBlackBoxSourcesGroups = item => { + const allSources = []; + function collectAllSources(list, _item) { + if (_item.children) { + _item.children.forEach(i => collectAllSources(list, i)); + } + if (_item.type == "source") { + list.push(_item.source); + } + } + for (const rootItem of this.props.rootItems) { + collectAllSources(allSources, rootItem); + } + + const sourcesInside = []; + collectAllSources(sourcesInside, item); + + const sourcesOutside = allSources.filter( + source => !sourcesInside.includes(source) + ); + const allInsideBlackBoxed = sourcesInside.every( + source => this.props.blackBoxRanges[source.url] + ); + const allOutsideBlackBoxed = sourcesOutside.every( + source => this.props.blackBoxRanges[source.url] + ); + + return { + sourcesInside, + sourcesOutside, + allInsideBlackBoxed, + allOutsideBlackBoxed, + }; + }; + + renderProjectRootHeader() { + const { cx, 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(cx)} + title={L10N.getStr("removeDirectoryRoot.label")} + > + <AccessibleImage className="home" /> + <AccessibleImage className="breadcrumb" /> + <span className="sources-clear-root-label">{projectRootName}</span> + </button> + </div> + ); + } + + renderItem = (item, depth, focused, _, expanded) => { + const { mainThreadHost, projectRoot } = this.props; + return ( + <SourcesTreeItem + item={item} + depth={depth} + focused={focused} + autoExpand={shouldAutoExpand(item, mainThreadHost)} + expanded={expanded} + focusItem={this.onFocus} + selectSourceItem={this.selectSourceItem} + projectRoot={projectRoot} + setExpanded={this.setExpanded} + getBlackBoxSourcesGroups={this.getBlackBoxSourcesGroups} + 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, + itemHeight: 21, + key: this.isEmpty() ? "empty" : "full", + 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 <Tree {...treeProps} />; + } + + renderPane(child) { + const { projectRoot } = this.props; + + return ( + <div + key="pane" + className={classnames("sources-pane", { + "sources-list-custom-root": !!projectRoot, + })} + > + {child} + </div> + ); + } + + 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")} + </button> + </footer> + ); + } + 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")) + ) : ( + <> + {this.renderProjectRootHeader()} + {this.renderTree()} + {this.renderFooter()} + </> + )} + </div> + ); + } +} + +function getTreeLocation(state, location) { + // In the SourceTree, we never show the pretty printed sources and only + // the minified version, so if we are selecting a pretty file, fake selecting + // the minified version. + if (location?.source.isPrettyPrinted) { + const source = getGeneratedSourceByURL( + state, + getRawSourceURL(location.source.url) + ); + if (source) { + return createLocation({ + source, + // A source actor is required by getSourceItemForTreeLocation + // in order to know in which thread this source relates to. + sourceActor: location.sourceActor, + }); + } + } + return location; +} + +const mapStateToProps = state => { + const rootItems = getSourcesTreeSources(state); + + return { + cx: getContext(state), + selectedTreeLocation: getTreeLocation(state, getSelectedLocation(state)), + mainThreadHost: getMainThreadHost(state), + expanded: getExpandedState(state), + focused: getFocusedSourceItem(state), + projectRoot: getProjectDirectoryRoot(state), + rootItems, + blackBoxRanges: getBlackBoxRanges(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..874df4c77c --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js @@ -0,0 +1,457 @@ +/* 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 "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import { showMenu } from "../../context-menu/menu"; + +import SourceIcon from "../shared/SourceIcon"; +import AccessibleImage from "../shared/AccessibleImage"; + +import { + getGeneratedSourceByURL, + getContext, + getFirstSourceActorForGeneratedSource, + isSourceOverridden, + getHideIgnoredSources, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; +import actions from "../../actions"; + +import { shouldBlackbox, sourceTypes } from "../../utils/source"; +import { copyToTheClipboard } from "../../utils/clipboard"; +import { saveAsLocalFile } from "../../utils/utils"; +import { createLocation } from "../../utils/location"; +import { safeDecodeItemName } from "../../utils/sources-tree/utils"; + +const classnames = require("devtools/client/shared/classnames.js"); + +class SourceTreeItem extends Component { + static get propTypes() { + return { + autoExpand: PropTypes.bool.isRequired, + blackBoxSources: PropTypes.func.isRequired, + clearProjectDirectoryRoot: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + depth: PropTypes.number.isRequired, + expanded: PropTypes.bool.isRequired, + focusItem: PropTypes.func.isRequired, + focused: PropTypes.bool.isRequired, + getBlackBoxSourcesGroups: PropTypes.func.isRequired, + hasMatchingGeneratedSource: PropTypes.bool.isRequired, + item: PropTypes.object.isRequired, + loadSourceText: PropTypes.func.isRequired, + getFirstSourceActorForGeneratedSource: PropTypes.func.isRequired, + projectRoot: PropTypes.string.isRequired, + selectSourceItem: PropTypes.func.isRequired, + setExpanded: PropTypes.func.isRequired, + setProjectDirectoryRoot: PropTypes.func.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + getParent: PropTypes.func.isRequired, + setOverrideSource: PropTypes.func.isRequired, + removeOverrideSource: PropTypes.func.isRequired, + isOverridden: PropTypes.bool, + hideIgnoredSources: PropTypes.bool, + isSourceOnIgnoreList: 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 => { + const copySourceUri2Label = L10N.getStr("copySourceUri2"); + const copySourceUri2Key = L10N.getStr("copySourceUri2.accesskey"); + const setDirectoryRootLabel = L10N.getStr("setDirectoryRoot.label"); + const setDirectoryRootKey = L10N.getStr("setDirectoryRoot.accesskey"); + const removeDirectoryRootLabel = L10N.getStr("removeDirectoryRoot.label"); + + event.stopPropagation(); + event.preventDefault(); + + const menuOptions = []; + + const { item, isOverridden, cx, isSourceOnIgnoreList } = this.props; + if (item.type == "source") { + const { source } = item; + const copySourceUri2 = { + id: "node-menu-copy-source", + label: copySourceUri2Label, + accesskey: copySourceUri2Key, + disabled: false, + click: () => copyToTheClipboard(source.url), + }; + + const ignoreStr = item.isBlackBoxed ? "unignore" : "ignore"; + const blackBoxMenuItem = { + id: "node-menu-blackbox", + label: L10N.getStr(`ignoreContextItem.${ignoreStr}`), + accesskey: L10N.getStr(`ignoreContextItem.${ignoreStr}.accesskey`), + disabled: isSourceOnIgnoreList || !shouldBlackbox(source), + click: () => this.props.toggleBlackBox(cx, source), + }; + const downloadFileItem = { + id: "node-menu-download-file", + label: L10N.getStr("downloadFile.label"), + accesskey: L10N.getStr("downloadFile.accesskey"), + disabled: false, + click: () => this.saveLocalFile(cx, source), + }; + + const overrideStr = !isOverridden ? "override" : "removeOverride"; + const overridesItem = { + id: "node-menu-overrides", + label: L10N.getStr(`overridesContextItem.${overrideStr}`), + accesskey: L10N.getStr(`overridesContextItem.${overrideStr}.accesskey`), + disabled: !!source.isHTML, + click: () => this.handleLocalOverride(cx, source, isOverridden), + }; + + menuOptions.push( + copySourceUri2, + blackBoxMenuItem, + downloadFileItem, + overridesItem + ); + } + + // All other types other than source are folder-like + if (item.type != "source") { + this.addCollapseExpandAllOptions(menuOptions, item); + + const { depth, projectRoot } = this.props; + + if (projectRoot == item.uniquePath) { + menuOptions.push({ + id: "node-remove-directory-root", + label: removeDirectoryRootLabel, + disabled: false, + click: () => this.props.clearProjectDirectoryRoot(cx), + }); + } else { + menuOptions.push({ + id: "node-set-directory-root", + label: setDirectoryRootLabel, + accesskey: setDirectoryRootKey, + disabled: false, + click: () => + this.props.setProjectDirectoryRoot( + cx, + item.uniquePath, + this.renderItemName(depth) + ), + }); + } + + this.addBlackboxAllOption(menuOptions, item); + } + + showMenu(event, menuOptions); + }; + + saveLocalFile = async (cx, source) => { + if (!source) { + return null; + } + + const data = await this.props.loadSourceText(cx, source); + if (!data) { + return null; + } + return saveAsLocalFile(data.value, source.displayURL.filename); + }; + + handleLocalOverride = async (cx, source, isOverridden) => { + if (!isOverridden) { + const localPath = await this.saveLocalFile(cx, source); + if (localPath) { + this.props.setOverrideSource(cx, source, localPath); + } + } else { + this.props.removeOverrideSource(cx, source); + } + }; + + addBlackboxAllOption = (menuOptions, item) => { + const { cx, depth, projectRoot } = this.props; + const { + sourcesInside, + sourcesOutside, + allInsideBlackBoxed, + allOutsideBlackBoxed, + } = this.props.getBlackBoxSourcesGroups(item); + + let blackBoxInsideMenuItemLabel; + let blackBoxOutsideMenuItemLabel; + if (depth === 0 || (depth === 1 && projectRoot === "")) { + blackBoxInsideMenuItemLabel = allInsideBlackBoxed + ? L10N.getStr("unignoreAllInGroup.label") + : L10N.getStr("ignoreAllInGroup.label"); + if (sourcesOutside.length) { + blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed + ? L10N.getStr("unignoreAllOutsideGroup.label") + : L10N.getStr("ignoreAllOutsideGroup.label"); + } + } else { + blackBoxInsideMenuItemLabel = allInsideBlackBoxed + ? L10N.getStr("unignoreAllInDir.label") + : L10N.getStr("ignoreAllInDir.label"); + if (sourcesOutside.length) { + blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed + ? L10N.getStr("unignoreAllOutsideDir.label") + : L10N.getStr("ignoreAllOutsideDir.label"); + } + } + + const blackBoxInsideMenuItem = { + id: allInsideBlackBoxed + ? "node-unblackbox-all-inside" + : "node-blackbox-all-inside", + label: blackBoxInsideMenuItemLabel, + disabled: false, + click: () => + this.props.blackBoxSources(cx, sourcesInside, !allInsideBlackBoxed), + }; + + if (sourcesOutside.length) { + menuOptions.push({ + id: "node-blackbox-all", + label: L10N.getStr("ignoreAll.label"), + submenu: [ + blackBoxInsideMenuItem, + { + id: allOutsideBlackBoxed + ? "node-unblackbox-all-outside" + : "node-blackbox-all-outside", + label: blackBoxOutsideMenuItemLabel, + disabled: false, + click: () => + this.props.blackBoxSources( + cx, + sourcesOutside, + !allOutsideBlackBoxed + ), + }, + ], + }); + } else { + menuOptions.push(blackBoxInsideMenuItem); + } + }; + + addCollapseExpandAllOptions = (menuOptions, item) => { + const { setExpanded } = this.props; + + menuOptions.push({ + id: "node-menu-collapse-all", + label: L10N.getStr("collapseAll.label"), + disabled: false, + click: () => setExpanded(item, false, true), + }); + + menuOptions.push({ + id: "node-menu-expand-all", + label: L10N.getStr("expandAll.label"), + disabled: false, + click: () => setExpanded(item, true, true), + }); + }; + + renderItemArrow() { + const { item, expanded } = this.props; + return item.type != "source" ? ( + <AccessibleImage className={classnames("arrow", { expanded })} /> + ) : ( + <span className="img no-arrow" /> + ); + } + + renderIcon(item, depth) { + if (item.type == "thread") { + const icon = item.thread.targetType.includes("worker") + ? "worker" + : "window"; + return <AccessibleImage className={classnames(icon)} />; + } + if (item.type == "group") { + if (item.groupName === "Webpack") { + return <AccessibleImage className="webpack" />; + } else if (item.groupName === "Angular") { + return <AccessibleImage className="angular" />; + } + // Check if the group relates to an extension. + // This happens when a webextension injects a content script. + if (item.isForExtensionSource) { + return <AccessibleImage className="extension" />; + } + + return <AccessibleImage className="globe-small" />; + } + if (item.type == "directory") { + return <AccessibleImage className="folder" />; + } + if (item.type == "source") { + const { source, sourceActor } = item; + return ( + <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(depth) { + 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, + depth, + focused, + hasMatchingGeneratedSource, + hideIgnoredSources, + } = this.props; + + if (hideIgnoredSources && item.isBlackBoxed) { + return null; + } + const suffix = hasMatchingGeneratedSource ? ( + <span className="suffix">{L10N.getStr("sourceFooter.mappedSuffix")}</span> + ) : 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, depth)} + <span className="label"> + {this.renderItemName(depth)} + {suffix} + </span> + </div> + ); + } +} + +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 { + cx: getContext(state), + hasMatchingGeneratedSource: getHasMatchingGeneratedSource(state, source), + getFirstSourceActorForGeneratedSource: (sourceId, threadId) => + getFirstSourceActorForGeneratedSource(state, sourceId, threadId), + isOverridden: isSourceOverridden(state, source), + hideIgnoredSources: getHideIgnoredSources(state), + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source), + }; + } + return { + cx: getContext(state), + getFirstSourceActorForGeneratedSource: (sourceId, threadId) => + getFirstSourceActorForGeneratedSource(state, sourceId, threadId), + }; +}; + +export default connect(mapStateToProps, { + setProjectDirectoryRoot: actions.setProjectDirectoryRoot, + clearProjectDirectoryRoot: actions.clearProjectDirectoryRoot, + toggleBlackBox: actions.toggleBlackBox, + loadSourceText: actions.loadSourceText, + blackBoxSources: actions.blackBoxSources, + setBlackBoxAllOutside: actions.setBlackBoxAllOutside, + setOverrideSource: actions.setOverrideSource, + removeOverrideSource: actions.removeOverrideSource, +})(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..c0ab3075bd --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/index.js @@ -0,0 +1,132 @@ +/* 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 "react"; +import PropTypes from "prop-types"; +import { Tab, Tabs, TabList, TabPanels } from "react-aria-components/src/tabs"; + +import actions from "../../actions"; +import { getSelectedPrimaryPaneTab, getContext } from "../../selectors"; +import { prefs } from "../../utils/prefs"; +import { connect } from "../../utils/connect"; +import { primaryPaneTabs } from "../../constants"; +import { formatKeyShortcut } from "../../utils/text"; + +import Outline from "./Outline"; +import SourcesTree from "./SourcesTree"; +import ProjectSearch from "./ProjectSearch"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./Sources.css"; + +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 { + cx: PropTypes.object.isRequired, + 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(); + } + }; + + renderTabList() { + return [ + <Tab + className={classnames("tab sources-tab", { + active: this.props.selectedTab === primaryPaneTabs.SOURCES, + })} + key="sources-tab" + > + {formatKeyShortcut(L10N.getStr("sources.header"))} + </Tab>, + <Tab + className={classnames("tab outline-tab", { + active: this.props.selectedTab === primaryPaneTabs.OUTLINE, + })} + key="outline-tab" + > + {formatKeyShortcut(L10N.getStr("outline.header"))} + </Tab>, + <Tab + className={classnames("tab search-tab", { + active: this.props.selectedTab === primaryPaneTabs.PROJECT_SEARCH, + })} + key="search-tab" + > + {formatKeyShortcut(L10N.getStr("search.header"))} + </Tab>, + ]; + } + + render() { + const { selectedTab } = this.props; + return ( + <Tabs + activeIndex={tabs.indexOf(selectedTab)} + className="sources-panel" + onActivateTab={this.onActivateTab} + > + <TabList className="source-outline-tabs"> + {this.renderTabList()} + </TabList> + <TabPanels className="source-outline-panel" hasFocusableContent> + <SourcesTree /> + <Outline + alphabetizeOutline={this.state.alphabetizeOutline} + onAlphabetizeClick={this.onAlphabetizeClick} + /> + <ProjectSearch /> + </TabPanels> + </Tabs> + ); + } +} + +const mapStateToProps = state => { + return { + cx: getContext(state), + 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", +) diff --git a/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js b/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js new file mode 100644 index 0000000000..10f9f197fe --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js @@ -0,0 +1,326 @@ +/* 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 from "react"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import PropTypes from "prop-types"; + +import { mount, shallow } from "enzyme"; +import { ProjectSearch } from "../ProjectSearch"; +import { statusType } from "../../../reducers/project-text-search"; +import { mockcx } from "../../../utils/test-mockup"; +import { searchKeys } from "../../../constants"; + +const hooks = { on: [], off: [] }; +const shortcuts = { + dispatch(eventName) { + hooks.on.forEach(hook => { + if (hook.event === eventName) { + hook.cb(); + } + }); + hooks.off.forEach(hook => { + if (hook.event === eventName) { + hook.cb(); + } + }); + }, + on: jest.fn((event, cb) => hooks.on.push({ event, cb })), + off: jest.fn((event, cb) => hooks.off.push({ event, cb })), +}; + +const context = { shortcuts }; + +const testResults = [ + { + location: { + source: { + url: "testFilePath1", + }, + }, + type: "RESULT", + matches: [ + { + match: "match1", + value: "some thing match1", + location: { + source: {}, + column: 30, + }, + type: "MATCH", + }, + { + match: "match2", + value: "some thing match2", + location: { + source: {}, + column: 60, + }, + type: "MATCH", + }, + { + match: "match3", + value: "some thing match3", + location: { + source: {}, + column: 90, + }, + type: "MATCH", + }, + ], + }, + { + location: { + source: { + url: "testFilePath2", + }, + }, + type: "RESULT", + matches: [ + { + match: "match4", + value: "some thing match4", + location: { + source: {}, + column: 80, + }, + type: "MATCH", + }, + { + match: "match5", + value: "some thing match5", + location: { + source: {}, + column: 40, + }, + type: "MATCH", + }, + ], + }, +]; + +const testMatch = { + type: "MATCH", + match: "match1", + value: "some thing match1", + sourceId: "some-target/source42", + location: { + source: { + id: "some-target/source42", + }, + line: 3, + column: 30, + }, +}; + +function render(overrides = {}, mounted = false) { + const mockStore = configureStore([]); + const store = mockStore({ + ui: { + mutableSearchOptions: { + [searchKeys.PROJECT_SEARCH]: { + regexMatch: false, + wholeWord: false, + caseSensitive: false, + excludePatterns: "", + }, + }, + }, + }); + const props = { + cx: mockcx, + status: "DONE", + sources: {}, + results: [], + query: "foo", + activeSearch: "project", + closeProjectSearch: jest.fn(), + searchSources: jest.fn(), + clearSearch: jest.fn(), + updateSearchStatus: jest.fn(), + selectSpecificLocation: jest.fn(), + doSearchForHighlight: jest.fn(), + setActiveSearch: jest.fn(), + ...overrides, + }; + + if (mounted) { + return mount( + <Provider store={store}> + <ProjectSearch {...props} /> + </Provider>, + { context, childContextTypes: { shortcuts: PropTypes.object } } + ).childAt(0); + } + + return shallow( + <Provider store={store}> + <ProjectSearch {...props} /> + </Provider>, + { context } + ).dive(); +} + +describe("ProjectSearch", () => { + beforeEach(() => { + context.shortcuts.on.mockClear(); + context.shortcuts.off.mockClear(); + }); + + it("renders nothing when disabled", () => { + const component = render({ activeSearch: "" }); + expect(component).toMatchSnapshot(); + }); + + it("where <Enter> has not been pressed", () => { + const component = render({ query: "" }); + expect(component).toMatchSnapshot(); + }); + + it("found no search results", () => { + const component = render(); + expect(component).toMatchSnapshot(); + }); + + it("should display loading message while search is in progress", () => { + const component = render({ + query: "match", + status: statusType.fetching, + }); + expect(component).toMatchSnapshot(); + }); + + it("found search results", () => { + const component = render( + { + query: "match", + results: testResults, + }, + true + ); + expect(component).toMatchSnapshot(); + }); + + it("turns off shortcuts on unmount", () => { + const component = render({ + query: "", + }); + expect(component).toMatchSnapshot(); + component.unmount(); + expect(context.shortcuts.off).toHaveBeenCalled(); + }); + + it("calls inputOnChange", () => { + const component = render( + { + results: testResults, + }, + true + ); + component + .find("SearchInput .search-field input") + .simulate("change", { target: { value: "bar" } }); + expect(component.state().inputValue).toEqual("bar"); + }); + + it("onKeyDown Escape/Other", () => { + const searchSources = jest.fn(); + const component = render( + { + results: testResults, + searchSources, + }, + true + ); + component + .find("SearchInput .search-field input") + .simulate("keydown", { key: "Escape" }); + expect(searchSources).not.toHaveBeenCalled(); + searchSources.mockClear(); + component + .find("SearchInput .search-field input") + .simulate("keydown", { key: "Other", stopPropagation: jest.fn() }); + expect(searchSources).not.toHaveBeenCalled(); + }); + + it("onKeyDown Enter", () => { + const searchSources = jest.fn(); + const component = render( + { + results: testResults, + searchSources, + }, + true + ); + component + .find("SearchInput .search-field input") + .simulate("keydown", { key: "Enter", stopPropagation: jest.fn() }); + expect(searchSources).toHaveBeenCalledWith(mockcx, "foo"); + }); + + it("onEnterPress shortcut no match or setExpanded", () => { + const selectSpecificLocation = jest.fn(); + const component = render( + { + results: testResults, + selectSpecificLocation, + }, + true + ); + component.instance().state.focusedItem = null; + shortcuts.dispatch("Enter"); + expect(selectSpecificLocation).not.toHaveBeenCalled(); + }); + + it("onEnterPress shortcut match", () => { + const selectSpecificLocation = jest.fn(); + const component = render( + { + results: testResults, + selectSpecificLocation, + }, + true + ); + component.instance().state.focusedItem = { ...testMatch }; + shortcuts.dispatch("Enter"); + expect(selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + source: { + id: "some-target/source42", + }, + line: 3, + column: 30, + }); + }); + + it("state.inputValue responds to prop.query changes", () => { + const component = render({ query: "foo" }); + expect(component.state().inputValue).toEqual("foo"); + component.setProps({ query: "" }); + expect(component.state().inputValue).toEqual(""); + }); + + describe("showErrorEmoji", () => { + it("false if not done & results", () => { + const component = render({ + status: statusType.fetching, + results: testResults, + }); + expect(component).toMatchSnapshot(); + }); + + it("false if not done & no results", () => { + const component = render({ + status: statusType.fetching, + }); + expect(component).toMatchSnapshot(); + }); + + // "false if done & has results" + // is the same test as "found search results" + + // "true if done & has no results" + // is the same test as "found no search results" + }); +}); diff --git a/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap b/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap new file mode 100644 index 0000000000..4be18c4753 --- /dev/null +++ b/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap @@ -0,0 +1,1111 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProjectSearch found no search results 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="foo" + searchKey="project-search" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="0 results" + /> + </div> + <div + className="no-result-msg absolute-center" + > + No results found + </div> + </div> +</div> +`; + +exports[`ProjectSearch found search results 1`] = ` +<ProjectSearch + activeSearch="project" + clearSearch={[MockFunction]} + closeProjectSearch={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + doSearchForHighlight={[MockFunction]} + query="match" + results={ + Array [ + Object { + "location": Object { + "source": Object { + "url": "testFilePath1", + }, + }, + "matches": Array [ + Object { + "location": Object { + "column": 30, + "source": Object {}, + }, + "match": "match1", + "type": "MATCH", + "value": "some thing match1", + }, + Object { + "location": Object { + "column": 60, + "source": Object {}, + }, + "match": "match2", + "type": "MATCH", + "value": "some thing match2", + }, + Object { + "location": Object { + "column": 90, + "source": Object {}, + }, + "match": "match3", + "type": "MATCH", + "value": "some thing match3", + }, + ], + "type": "RESULT", + }, + Object { + "location": Object { + "source": Object { + "url": "testFilePath2", + }, + }, + "matches": Array [ + Object { + "location": Object { + "column": 80, + "source": Object {}, + }, + "match": "match4", + "type": "MATCH", + "value": "some thing match4", + }, + Object { + "location": Object { + "column": 40, + "source": Object {}, + }, + "match": "match5", + "type": "MATCH", + "value": "some thing match5", + }, + ], + "type": "RESULT", + }, + ] + } + searchSources={[MockFunction]} + selectSpecificLocation={[MockFunction]} + setActiveSearch={[MockFunction]} + sources={Object {}} + status="DONE" + updateSearchStatus={[MockFunction]} +> + <div + className="search-container" + > + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={5} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="match" + searchKey="project-search" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="5 results" + > + <SearchInput + count={5} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + expanded={false} + hasPrefix={false} + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="match" + searchKey="project-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="5 results" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field small" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Find in files…" + spellCheck={false} + value="match" + /> + <div + className="search-field-summary" + > + 5 results + </div> + <div + className="search-buttons-bar" + > + <SearchModifiers + modifiers={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + onToggleSearchModifier={[Function]} + > + <div + className="search-modifiers" + > + <span + className="pipe-divider" + /> + <button + className="regex-match-btn " + onKeyDown={[Function]} + onMouseDown={[Function]} + title="Use Regular Expression" + > + <span + className="regex-match" + /> + </button> + <button + className="case-sensitive-btn " + onKeyDown={[Function]} + onMouseDown={[Function]} + title="Match Case" + > + <span + className="case-match" + /> + </button> + <button + className="whole-word-btn " + onKeyDown={[Function]} + onMouseDown={[Function]} + title="Match Whole Word" + > + <span + className="whole-word-match" + /> + </button> + </div> + </SearchModifiers> + </div> + </div> + <div + className="exclude-patterns-field small" + > + <label> + files to exclude + </label> + <input + onChange={[Function]} + onKeyDown={[Function]} + placeholder="e.g. **/node_modules/**,app.js" + value="" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + <Tree + autoExpandAll={true} + autoExpandDepth={1} + autoExpandNodeChildrenLimit={100} + focused={null} + getChildren={[Function]} + getKey={[Function]} + getParent={[Function]} + getPath={[Function]} + getRoots={[Function]} + isExpanded={[Function]} + itemHeight={24} + onCollapse={[Function]} + onExpand={[Function]} + onFocus={[Function]} + renderItem={[Function]} + > + <div + aria-activedescendant={null} + className="tree " + onBlur={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyPress={[Function]} + onKeyUp={[Function]} + role="tree" + style={Object {}} + tabIndex="0" + > + <TreeNode + active={false} + depth={0} + expanded={true} + focused={false} + id="undefined-$" + index={0} + isExpandable={true} + item={ + Object { + "location": Object { + "source": Object { + "url": "testFilePath1", + }, + }, + "matches": Array [ + Object { + "location": Object { + "column": 30, + "source": Object {}, + }, + "match": "match1", + "type": "MATCH", + "value": "some thing match1", + }, + Object { + "location": Object { + "column": 60, + "source": Object {}, + }, + "match": "match2", + "type": "MATCH", + "value": "some thing match2", + }, + Object { + "location": Object { + "column": 90, + "source": Object {}, + }, + "match": "match3", + "type": "MATCH", + "value": "some thing match3", + }, + ], + "type": "RESULT", + } + } + key="undefined-$-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-expanded={true} + aria-level={1} + className="tree-node" + data-expandable={true} + id="undefined-$" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <div + className="file-result" + > + <AccessibleImage + className="arrow expanded" + > + <span + className="img arrow expanded" + /> + </AccessibleImage> + <AccessibleImage + className="file" + > + <span + className="img file" + /> + </AccessibleImage> + <span + className="file-path" + /> + <span + className="matches-summary" + > + (3 matches) + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-30-1" + index={1} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 30, + "source": Object {}, + }, + "match": "match1", + "type": "MATCH", + "value": "some thing match1", + } + } + key="undefined-undefined-30-1-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-30-1" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match1 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match1 + </span> + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-60-2" + index={2} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 60, + "source": Object {}, + }, + "match": "match2", + "type": "MATCH", + "value": "some thing match2", + } + } + key="undefined-undefined-60-2-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-60-2" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match2 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match2 + </span> + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-90-3" + index={3} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 90, + "source": Object {}, + }, + "match": "match3", + "type": "MATCH", + "value": "some thing match3", + } + } + key="undefined-undefined-90-3-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-90-3" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match3 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match3 + </span> + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={0} + expanded={true} + focused={false} + id="undefined-4" + index={4} + isExpandable={true} + item={ + Object { + "location": Object { + "source": Object { + "url": "testFilePath2", + }, + }, + "matches": Array [ + Object { + "location": Object { + "column": 80, + "source": Object {}, + }, + "match": "match4", + "type": "MATCH", + "value": "some thing match4", + }, + Object { + "location": Object { + "column": 40, + "source": Object {}, + }, + "match": "match5", + "type": "MATCH", + "value": "some thing match5", + }, + ], + "type": "RESULT", + } + } + key="undefined-4-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-expanded={true} + aria-level={1} + className="tree-node" + data-expandable={true} + id="undefined-4" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <div + className="file-result" + > + <AccessibleImage + className="arrow expanded" + > + <span + className="img arrow expanded" + /> + </AccessibleImage> + <AccessibleImage + className="file" + > + <span + className="img file" + /> + </AccessibleImage> + <span + className="file-path" + /> + <span + className="matches-summary" + > + (2 matches) + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-80-5" + index={5} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 80, + "source": Object {}, + }, + "match": "match4", + "type": "MATCH", + "value": "some thing match4", + } + } + key="undefined-undefined-80-5-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-80-5" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match4 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match4 + </span> + </span> + </div> + </div> + </TreeNode> + <TreeNode + active={false} + depth={1} + expanded={false} + focused={false} + id="undefined-undefined-40-6" + index={6} + isExpandable={false} + item={ + Object { + "location": Object { + "column": 40, + "source": Object {}, + }, + "match": "match5", + "type": "MATCH", + "value": "some thing match5", + } + } + key="undefined-undefined-40-6-inactive" + onClick={[Function]} + onCollapse={[Function]} + onExpand={[Function]} + renderItem={[Function]} + > + <div + aria-level={2} + className="tree-node" + data-expandable={false} + id="undefined-undefined-40-6" + onClick={[Function]} + onKeyDownCapture={null} + role="treeitem" + > + <span + className="tree-indent tree-last-indent" + > + + </span> + <div + className="result" + onClick={[Function]} + > + <span + className="line-number" + /> + <span + className="line-value" + > + <span + className="line-match" + key="0" + > + some thing match5 + </span> + <span + className="query-match" + key="1" + > + some t + </span> + <span + className="line-match" + key="2" + > + some thing match5 + </span> + </span> + </div> + </div> + </TreeNode> + </div> + </Tree> + </div> + </div> +</ProjectSearch> +`; + +exports[`ProjectSearch renders nothing when disabled 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="foo" + searchKey="project-search" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="0 results" + /> + </div> + <div + className="no-result-msg absolute-center" + > + No results found + </div> + </div> +</div> +`; + +exports[`ProjectSearch should display loading message while search is in progress 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={true} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="match" + searchKey="project-search" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="0 results" + /> + </div> + <div + className="no-result-msg absolute-center" + > + Loading… + </div> + </div> +</div> +`; + +exports[`ProjectSearch showErrorEmoji false if not done & no results 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={true} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="foo" + searchKey="project-search" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="0 results" + /> + </div> + <div + className="no-result-msg absolute-center" + > + Loading… + </div> + </div> +</div> +`; + +exports[`ProjectSearch showErrorEmoji false if not done & results 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={5} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={true} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="foo" + searchKey="project-search" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="5 results" + /> + </div> + <Tree + autoExpandAll={true} + autoExpandDepth={1} + autoExpandNodeChildrenLimit={100} + focused={null} + getChildren={[Function]} + getKey={[Function]} + getParent={[Function]} + getPath={[Function]} + getRoots={[Function]} + isExpanded={[Function]} + itemHeight={24} + onCollapse={[Function]} + onExpand={[Function]} + onFocus={[Function]} + renderItem={[Function]} + /> + </div> +</div> +`; + +exports[`ProjectSearch turns off shortcuts on unmount 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="" + searchKey="project-search" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="" + /> + </div> + </div> +</div> +`; + +exports[`ProjectSearch where <Enter> has not been pressed 1`] = ` +<div + className="search-container" +> + <div + className="project-text-search" + > + <div + className="header" + > + <Connect(SearchInput) + count={0} + excludePatternsLabel="files to exclude" + excludePatternsPlaceholder="e.g. **/node_modules/**,app.js" + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + onToggleSearchModifier={[Function]} + placeholder="Find in files…" + query="" + searchKey="project-search" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={true} + showSearchModifiers={true} + size="small" + summaryMsg="" + /> + </div> + </div> +</div> +`; |