diff options
Diffstat (limited to 'devtools/client/debugger/src/components')
201 files changed, 36076 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/A11yIntention.css b/devtools/client/debugger/src/components/A11yIntention.css new file mode 100644 index 0000000000..e97a03ad32 --- /dev/null +++ b/devtools/client/debugger/src/components/A11yIntention.css @@ -0,0 +1,7 @@ +/* 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/. */ + +.A11y-mouse :focus { + outline: 0; +} diff --git a/devtools/client/debugger/src/components/A11yIntention.js b/devtools/client/debugger/src/components/A11yIntention.js new file mode 100644 index 0000000000..fab894b216 --- /dev/null +++ b/devtools/client/debugger/src/components/A11yIntention.js @@ -0,0 +1,37 @@ +/* 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 PropTypes from "prop-types"; +import "./A11yIntention.css"; + +export default class A11yIntention extends React.Component { + static get propTypes() { + return { + children: PropTypes.array.isRequired, + }; + } + + state = { keyboard: false }; + + handleKeyDown = () => { + this.setState({ keyboard: true }); + }; + + handleMouseDown = () => { + this.setState({ keyboard: false }); + }; + + render() { + return ( + <div + className={this.state.keyboard ? "A11y-keyboard" : "A11y-mouse"} + onKeyDown={this.handleKeyDown} + onMouseDown={this.handleMouseDown} + > + {this.props.children} + </div> + ); + } +} diff --git a/devtools/client/debugger/src/components/App.css b/devtools/client/debugger/src/components/App.css new file mode 100644 index 0000000000..6a793c2f48 --- /dev/null +++ b/devtools/client/debugger/src/components/App.css @@ -0,0 +1,130 @@ +/* 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/>. */ + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; +} + +#mount { + height: 100%; +} + +button { + background: transparent; + border: none; + font-family: inherit; + font-size: inherit; +} + +button:hover, +button:focus { + background-color: var(--theme-toolbar-background-hover); +} + +.theme-dark button:hover, +.theme-dark button:focus { + background-color: var(--theme-toolbar-hover); +} + +.debugger { + display: flex; + flex: 1; + height: 100%; +} + +.debugger .tree-indent { + width: 16px; + margin-inline-start: 0; + border-inline-start: 0; +} + +.editor-pane { + display: flex; + position: relative; + flex: 1; + background-color: var(--theme-body-background); + height: 100%; + overflow: hidden; +} + +.editor-container { + width: 100%; +} + +/* Utils */ +.absolute-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.d-flex { + display: flex; +} + +.align-items-center { + align-items: center; +} + +.rounded-circle { + border-radius: 50%; +} + +.text-white { + color: white; +} + +.text-center { + text-align: center; +} + +.min-width-0 { + min-width: 0; +} + +/* + Prevents horizontal scrollbar from displaying when + right pane collapsed (#7505) +*/ +.split-box > .splitter:last-child { + display: none; +} + +/** + * In RTL layouts, the Debugger UI overlays the splitters. See Bug 1731233. + * Note: we need to the `.debugger` prefix here to beat the specificity of the + * general rule defined in SlitBox.css for `.split-box.vert > .splitter`. + */ +.debugger .split-box.vert > .splitter { + border-left-width: var(--devtools-splitter-inline-start-width); + border-right-width: var(--devtools-splitter-inline-end-width); + + margin-left: calc(-1 * var(--devtools-splitter-inline-start-width) - 1px); + margin-right: calc(-1 * var(--devtools-splitter-inline-end-width)); +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; + background: transparent; +} + +::-webkit-scrollbar-track { + border-radius: 8px; + background: transparent; +} + +::-webkit-scrollbar-thumb { + border-radius: 8px; + background: rgba(113, 113, 113, 0.5); +} diff --git a/devtools/client/debugger/src/components/App.js b/devtools/client/debugger/src/components/App.js new file mode 100644 index 0000000000..011d743cd9 --- /dev/null +++ b/devtools/client/debugger/src/components/App.js @@ -0,0 +1,336 @@ +/* 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 { prefs } from "../utils/prefs"; +import { primaryPaneTabs } from "../constants"; +import actions from "../actions"; +import A11yIntention from "./A11yIntention"; +import { ShortcutsModal } from "./ShortcutsModal"; + +import { + getSelectedSource, + getPaneCollapse, + getActiveSearch, + getQuickOpenEnabled, + getOrientation, +} from "../selectors"; + +const KeyShortcuts = require("devtools/client/shared/key-shortcuts"); +const SplitBox = require("devtools/client/shared/components/splitter/SplitBox"); +const AppErrorBoundary = require("devtools/client/shared/components/AppErrorBoundary"); + +const shortcuts = new KeyShortcuts({ window }); + +const horizontalLayoutBreakpoint = window.matchMedia("(min-width: 800px)"); +const verticalLayoutBreakpoint = window.matchMedia( + "(min-width: 10px) and (max-width: 799px)" +); + +import "./variables.css"; +import "./App.css"; + +import "./shared/menu.css"; + +import PrimaryPanes from "./PrimaryPanes"; +import Editor from "./Editor"; +import SecondaryPanes from "./SecondaryPanes"; +import WelcomeBox from "./WelcomeBox"; +import EditorTabs from "./Editor/Tabs"; +import EditorFooter from "./Editor/Footer"; +import QuickOpenModal from "./QuickOpenModal"; + +class App extends Component { + constructor(props) { + super(props); + this.state = { + shortcutsModalEnabled: false, + startPanelSize: 0, + endPanelSize: 0, + }; + } + + static get propTypes() { + return { + activeSearch: PropTypes.oneOf(["file", "project"]), + closeActiveSearch: PropTypes.func.isRequired, + closeQuickOpen: PropTypes.func.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + fluentBundles: PropTypes.array.isRequired, + openQuickOpen: PropTypes.func.isRequired, + orientation: PropTypes.oneOf(["horizontal", "vertical"]).isRequired, + quickOpenEnabled: PropTypes.bool.isRequired, + selectedSource: PropTypes.object, + setActiveSearch: PropTypes.func.isRequired, + setOrientation: PropTypes.func.isRequired, + setPrimaryPaneTab: PropTypes.func.isRequired, + startPanelCollapsed: PropTypes.bool.isRequired, + toolboxDoc: PropTypes.object.isRequired, + }; + } + + getChildContext() { + return { + fluentBundles: this.props.fluentBundles, + toolboxDoc: this.props.toolboxDoc, + shortcuts, + l10n: L10N, + }; + } + + componentDidMount() { + horizontalLayoutBreakpoint.addListener(this.onLayoutChange); + verticalLayoutBreakpoint.addListener(this.onLayoutChange); + this.setOrientation(); + + shortcuts.on(L10N.getStr("symbolSearch.search.key2"), e => + this.toggleQuickOpenModal(e, "@") + ); + + [ + L10N.getStr("sources.search.key2"), + L10N.getStr("sources.search.alt.key"), + ].forEach(key => shortcuts.on(key, this.toggleQuickOpenModal)); + + shortcuts.on(L10N.getStr("gotoLineModal.key3"), e => + this.toggleQuickOpenModal(e, ":") + ); + + shortcuts.on( + L10N.getStr("projectTextSearch.key"), + this.jumpToProjectSearch + ); + + shortcuts.on("Escape", this.onEscape); + shortcuts.on("CmdOrCtrl+/", this.onCommandSlash); + } + + componentWillUnmount() { + horizontalLayoutBreakpoint.removeListener(this.onLayoutChange); + verticalLayoutBreakpoint.removeListener(this.onLayoutChange); + shortcuts.off( + L10N.getStr("symbolSearch.search.key2"), + this.toggleQuickOpenModal + ); + + [ + L10N.getStr("sources.search.key2"), + L10N.getStr("sources.search.alt.key"), + ].forEach(key => shortcuts.off(key, this.toggleQuickOpenModal)); + + shortcuts.off(L10N.getStr("gotoLineModal.key3"), this.toggleQuickOpenModal); + + shortcuts.off( + L10N.getStr("projectTextSearch.key"), + this.jumpToProjectSearch + ); + + shortcuts.off("Escape", this.onEscape); + shortcuts.off("CmdOrCtrl+/", this.onCommandSlash); + } + + jumpToProjectSearch = e => { + e.preventDefault(); + this.props.setPrimaryPaneTab(primaryPaneTabs.PROJECT_SEARCH); + this.props.setActiveSearch(primaryPaneTabs.PROJECT_SEARCH); + }; + + onEscape = e => { + const { + activeSearch, + closeActiveSearch, + closeQuickOpen, + quickOpenEnabled, + } = this.props; + const { shortcutsModalEnabled } = this.state; + + if (activeSearch) { + e.preventDefault(); + closeActiveSearch(); + } + + if (quickOpenEnabled) { + e.preventDefault(); + closeQuickOpen(); + } + + if (shortcutsModalEnabled) { + e.preventDefault(); + this.toggleShortcutsModal(); + } + }; + + onCommandSlash = () => { + this.toggleShortcutsModal(); + }; + + isHorizontal() { + return this.props.orientation === "horizontal"; + } + + toggleQuickOpenModal = (e, query) => { + const { quickOpenEnabled, openQuickOpen, closeQuickOpen } = this.props; + + e.preventDefault(); + e.stopPropagation(); + + if (quickOpenEnabled === true) { + closeQuickOpen(); + return; + } + + if (query != null) { + openQuickOpen(query); + return; + } + openQuickOpen(); + }; + + onLayoutChange = () => { + this.setOrientation(); + }; + + setOrientation() { + // If the orientation does not match (if it is not visible) it will + // not setOrientation, or if it is the same as before, calling + // setOrientation will not cause a rerender. + if (horizontalLayoutBreakpoint.matches) { + this.props.setOrientation("horizontal"); + } else if (verticalLayoutBreakpoint.matches) { + this.props.setOrientation("vertical"); + } + } + + renderEditorPane = () => { + const { startPanelCollapsed, endPanelCollapsed } = this.props; + const { endPanelSize, startPanelSize } = this.state; + const horizontal = this.isHorizontal(); + + return ( + <div className="editor-pane"> + <div className="editor-container"> + <EditorTabs + startPanelCollapsed={startPanelCollapsed} + endPanelCollapsed={endPanelCollapsed} + horizontal={horizontal} + /> + <Editor startPanelSize={startPanelSize} endPanelSize={endPanelSize} /> + {!this.props.selectedSource ? ( + <WelcomeBox + horizontal={horizontal} + toggleShortcutsModal={() => this.toggleShortcutsModal()} + /> + ) : null} + <EditorFooter horizontal={horizontal} /> + </div> + </div> + ); + }; + + toggleShortcutsModal() { + this.setState(prevState => ({ + shortcutsModalEnabled: !prevState.shortcutsModalEnabled, + })); + } + + // Important so that the tabs chevron updates appropriately when + // the user resizes the left or right columns + triggerEditorPaneResize() { + const editorPane = window.document.querySelector(".editor-pane"); + if (editorPane) { + editorPane.dispatchEvent(new Event("resizeend")); + } + } + + renderLayout = () => { + const { startPanelCollapsed, endPanelCollapsed } = this.props; + const horizontal = this.isHorizontal(); + + return ( + <SplitBox + style={{ width: "100vw" }} + initialSize={prefs.endPanelSize} + minSize={30} + maxSize="70%" + splitterSize={1} + vert={horizontal} + onResizeEnd={num => { + prefs.endPanelSize = num; + this.triggerEditorPaneResize(); + }} + startPanel={ + <SplitBox + style={{ width: "100vw" }} + initialSize={prefs.startPanelSize} + minSize={30} + maxSize="85%" + splitterSize={1} + onResizeEnd={num => { + prefs.startPanelSize = num; + }} + startPanelCollapsed={startPanelCollapsed} + startPanel={<PrimaryPanes horizontal={horizontal} />} + endPanel={this.renderEditorPane()} + /> + } + endPanelControl={true} + endPanel={<SecondaryPanes horizontal={horizontal} />} + endPanelCollapsed={endPanelCollapsed} + /> + ); + }; + + render() { + const { quickOpenEnabled } = this.props; + return ( + <div className="debugger"> + <AppErrorBoundary + componentName="Debugger" + panel={L10N.getStr("ToolboxDebugger.label")} + > + <A11yIntention> + {this.renderLayout()} + {quickOpenEnabled === true && ( + <QuickOpenModal + shortcutsModalEnabled={this.state.shortcutsModalEnabled} + toggleShortcutsModal={() => this.toggleShortcutsModal()} + /> + )} + <ShortcutsModal + enabled={this.state.shortcutsModalEnabled} + handleClose={() => this.toggleShortcutsModal()} + /> + </A11yIntention> + </AppErrorBoundary> + </div> + ); + } +} + +App.childContextTypes = { + toolboxDoc: PropTypes.object, + shortcuts: PropTypes.object, + l10n: PropTypes.object, + fluentBundles: PropTypes.array, +}; + +const mapStateToProps = state => ({ + selectedSource: getSelectedSource(state), + startPanelCollapsed: getPaneCollapse(state, "start"), + endPanelCollapsed: getPaneCollapse(state, "end"), + activeSearch: getActiveSearch(state), + quickOpenEnabled: getQuickOpenEnabled(state), + orientation: getOrientation(state), +}); + +export default connect(mapStateToProps, { + setActiveSearch: actions.setActiveSearch, + closeActiveSearch: actions.closeActiveSearch, + openQuickOpen: actions.openQuickOpen, + closeQuickOpen: actions.closeQuickOpen, + setOrientation: actions.setOrientation, + setPrimaryPaneTab: actions.setPrimaryPaneTab, +})(App); diff --git a/devtools/client/debugger/src/components/Editor/BlackboxLines.js b/devtools/client/debugger/src/components/Editor/BlackboxLines.js new file mode 100644 index 0000000000..c81db9c598 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/BlackboxLines.js @@ -0,0 +1,138 @@ +/* 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 PropTypes from "prop-types"; +import { Component } from "react"; +import { toEditorLine, fromEditorLine } from "../../utils/editor"; +import { isLineBlackboxed } from "../../utils/source"; +import { isWasm } from "../../utils/wasm"; + +// This renders blackbox line highlighting in the editor +class BlackboxLines extends Component { + static get propTypes() { + return { + editor: PropTypes.object.isRequired, + selectedSource: PropTypes.object.isRequired, + blackboxedRangesForSelectedSource: PropTypes.array, + isSourceOnIgnoreList: PropTypes.bool, + }; + } + + componentDidMount() { + const { selectedSource, blackboxedRangesForSelectedSource, editor } = + this.props; + + if (this.props.isSourceOnIgnoreList) { + this.setAllBlackboxLines(editor); + return; + } + + // When `blackboxedRangesForSelectedSource` is defined and the array is empty, + // the whole source was blackboxed. + if (!blackboxedRangesForSelectedSource.length) { + this.setAllBlackboxLines(editor); + } else { + editor.codeMirror.operation(() => { + blackboxedRangesForSelectedSource.forEach(range => { + const start = toEditorLine(selectedSource.id, range.start.line); + const end = toEditorLine(selectedSource.id, range.end.line); + editor.codeMirror.eachLine(start, end, lineHandle => { + this.setBlackboxLine(editor, lineHandle); + }); + }); + }); + } + } + + componentDidUpdate() { + const { + selectedSource, + blackboxedRangesForSelectedSource, + editor, + isSourceOnIgnoreList, + } = this.props; + + if (this.props.isSourceOnIgnoreList) { + this.setAllBlackboxLines(editor); + return; + } + + // when unblackboxed + if (!blackboxedRangesForSelectedSource) { + this.clearAllBlackboxLines(editor); + return; + } + + // When the whole source is blackboxed + if (!blackboxedRangesForSelectedSource.length) { + this.setAllBlackboxLines(editor); + return; + } + + const sourceIsWasm = isWasm(selectedSource.id); + + // TODO: Possible perf improvement. Instead of going + // over all the lines each time get diffs of what has + // changed and update those. + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + const line = fromEditorLine( + selectedSource.id, + editor.codeMirror.getLineNumber(lineHandle), + sourceIsWasm + ); + + if ( + isLineBlackboxed( + blackboxedRangesForSelectedSource, + line, + isSourceOnIgnoreList + ) + ) { + this.setBlackboxLine(editor, lineHandle); + } else { + this.clearBlackboxLine(editor, lineHandle); + } + }); + }); + } + + componentWillUnmount() { + // Lets make sure we remove everything relating to + // blackboxing lines when this component is unmounted. + this.clearAllBlackboxLines(this.props.editor); + } + + clearAllBlackboxLines(editor) { + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + this.clearBlackboxLine(editor, lineHandle); + }); + }); + } + + setAllBlackboxLines(editor) { + //TODO:We might be able to handle the whole source + // than adding the blackboxing line by line + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + this.setBlackboxLine(editor, lineHandle); + }); + }); + } + + clearBlackboxLine(editor, lineHandle) { + editor.codeMirror.removeLineClass(lineHandle, "wrap", "blackboxed-line"); + } + + setBlackboxLine(editor, lineHandle) { + editor.codeMirror.addLineClass(lineHandle, "wrap", "blackboxed-line"); + } + + render() { + return null; + } +} + +export default BlackboxLines; diff --git a/devtools/client/debugger/src/components/Editor/Breakpoint.js b/devtools/client/debugger/src/components/Editor/Breakpoint.js new file mode 100644 index 0000000000..cce23c199f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoint.js @@ -0,0 +1,183 @@ +/* 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 { PureComponent } from "react"; +import PropTypes from "prop-types"; + +import { getDocument, toEditorLine } from "../../utils/editor"; +import { getSelectedLocation } from "../../utils/selected-location"; +import { features } from "../../utils/prefs"; +import { showMenu } from "../../context-menu/menu"; +import { breakpointItems } from "./menus/breakpoints"; +const classnames = require("devtools/client/shared/classnames.js"); + +const breakpointSvg = document.createElement("div"); +breakpointSvg.innerHTML = + '<svg viewBox="0 0 60 15" width="60" height="15"><path d="M53.07.5H1.5c-.54 0-1 .46-1 1v12c0 .54.46 1 1 1h51.57c.58 0 1.15-.26 1.53-.7l4.7-6.3-4.7-6.3c-.38-.44-.95-.7-1.53-.7z"/></svg>'; + +class Breakpoint extends PureComponent { + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + breakpoint: PropTypes.object.isRequired, + breakpointActions: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + editorActions: PropTypes.object.isRequired, + selectedSource: PropTypes.object, + blackboxedRangesForSelectedSource: PropTypes.array, + isSelectedSourceOnIgnoreList: PropTypes.bool.isRequired, + }; + } + + componentDidMount() { + this.addBreakpoint(this.props); + } + + componentDidUpdate(prevProps) { + this.removeBreakpoint(prevProps); + this.addBreakpoint(this.props); + } + + componentWillUnmount() { + this.removeBreakpoint(this.props); + } + + makeMarker() { + const { breakpoint } = this.props; + const bp = breakpointSvg.cloneNode(true); + + bp.className = classnames("editor new-breakpoint", { + "breakpoint-disabled": breakpoint.disabled, + "folding-enabled": features.codeFolding, + }); + bp.onmousedown = this.onClick; + bp.oncontextmenu = this.onContextMenu; + + return bp; + } + + onClick = event => { + const { cx, breakpointActions, editorActions, breakpoint, selectedSource } = + this.props; + + // ignore right clicks + if ((event.ctrlKey && event.button === 0) || event.button === 2) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + if (event.metaKey) { + editorActions.continueToHere(cx, selectedLocation); + return; + } + + if (event.shiftKey) { + breakpointActions.toggleBreakpointsAtLine( + cx, + !breakpoint.disabled, + selectedLocation.line + ); + return; + } + + breakpointActions.removeBreakpointsAtLine( + cx, + selectedLocation.sourceId, + selectedLocation.line + ); + }; + + onContextMenu = event => { + const { + cx, + breakpoint, + selectedSource, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList, + } = this.props; + event.stopPropagation(); + event.preventDefault(); + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + + showMenu( + event, + breakpointItems( + cx, + breakpoint, + selectedLocation, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList + ) + ); + }; + + addBreakpoint(props) { + const { breakpoint, editor, selectedSource } = props; + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + + // Hidden Breakpoints are never rendered on the client + if (breakpoint.options.hidden) { + return; + } + + if (!selectedSource) { + return; + } + + const sourceId = selectedSource.id; + const line = toEditorLine(sourceId, selectedLocation.line); + const doc = getDocument(sourceId); + + doc.setGutterMarker(line, "breakpoints", this.makeMarker()); + + editor.codeMirror.addLineClass(line, "wrap", "new-breakpoint"); + editor.codeMirror.removeLineClass(line, "wrap", "breakpoint-disabled"); + editor.codeMirror.removeLineClass(line, "wrap", "has-condition"); + editor.codeMirror.removeLineClass(line, "wrap", "has-log"); + + if (breakpoint.disabled) { + editor.codeMirror.addLineClass(line, "wrap", "breakpoint-disabled"); + } + + if (breakpoint.options.logValue) { + editor.codeMirror.addLineClass(line, "wrap", "has-log"); + } else if (breakpoint.options.condition) { + editor.codeMirror.addLineClass(line, "wrap", "has-condition"); + } + } + + removeBreakpoint(props) { + const { selectedSource, breakpoint } = props; + if (!selectedSource) { + return; + } + + const sourceId = selectedSource.id; + const doc = getDocument(sourceId); + + if (!doc) { + return; + } + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const line = toEditorLine(sourceId, selectedLocation.line); + + doc.setGutterMarker(line, "breakpoints", null); + doc.removeLineClass(line, "wrap", "new-breakpoint"); + doc.removeLineClass(line, "wrap", "breakpoint-disabled"); + doc.removeLineClass(line, "wrap", "has-condition"); + doc.removeLineClass(line, "wrap", "has-log"); + } + + render() { + return null; + } +} + +export default Breakpoint; diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.css b/devtools/client/debugger/src/components/Editor/Breakpoints.css new file mode 100644 index 0000000000..1269f73f82 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoints.css @@ -0,0 +1,153 @@ +/* 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/>. */ + +.theme-light { + --gutter-hover-background-color: #dde1e4; + --breakpoint-fill: var(--blue-50); + --breakpoint-stroke: var(--blue-60); +} + +.theme-dark { + --gutter-hover-background-color: #414141; + --breakpoint-fill: var(--blue-55); + --breakpoint-stroke: var(--blue-40); +} + +.theme-light, +.theme-dark { + --logpoint-fill: var(--theme-graphs-purple); + --logpoint-stroke: var(--purple-60); + --breakpoint-condition-fill: var(--theme-graphs-yellow); + --breakpoint-condition-stroke: var(--theme-graphs-orange); + --breakpoint-skipped-opacity: 0.15; + --breakpoint-inactive-opacity: 0.3; + --breakpoint-disabled-opacity: 0.6; +} + +/* Standard gutter breakpoints */ +.editor-wrapper .breakpoints { + position: absolute; + top: 0; + left: 0; +} + +.new-breakpoint .CodeMirror-linenumber { + pointer-events: none; +} + +.editor-wrapper :not(.empty-line, .new-breakpoint) + > .CodeMirror-gutter-wrapper + > .CodeMirror-linenumber:hover::after { + content: ""; + position: absolute; + /* paint below the number */ + z-index: -1; + top: 0; + left: 0; + right: -4px; + bottom: 0; + height: 15px; + background-color: var(--gutter-hover-background-color); + mask: url(chrome://devtools/content/debugger/images/breakpoint.svg) + no-repeat; + mask-size: auto 15px; + mask-position: right; +} + +.editor.new-breakpoint svg { + fill: var(--breakpoint-fill); + stroke: var(--breakpoint-stroke); + width: 60px; + height: 15px; + position: absolute; + top: 0px; + right: -4px; +} + +.editor .breakpoint { + position: absolute; + right: -2px; +} + +.editor.new-breakpoint.folding-enabled svg { + right: -16px; +} + +.new-breakpoint.has-condition .CodeMirror-gutter-wrapper svg { + fill: var(--breakpoint-condition-fill); + stroke: var(--breakpoint-condition-stroke); +} + +.new-breakpoint.has-log .CodeMirror-gutter-wrapper svg { + fill: var(--logpoint-fill); + stroke: var(--logpoint-stroke); +} + +.editor.new-breakpoint.breakpoint-disabled svg, +.blackboxed-line .editor.new-breakpoint svg { + fill-opacity: var(--breakpoint-disabled-opacity); + stroke-opacity: var(--breakpoint-disabled-opacity); +} + +.editor-wrapper.skip-pausing .editor.new-breakpoint svg { + fill-opacity: var(--breakpoint-skipped-opacity); +} + +/* Columnn breakpoints */ +.column-breakpoint { + display: inline; + padding-inline-start: 1px; + padding-inline-end: 1px; +} + +.column-breakpoint:hover { + background-color: transparent; +} + +.column-breakpoint svg { + display: inline-block; + cursor: pointer; + height: 13px; + width: 11px; + vertical-align: top; + fill: var(--breakpoint-fill); + stroke: var(--breakpoint-stroke); + fill-opacity: var(--breakpoint-inactive-opacity); + stroke-opacity: var(--breakpoint-inactive-opacity); +} + +.column-breakpoint.active svg { + fill: var(--breakpoint-fill); + stroke: var(--breakpoint-stroke); + fill-opacity: 1; + stroke-opacity: 1; +} + +.column-breakpoint.disabled svg { + fill-opacity: var(--breakpoint-disabled-opacity); + stroke-opacity: var(--breakpoint-disabled-opacity); +} + +.column-breakpoint.has-log.disabled svg { + fill-opacity: 0.5; + stroke-opacity: 0.5; +} + +.column-breakpoint.has-condition svg { + fill: var(--breakpoint-condition-fill); + stroke: var(--breakpoint-condition-stroke); +} + +.column-breakpoint.has-log svg { + fill: var(--logpoint-fill); + stroke: var(--logpoint-stroke); +} + +.editor-wrapper.skip-pausing .column-breakpoint svg { + fill-opacity: var(--breakpoint-skipped-opacity); +} + +.img.column-marker { + background-image: url(chrome://devtools/content/debugger/images/column-marker.svg); +} diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.js b/devtools/client/debugger/src/components/Editor/Breakpoints.js new file mode 100644 index 0000000000..36added4ee --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoints.js @@ -0,0 +1,96 @@ +/* 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 PropTypes from "prop-types"; +import React, { Component } from "react"; +import Breakpoint from "./Breakpoint"; + +import { + getSelectedSource, + getFirstVisibleBreakpoints, + getBlackBoxRanges, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; +import { makeBreakpointId } from "../../utils/breakpoint"; +import { connect } from "../../utils/connect"; +import { breakpointItemActions } from "./menus/breakpoints"; +import { editorItemActions } from "./menus/editor"; + +class Breakpoints extends Component { + static get propTypes() { + return { + cx: PropTypes.object, + breakpoints: PropTypes.array, + editor: PropTypes.object, + breakpointActions: PropTypes.object, + editorActions: PropTypes.object, + selectedSource: PropTypes.object, + blackboxedRanges: PropTypes.object, + isSelectedSourceOnIgnoreList: PropTypes.bool, + blackboxedRangesForSelectedSource: PropTypes.array, + }; + } + render() { + const { + cx, + breakpoints, + selectedSource, + editor, + breakpointActions, + editorActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList, + } = this.props; + + if (!selectedSource || !breakpoints) { + return null; + } + + return ( + <div> + {breakpoints.map(bp => { + return ( + <Breakpoint + cx={cx} + key={makeBreakpointId(bp.location)} + breakpoint={bp} + selectedSource={selectedSource} + blackboxedRangesForSelectedSource={ + blackboxedRangesForSelectedSource + } + isSelectedSourceOnIgnoreList={isSelectedSourceOnIgnoreList} + editor={editor} + breakpointActions={breakpointActions} + editorActions={editorActions} + /> + ); + })} + </div> + ); + } +} + +export default connect( + state => { + const selectedSource = getSelectedSource(state); + const blackboxedRanges = getBlackBoxRanges(state); + return { + // Retrieves only the first breakpoint per line so that the + // breakpoint marker represents only the first breakpoint + breakpoints: getFirstVisibleBreakpoints(state), + selectedSource, + blackboxedRangesForSelectedSource: + selectedSource && blackboxedRanges[selectedSource.url], + isSelectedSourceOnIgnoreList: + selectedSource && + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, selectedSource), + }; + }, + dispatch => ({ + breakpointActions: breakpointItemActions(dispatch), + editorActions: editorItemActions(dispatch), + }) +)(Breakpoints); diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js new file mode 100644 index 0000000000..0577a61f5c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js @@ -0,0 +1,140 @@ +/* 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 { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { showMenu } from "../../context-menu/menu"; + +import { getDocument } from "../../utils/editor"; +import { breakpointItems, createBreakpointItems } from "./menus/breakpoints"; +import { getSelectedLocation } from "../../utils/selected-location"; +const classnames = require("devtools/client/shared/classnames.js"); + +// eslint-disable-next-line max-len + +const breakpointButton = document.createElement("button"); +breakpointButton.innerHTML = + '<svg viewBox="0 0 11 13" width="11" height="13"><path d="M5.07.5H1.5c-.54 0-1 .46-1 1v10c0 .54.46 1 1 1h3.57c.58 0 1.15-.26 1.53-.7l3.7-5.3-3.7-5.3C6.22.76 5.65.5 5.07.5z"/></svg>'; + +function makeBookmark({ breakpoint }, { onClick, onContextMenu }) { + const bp = breakpointButton.cloneNode(true); + + const isActive = breakpoint && !breakpoint.disabled; + const isDisabled = breakpoint?.disabled; + const condition = breakpoint?.options.condition; + const logValue = breakpoint?.options.logValue; + + bp.className = classnames("column-breakpoint", { + "has-condition": condition, + "has-log": logValue, + active: isActive, + disabled: isDisabled, + }); + + bp.setAttribute("title", logValue || condition || ""); + bp.onclick = onClick; + bp.oncontextmenu = onContextMenu; + + return bp; +} + +export default class ColumnBreakpoint extends PureComponent { + bookmark; + + static get propTypes() { + return { + breakpointActions: PropTypes.object.isRequired, + columnBreakpoint: PropTypes.object.isRequired, + cx: PropTypes.object.isRequired, + source: PropTypes.object.isRequired, + }; + } + + addColumnBreakpoint = nextProps => { + const { columnBreakpoint, source } = nextProps || this.props; + + const sourceId = source.id; + const doc = getDocument(sourceId); + if (!doc) { + return; + } + + const { line, column } = columnBreakpoint.location; + const widget = makeBookmark(columnBreakpoint, { + onClick: this.onClick, + onContextMenu: this.onContextMenu, + }); + + this.bookmark = doc.setBookmark({ line: line - 1, ch: column }, { widget }); + }; + + clearColumnBreakpoint = () => { + if (this.bookmark) { + this.bookmark.clear(); + this.bookmark = null; + } + }; + + onClick = event => { + event.stopPropagation(); + event.preventDefault(); + const { cx, columnBreakpoint, breakpointActions } = this.props; + + // disable column breakpoint on shift-click. + if (event.shiftKey) { + const breakpoint = columnBreakpoint.breakpoint; + breakpointActions.toggleDisabledBreakpoint(cx, breakpoint); + return; + } + + if (columnBreakpoint.breakpoint) { + breakpointActions.removeBreakpoint(cx, columnBreakpoint.breakpoint); + } else { + breakpointActions.addBreakpoint(cx, columnBreakpoint.location); + } + }; + + onContextMenu = event => { + event.stopPropagation(); + event.preventDefault(); + const { + cx, + columnBreakpoint: { breakpoint, location }, + source, + breakpointActions, + } = this.props; + + let items = createBreakpointItems(cx, location, breakpointActions); + + if (breakpoint) { + const selectedLocation = getSelectedLocation(breakpoint, source); + + items = breakpointItems( + cx, + breakpoint, + selectedLocation, + breakpointActions + ); + } + + showMenu(event, items); + }; + + componentDidMount() { + this.addColumnBreakpoint(); + } + + componentWillUnmount() { + this.clearColumnBreakpoint(); + } + + componentDidUpdate() { + this.clearColumnBreakpoint(); + this.addColumnBreakpoint(); + } + + render() { + return null; + } +} diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js new file mode 100644 index 0000000000..62c2ab29e3 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js @@ -0,0 +1,75 @@ +/* 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 ColumnBreakpoint from "./ColumnBreakpoint"; + +import { + getSelectedSource, + visibleColumnBreakpoints, + getContext, + isSourceBlackBoxed, +} from "../../selectors"; +import { connect } from "../../utils/connect"; +import { makeBreakpointId } from "../../utils/breakpoint"; +import { breakpointItemActions } from "./menus/breakpoints"; + +// eslint-disable-next-line max-len + +class ColumnBreakpoints extends Component { + static get propTypes() { + return { + breakpointActions: PropTypes.object.isRequired, + columnBreakpoints: PropTypes.array.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + selectedSource: PropTypes.object, + }; + } + + render() { + const { cx, editor, columnBreakpoints, selectedSource, breakpointActions } = + this.props; + + if (!selectedSource || columnBreakpoints.length === 0) { + return null; + } + + let breakpoints; + editor.codeMirror.operation(() => { + breakpoints = columnBreakpoints.map(breakpoint => ( + <ColumnBreakpoint + cx={cx} + key={makeBreakpointId(breakpoint.location)} + columnBreakpoint={breakpoint} + editor={editor} + source={selectedSource} + breakpointActions={breakpointActions} + /> + )); + }); + return <div>{breakpoints}</div>; + } +} + +const mapStateToProps = state => { + // Avoid rendering this component is there is no selected source, + // or if the selected source is blackboxed. + // Also avoid computing visible column breakpoint when this happens. + const selectedSource = getSelectedSource(state); + if (!selectedSource || isSourceBlackBoxed(state, selectedSource)) { + return {}; + } + return { + cx: getContext(state), + selectedSource, + columnBreakpoints: visibleColumnBreakpoints(state), + }; +}; + +export default connect(mapStateToProps, dispatch => ({ + breakpointActions: breakpointItemActions(dispatch), +}))(ColumnBreakpoints); diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.css b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css new file mode 100644 index 0000000000..4ce8dbcd8c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css @@ -0,0 +1,39 @@ +/* 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/>. */ + +.conditional-breakpoint-panel { + cursor: initial; + margin: 1em 0; + position: relative; + display: flex; + align-items: center; + background: var(--theme-toolbar-background); + border-top: 1px solid var(--theme-splitter-color); + border-bottom: 1px solid var(--theme-splitter-color); +} + +.conditional-breakpoint-panel .prompt { + font-size: 1.8em; + color: var(--theme-graphs-orange); + padding-left: 3px; + padding-right: 3px; + padding-bottom: 3px; + text-align: right; + width: 30px; + align-self: baseline; + margin-top: 3px; +} + +.conditional-breakpoint-panel.log-point .prompt { + color: var(--purple-60); +} + +.conditional-breakpoint-panel .CodeMirror { + margin: 6px 10px; +} + +.conditional-breakpoint-panel .CodeMirror pre.CodeMirror-placeholder { + /* Match the color of the placeholder text to existing inputs in the Debugger */ + color: var(--theme-text-color-alt); +} diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.js b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js new file mode 100644 index 0000000000..e451ffa960 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js @@ -0,0 +1,274 @@ +/* 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, { PureComponent } from "react"; +import ReactDOM from "react-dom"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import "./ConditionalPanel.css"; +import { toEditorLine } from "../../utils/editor"; +import { prefs } from "../../utils/prefs"; +import actions from "../../actions"; + +import { + getClosestBreakpoint, + getConditionalPanelLocation, + getLogPointStatus, + getContext, +} from "../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); + +function addNewLine(doc) { + const cursor = doc.getCursor(); + const pos = { line: cursor.line, ch: cursor.ch }; + doc.replaceRange("\n", pos); +} + +export class ConditionalPanel extends PureComponent { + cbPanel; + input; + codeMirror; + panelNode; + scrollParent; + + constructor() { + super(); + this.cbPanel = null; + } + + static get propTypes() { + return { + breakpoint: PropTypes.object, + closeConditionalPanel: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + location: PropTypes.any.isRequired, + log: PropTypes.bool.isRequired, + openConditionalPanel: PropTypes.func.isRequired, + setBreakpointOptions: PropTypes.func.isRequired, + }; + } + + keepFocusOnInput() { + if (this.input) { + this.input.focus(); + } + } + + saveAndClose = () => { + if (this.input) { + this.setBreakpoint(this.input.value.trim()); + } + + this.props.closeConditionalPanel(); + }; + + onKey = e => { + if (e.key === "Enter") { + if (this.codeMirror && e.altKey) { + addNewLine(this.codeMirror.doc); + } else { + this.saveAndClose(); + } + } else if (e.key === "Escape") { + this.props.closeConditionalPanel(); + } + }; + + setBreakpoint(value) { + const { cx, log, breakpoint } = this.props; + // If breakpoint is `pending`, props will not contain a breakpoint. + // If source is a URL without location, breakpoint will contain no generatedLocation. + const location = + breakpoint && breakpoint.generatedLocation + ? breakpoint.generatedLocation + : this.props.location; + const options = breakpoint ? breakpoint.options : {}; + const type = log ? "logValue" : "condition"; + return this.props.setBreakpointOptions(cx, location, { + ...options, + [type]: value, + }); + } + + clearConditionalPanel() { + if (this.cbPanel) { + this.cbPanel.clear(); + this.cbPanel = null; + } + if (this.scrollParent) { + this.scrollParent.removeEventListener("scroll", this.repositionOnScroll); + } + } + + repositionOnScroll = () => { + if (this.panelNode && this.scrollParent) { + const { scrollLeft } = this.scrollParent; + this.panelNode.style.transform = `translateX(${scrollLeft}px)`; + } + }; + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + return this.renderToWidget(this.props); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate() { + return this.clearConditionalPanel(); + } + + componentDidUpdate(prevProps) { + this.keepFocusOnInput(); + } + + componentWillUnmount() { + // This is called if CodeMirror is re-initializing itself before the + // user closes the conditional panel. Clear the widget, and re-render it + // as soon as this component gets remounted + return this.clearConditionalPanel(); + } + + renderToWidget(props) { + if (this.cbPanel) { + this.clearConditionalPanel(); + } + const { location, editor } = props; + + const editorLine = toEditorLine(location.sourceId, location.line || 0); + this.cbPanel = editor.codeMirror.addLineWidget( + editorLine, + this.renderConditionalPanel(props), + { + coverGutter: true, + noHScroll: true, + } + ); + + if (this.input) { + let parent = this.input.parentNode; + while (parent) { + if ( + parent instanceof HTMLElement && + parent.classList.contains("CodeMirror-scroll") + ) { + this.scrollParent = parent; + break; + } + parent = parent.parentNode; + } + + if (this.scrollParent) { + this.scrollParent.addEventListener("scroll", this.repositionOnScroll); + this.repositionOnScroll(); + } + } + } + + createEditor = input => { + const { log, editor, closeConditionalPanel } = this.props; + const codeMirror = editor.CodeMirror.fromTextArea(input, { + mode: "javascript", + theme: "mozilla", + placeholder: L10N.getStr( + log + ? "editor.conditionalPanel.logPoint.placeholder2" + : "editor.conditionalPanel.placeholder2" + ), + cursorBlinkRate: prefs.cursorBlinkRate, + }); + + codeMirror.on("keydown", (cm, e) => { + if (e.key === "Enter") { + e.codemirrorIgnore = true; + } + }); + + codeMirror.on("blur", (cm, e) => { + if ( + e?.relatedTarget && + e.relatedTarget.closest(".conditional-breakpoint-panel") + ) { + return; + } + + closeConditionalPanel(); + }); + + const codeMirrorWrapper = codeMirror.getWrapperElement(); + + codeMirrorWrapper.addEventListener("keydown", e => { + codeMirror.save(); + this.onKey(e); + }); + + this.input = input; + this.codeMirror = codeMirror; + codeMirror.focus(); + codeMirror.setCursor(codeMirror.lineCount(), 0); + }; + + getDefaultValue() { + const { breakpoint, log } = this.props; + const options = breakpoint?.options || {}; + return log ? options.logValue : options.condition; + } + + renderConditionalPanel(props) { + const { log } = props; + const defaultValue = this.getDefaultValue(); + + const panel = document.createElement("div"); + ReactDOM.render( + <div + className={classnames("conditional-breakpoint-panel", { + "log-point": log, + })} + onClick={() => this.keepFocusOnInput()} + ref={node => (this.panelNode = node)} + > + <div className="prompt">»</div> + <textarea + defaultValue={defaultValue} + ref={input => this.createEditor(input)} + /> + </div>, + panel + ); + return panel; + } + + render() { + return null; + } +} + +const mapStateToProps = state => { + const location = getConditionalPanelLocation(state); + + if (!location) { + throw new Error("Conditional panel location needed."); + } + + const breakpoint = getClosestBreakpoint(state, location); + + return { + cx: getContext(state), + breakpoint, + location, + log: getLogPointStatus(state), + }; +}; + +const { setBreakpointOptions, openConditionalPanel, closeConditionalPanel } = + actions; + +const mapDispatchToProps = { + setBreakpointOptions, + openConditionalPanel, + closeConditionalPanel, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ConditionalPanel); diff --git a/devtools/client/debugger/src/components/Editor/DebugLine.js b/devtools/client/debugger/src/components/Editor/DebugLine.js new file mode 100644 index 0000000000..95cfc5a94d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/DebugLine.js @@ -0,0 +1,138 @@ +/* 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 { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { + toEditorPosition, + getDocument, + hasDocument, + startOperation, + endOperation, + getTokenEnd, +} from "../../utils/editor"; +import { isException } from "../../utils/pause"; +import { getIndentation } from "../../utils/indentation"; +import { connect } from "../../utils/connect"; +import { + getVisibleSelectedFrame, + getPauseReason, + getSourceTextContent, + getCurrentThread, +} from "../../selectors"; + +export class DebugLine extends PureComponent { + debugExpression; + + static get propTypes() { + return { + location: PropTypes.object, + why: PropTypes.object, + }; + } + + componentDidMount() { + const { why, location } = this.props; + this.setDebugLine(why, location); + } + + componentWillUnmount() { + const { why, location } = this.props; + this.clearDebugLine(why, location); + } + + componentDidUpdate(prevProps) { + const { why, location } = this.props; + + startOperation(); + this.clearDebugLine(prevProps.why, prevProps.location); + this.setDebugLine(why, location); + endOperation(); + } + + setDebugLine(why, location) { + if (!location) { + return; + } + const { sourceId } = location; + const doc = getDocument(sourceId); + + let { line, column } = toEditorPosition(location); + let { markTextClass, lineClass } = this.getTextClasses(why); + doc.addLineClass(line, "wrap", lineClass); + + const lineText = doc.getLine(line); + column = Math.max(column, getIndentation(lineText)); + + // If component updates because user clicks on + // another source tab, codeMirror will be null. + const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null; + + if (columnEnd === null) { + markTextClass += " to-line-end"; + } + + this.debugExpression = doc.markText( + { ch: column, line }, + { ch: columnEnd, line }, + { className: markTextClass } + ); + } + + clearDebugLine(why, location) { + // Avoid clearing the line if we didn't set a debug line before, + // or, if the document is no longer available + if (!location || !hasDocument(location.sourceId)) { + return; + } + + if (this.debugExpression) { + this.debugExpression.clear(); + } + + const { line } = toEditorPosition(location); + const doc = getDocument(location.sourceId); + const { lineClass } = this.getTextClasses(why); + doc.removeLineClass(line, "wrap", lineClass); + } + + getTextClasses(why) { + if (why && isException(why)) { + return { + markTextClass: "debug-expression-error", + lineClass: "new-debug-line-error", + }; + } + + return { markTextClass: "debug-expression", lineClass: "new-debug-line" }; + } + + render() { + return null; + } +} + +function isDocumentReady(location, sourceTextContent) { + return location && sourceTextContent && hasDocument(location.sourceId); +} + +const mapStateToProps = state => { + // Avoid unecessary intermediate updates when there is no location + // or the source text content isn't yet fully loaded + const frame = getVisibleSelectedFrame(state); + const location = frame?.location; + if (!location) { + return {}; + } + const sourceTextContent = getSourceTextContent(state, location); + if (!isDocumentReady(location, sourceTextContent)) { + return {}; + } + return { + location, + why: getPauseReason(state, getCurrentThread(state)), + }; +}; + +export default connect(mapStateToProps)(DebugLine); diff --git a/devtools/client/debugger/src/components/Editor/Editor.css b/devtools/client/debugger/src/components/Editor/Editor.css new file mode 100644 index 0000000000..7ea45c629d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Editor.css @@ -0,0 +1,220 @@ +/* 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/>. */ + +.editor-wrapper { + --debug-line-border: rgb(145, 188, 219); + --debug-expression-background: rgba(202, 227, 255, 0.5); + --debug-line-error-border: rgb(255, 0, 0); + --debug-expression-error-background: rgba(231, 116, 113, 0.3); + --line-exception-background: hsl(344, 73%, 97%); + --highlight-line-duration: 5000ms; +} + +.theme-dark .editor-wrapper { + --debug-expression-background: rgba(202, 227, 255, 0.3); + --debug-line-border: #7786a2; + --line-exception-background: hsl(345, 23%, 24%); +} + +.editor-wrapper .CodeMirror-linewidget { + margin-right: -7px; +} + +.editor-wrapper { + min-width: 0 !important; +} + +.CodeMirror.cm-s-mozilla, +.CodeMirror-scroll, +.CodeMirror-sizer { + overflow-anchor: none; +} + +/* Prevents inline preview from shifting source height (#1576163) */ +.CodeMirror-linewidget { + padding: 0; + display: flow-root; +} + +/** + * There's a known codemirror flex issue with chrome that this addresses. + * BUG https://github.com/firefox-devtools/debugger/issues/63 + */ +.editor-wrapper { + position: absolute; + width: calc(100% - 1px); + top: var(--editor-header-height); + bottom: var(--editor-footer-height); + left: 0px; +} + +html[dir="rtl"] .editor-mount { + direction: ltr; +} + +.function-search { + max-height: 300px; + overflow: hidden; +} + +.function-search .results { + height: auto; +} + +.editor.hit-marker { + height: 15px; +} + +.editor-wrapper .highlight-lines { + background: var(--theme-selection-background-hover); +} + +.CodeMirror { + width: 100%; + height: 100%; +} + +.editor-wrapper .editor-mount { + width: 100%; + background-color: var(--theme-body-background); + font-size: var(--theme-code-font-size); + line-height: var(--theme-code-line-height); +} + +/* set the linenumber white when there is a breakpoint */ +.editor-wrapper:not(.skip-pausing) + .new-breakpoint + .CodeMirror-gutter-wrapper + .CodeMirror-linenumber { + color: white; +} + +/* move the breakpoint below the other gutter elements */ +.new-breakpoint .CodeMirror-gutter-elt:nth-child(2) { + z-index: 0; +} + +.theme-dark .editor-wrapper .CodeMirror-line .cm-comment { + color: var(--theme-comment); +} + +.debug-expression { + background-color: var(--debug-expression-background); + border-style: solid; + border-color: var(--debug-expression-background); + border-width: 1px 0px 1px 0px; + position: relative; +} + +.debug-expression::before { + content: ""; + line-height: 1px; + border-top: 1px solid var(--blue-50); + background: transparent; + position: absolute; + top: -2px; + left: 0px; + width: 100%; + } + +.debug-expression::after { + content: ""; + line-height: 1px; + border-bottom: 1px solid var(--blue-50); + position: absolute; + bottom: -2px; + left: 0px; + width: 100%; + } + +.to-line-end ~ .CodeMirror-widget { + background-color: var(--debug-expression-background); +} + +.debug-expression-error { + background-color: var(--debug-expression-error-background); +} + +.new-debug-line > .CodeMirror-line { + background-color: transparent !important; + outline: var(--debug-line-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.new-debug-line .CodeMirror-activeline-background { + display: none; +} + +.new-debug-line-error > .CodeMirror-line { + background-color: var(--debug-expression-error-background) !important; + outline: var(--debug-line-error-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.new-debug-line-error .CodeMirror-activeline-background { + display: none; +} +.highlight-line .CodeMirror-line { + animation-name: fade-highlight-out; + animation-duration: var(--highlight-line-duration); + animation-timing-function: ease-out; + animation-direction: forwards; +} + +@keyframes fade-highlight-out { + 0% { + background-color: var(--theme-contrast-background); + } + 30% { + background-color: var(--theme-contrast-background); + } + 100% { + background-color: transparent; + } +} + +.visible { + visibility: visible; +} + +/* Code folding */ +.editor-wrapper .CodeMirror-foldgutter-open { + color: var(--grey-40); +} + +.editor-wrapper .CodeMirror-foldgutter-open, +.editor-wrapper .CodeMirror-foldgutter-folded { + fill: var(--grey-40); +} + +.editor-wrapper .CodeMirror-foldgutter-open::before, +.editor-wrapper .CodeMirror-foldgutter-open::after { + border-top: none; +} + +.editor-wrapper .CodeMirror-foldgutter-folded::before, +.editor-wrapper .CodeMirror-foldgutter-folded::after { + border-left: none; +} + +.editor-wrapper .CodeMirror-foldgutter .CodeMirror-guttermarker-subtle { + visibility: visible; +} + +.editor-wrapper .CodeMirror-foldgutter .CodeMirror-linenumber { + text-align: left; + padding: 0 0 0 2px; +} + +/* Exception line */ +.line-exception { + background-color: var(--line-exception-background); +} + +.mark-text-exception { + text-decoration: var(--red-50) wavy underline; + text-decoration-skip-ink: none; +} diff --git a/devtools/client/debugger/src/components/Editor/EditorMenu.js b/devtools/client/debugger/src/components/Editor/EditorMenu.js new file mode 100644 index 0000000000..a865fcc9bd --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/EditorMenu.js @@ -0,0 +1,111 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { Component } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../../utils/connect"; +import { showMenu } from "../../context-menu/menu"; + +import { getSourceLocationFromMouseEvent } from "../../utils/editor"; +import { isPretty } from "../../utils/source"; +import { + getPrettySource, + getIsCurrentThreadPaused, + getThreadContext, + isSourceWithMap, + getBlackBoxRanges, + isSourceOnSourceMapIgnoreList, + isSourceMapIgnoreListEnabled, +} from "../../selectors"; + +import { editorMenuItems, editorItemActions } from "./menus/editor"; + +class EditorMenu extends Component { + static get propTypes() { + return { + clearContextMenu: PropTypes.func.isRequired, + contextMenu: PropTypes.object, + isSourceOnIgnoreList: PropTypes.bool, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate(nextProps) { + this.props.clearContextMenu(); + if (nextProps.contextMenu) { + this.showMenu(nextProps); + } + } + + showMenu(props) { + const { + cx, + editor, + selectedSource, + blackboxedRanges, + editorActions, + hasMappedLocation, + isPaused, + editorWrappingEnabled, + contextMenu: event, + isSourceOnIgnoreList, + } = props; + + const location = getSourceLocationFromMouseEvent( + editor, + selectedSource, + // Use a coercion, as contextMenu is optional + event + ); + + showMenu( + event, + editorMenuItems({ + cx, + editorActions, + selectedSource, + blackboxedRanges, + hasMappedLocation, + location, + isPaused, + editorWrappingEnabled, + selectionText: editor.codeMirror.getSelection().trim(), + isTextSelected: editor.codeMirror.somethingSelected(), + editor, + isSourceOnIgnoreList, + }) + ); + } + + render() { + return null; + } +} + +const mapStateToProps = (state, props) => { + // This component is a no-op when contextmenu is false + if (!props.contextMenu) { + return {}; + } + return { + cx: getThreadContext(state), + blackboxedRanges: getBlackBoxRanges(state), + isPaused: getIsCurrentThreadPaused(state), + hasMappedLocation: + (props.selectedSource.isOriginal || + isSourceWithMap(state, props.selectedSource.id) || + isPretty(props.selectedSource)) && + !getPrettySource(state, props.selectedSource.id), + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, props.selectedSource), + }; +}; + +const mapDispatchToProps = dispatch => ({ + editorActions: editorItemActions(dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(EditorMenu); diff --git a/devtools/client/debugger/src/components/Editor/EmptyLines.js b/devtools/client/debugger/src/components/Editor/EmptyLines.js new file mode 100644 index 0000000000..70a8c9c0a7 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/EmptyLines.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { connect } from "../../utils/connect"; +import { Component } from "react"; +import PropTypes from "prop-types"; +import { getSelectedSource, getSelectedBreakableLines } from "../../selectors"; +import { fromEditorLine } from "../../utils/editor"; +import { isWasm } from "../../utils/wasm"; + +class EmptyLines extends Component { + static get propTypes() { + return { + breakableLines: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + selectedSource: PropTypes.object.isRequired, + }; + } + + componentDidMount() { + this.disableEmptyLines(); + } + + componentDidUpdate() { + this.disableEmptyLines(); + } + + componentWillUnmount() { + const { editor } = this.props; + + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + editor.codeMirror.removeLineClass(lineHandle, "wrap", "empty-line"); + }); + }); + } + + shouldComponentUpdate(nextProps) { + const { breakableLines, selectedSource } = this.props; + return ( + // Breakable lines are something that evolves over time, + // but we either have them loaded or not. So only compare the size + // as sometimes we always get a blank new empty Set instance. + breakableLines.size != nextProps.breakableLines.size || + selectedSource.id != nextProps.selectedSource.id + ); + } + + disableEmptyLines() { + const { breakableLines, selectedSource, editor } = this.props; + + const { codeMirror } = editor; + const isSourceWasm = isWasm(selectedSource.id); + + codeMirror.operation(() => { + const lineCount = codeMirror.lineCount(); + for (let i = 0; i < lineCount; i++) { + const line = fromEditorLine(selectedSource.id, i, isSourceWasm); + + if (breakableLines.has(line)) { + codeMirror.removeLineClass(i, "wrap", "empty-line"); + } else { + codeMirror.addLineClass(i, "wrap", "empty-line"); + } + } + }); + } + + render() { + return null; + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + if (!selectedSource) { + throw new Error("no selectedSource"); + } + const breakableLines = getSelectedBreakableLines(state); + + return { + selectedSource, + breakableLines, + }; +}; + +export default connect(mapStateToProps)(EmptyLines); diff --git a/devtools/client/debugger/src/components/Editor/Exception.js b/devtools/client/debugger/src/components/Editor/Exception.js new file mode 100644 index 0000000000..8527cfed07 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Exception.js @@ -0,0 +1,96 @@ +/* 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 { PureComponent } from "react"; +import PropTypes from "prop-types"; + +import { toEditorPosition, getTokenEnd, hasDocument } from "../../utils/editor"; + +import { getIndentation } from "../../utils/indentation"; +import { createLocation } from "../../utils/location"; + +export default class Exception extends PureComponent { + exceptionLine; + markText; + + static get propTypes() { + return { + exception: PropTypes.object.isRequired, + doc: PropTypes.object.isRequired, + selectedSource: PropTypes.string.isRequired, + }; + } + + componentDidMount() { + this.addEditorExceptionLine(); + } + + componentDidUpdate() { + this.clearEditorExceptionLine(); + this.addEditorExceptionLine(); + } + + componentWillUnmount() { + this.clearEditorExceptionLine(); + } + + setEditorExceptionLine(doc, line, column, lineText) { + doc.addLineClass(line, "wrap", "line-exception"); + + column = Math.max(column, getIndentation(lineText)); + const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null; + + const markText = doc.markText( + { ch: column, line }, + { ch: columnEnd, line }, + { className: "mark-text-exception" } + ); + + this.exceptionLine = line; + this.markText = markText; + } + + addEditorExceptionLine() { + const { exception, doc, selectedSource } = this.props; + const { columnNumber, lineNumber } = exception; + + if (!hasDocument(selectedSource.id)) { + return; + } + + const location = createLocation({ + column: columnNumber - 1, + line: lineNumber, + source: selectedSource, + }); + + const { line, column } = toEditorPosition(location); + const lineText = doc.getLine(line); + + this.setEditorExceptionLine(doc, line, column, lineText); + } + + clearEditorExceptionLine() { + if (this.markText) { + const { selectedSource } = this.props; + + this.markText.clear(); + + if (hasDocument(selectedSource.id)) { + this.props.doc.removeLineClass( + this.exceptionLine, + "wrap", + "line-exception" + ); + } + this.exceptionLine = null; + this.markText = null; + } + } + + // This component is only used as a "proxy" to manipulate the editor. + render() { + return null; + } +} diff --git a/devtools/client/debugger/src/components/Editor/Exceptions.js b/devtools/client/debugger/src/components/Editor/Exceptions.js new file mode 100644 index 0000000000..d1bac48b1b --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Exceptions.js @@ -0,0 +1,67 @@ +/* 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 Exception from "./Exception"; + +import { + getSelectedSource, + getSelectedSourceExceptions, +} from "../../selectors"; +import { getDocument } from "../../utils/editor"; + +class Exceptions extends Component { + static get propTypes() { + return { + exceptions: PropTypes.array, + selectedSource: PropTypes.object, + }; + } + + render() { + const { exceptions, selectedSource } = this.props; + + if (!selectedSource || !exceptions.length) { + return null; + } + + const doc = getDocument(selectedSource.id); + + return ( + <> + {exceptions.map(exc => ( + <Exception + exception={exc} + doc={doc} + key={`${exc.sourceActorId}:${exc.lineNumber}`} + selectedSource={selectedSource} + /> + ))} + </> + ); + } +} + +export default connect(state => { + const selectedSource = getSelectedSource(state); + + // Avoid calling getSelectedSourceExceptions when there is no source selected. + if (!selectedSource) { + return {}; + } + + // Avoid causing any update until we start having exceptions + const exceptions = getSelectedSourceExceptions(state); + if (!exceptions.length) { + return {}; + } + + return { + exceptions: getSelectedSourceExceptions(state), + selectedSource, + }; +})(Exceptions); diff --git a/devtools/client/debugger/src/components/Editor/Footer.css b/devtools/client/debugger/src/components/Editor/Footer.css new file mode 100644 index 0000000000..aee6c51d38 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Footer.css @@ -0,0 +1,85 @@ +/* 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/>. */ + +.source-footer { + background: var(--theme-body-background); + border-top: 1px solid var(--theme-splitter-color); + position: absolute; + display: flex; + bottom: 0; + left: 0; + right: 0; + opacity: 1; + z-index: 1; + width: calc(100% - 1px); + user-select: none; + height: var(--editor-footer-height); + box-sizing: border-box; +} + +.source-footer-start { + display: flex; + align-items: center; + justify-self: start; +} + +.source-footer-end { + display: flex; + margin-left: auto; +} + +.source-footer .commands * { + user-select: none; +} + +.source-footer .commands { + display: flex; +} + +.source-footer .commands .action { + display: flex; + justify-content: center; + align-items: center; + transition: opacity 200ms; + border: none; + background: transparent; + padding: 4px 6px; +} + +.source-footer .commands button.action:hover { + background: var(--theme-toolbar-background-hover); +} + +:root.theme-dark .source-footer .commands .action { + fill: var(--theme-body-color); +} + +:root.theme-dark .source-footer .commands .action:hover { + fill: var(--theme-selection-color); +} + +.source-footer .blackboxed .img.blackBox { + background-color: #806414; +} + +.source-footer .commands button.prettyPrint:disabled { + opacity: 0.6; +} + +.source-footer .mapped-source, +.source-footer .cursor-position { + color: var(--theme-body-color); + padding-right: 2.5px; +} + +.source-footer .mapped-source { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.source-footer .cursor-position { + padding: 5px; + white-space: nowrap; +} diff --git a/devtools/client/debugger/src/components/Editor/Footer.js b/devtools/client/debugger/src/components/Editor/Footer.js new file mode 100644 index 0000000000..ea9acbc6f6 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Footer.js @@ -0,0 +1,302 @@ +/* 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, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import { createLocation } from "../../utils/location"; +import actions from "../../actions"; +import { + getSelectedSource, + getSelectedLocation, + getSelectedSourceTextContent, + getPrettySource, + getPaneCollapse, + getContext, + getGeneratedSource, + isSourceBlackBoxed, + canPrettyPrintSource, + getPrettyPrintMessage, + isSourceOnSourceMapIgnoreList, + isSourceMapIgnoreListEnabled, +} from "../../selectors"; + +import { isPretty, getFilename, shouldBlackbox } from "../../utils/source"; + +import { PaneToggleButton } from "../shared/Button"; +import AccessibleImage from "../shared/AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./Footer.css"; + +class SourceFooter extends PureComponent { + constructor() { + super(); + + this.state = { cursorPosition: { line: 0, column: 0 } }; + } + + static get propTypes() { + return { + canPrettyPrint: PropTypes.bool.isRequired, + prettyPrintMessage: PropTypes.string.isRequired, + cx: PropTypes.object.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + horizontal: PropTypes.bool.isRequired, + jumpToMappedLocation: PropTypes.func.isRequired, + mappedSource: PropTypes.object, + selectedSource: PropTypes.object, + isSelectedSourceBlackBoxed: PropTypes.bool.isRequired, + sourceLoaded: PropTypes.bool.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + togglePaneCollapse: PropTypes.func.isRequired, + togglePrettyPrint: PropTypes.func.isRequired, + isSourceOnIgnoreList: PropTypes.bool.isRequired, + }; + } + + componentDidUpdate() { + const eventDoc = document.querySelector(".editor-mount .CodeMirror"); + // querySelector can return null + if (eventDoc) { + this.toggleCodeMirror(eventDoc, true); + } + } + + componentWillUnmount() { + const eventDoc = document.querySelector(".editor-mount .CodeMirror"); + + if (eventDoc) { + this.toggleCodeMirror(eventDoc, false); + } + } + + toggleCodeMirror(eventDoc, toggle) { + if (toggle === true) { + eventDoc.CodeMirror.on("cursorActivity", this.onCursorChange); + } else { + eventDoc.CodeMirror.off("cursorActivity", this.onCursorChange); + } + } + + prettyPrintButton() { + const { + cx, + selectedSource, + canPrettyPrint, + prettyPrintMessage, + togglePrettyPrint, + sourceLoaded, + } = this.props; + + if (!selectedSource) { + return null; + } + + if (!sourceLoaded && selectedSource.isPrettyPrinted) { + return ( + <div className="action" key="pretty-loader"> + <AccessibleImage className="loader spin" /> + </div> + ); + } + + const type = "prettyPrint"; + return ( + <button + onClick={() => { + if (!canPrettyPrint) { + return; + } + togglePrettyPrint(cx, selectedSource.id); + }} + className={classnames("action", type, { + active: sourceLoaded && canPrettyPrint, + pretty: isPretty(selectedSource), + })} + key={type} + title={prettyPrintMessage} + aria-label={prettyPrintMessage} + disabled={!canPrettyPrint} + > + <AccessibleImage className={type} /> + </button> + ); + } + + blackBoxButton() { + const { + cx, + selectedSource, + isSelectedSourceBlackBoxed, + toggleBlackBox, + sourceLoaded, + isSourceOnIgnoreList, + } = this.props; + + if (!selectedSource || !shouldBlackbox(selectedSource)) { + return null; + } + + let tooltip = isSelectedSourceBlackBoxed + ? L10N.getStr("sourceFooter.unignore") + : L10N.getStr("sourceFooter.ignore"); + + if (isSourceOnIgnoreList) { + tooltip = L10N.getStr("sourceFooter.ignoreList"); + } + + const type = "black-box"; + + return ( + <button + onClick={() => toggleBlackBox(cx, selectedSource)} + className={classnames("action", type, { + active: sourceLoaded, + blackboxed: isSelectedSourceBlackBoxed || isSourceOnIgnoreList, + })} + key={type} + title={tooltip} + aria-label={tooltip} + disabled={isSourceOnIgnoreList} + > + <AccessibleImage className="blackBox" /> + </button> + ); + } + + renderToggleButton() { + if (this.props.horizontal) { + return null; + } + + return ( + <PaneToggleButton + key="toggle" + collapsed={this.props.endPanelCollapsed} + horizontal={this.props.horizontal} + handleClick={this.props.togglePaneCollapse} + position="end" + /> + ); + } + + renderCommands() { + const commands = [this.blackBoxButton(), this.prettyPrintButton()].filter( + Boolean + ); + + return commands.length ? <div className="commands">{commands}</div> : null; + } + + renderSourceSummary() { + const { cx, mappedSource, jumpToMappedLocation, selectedSource } = + this.props; + + if (!mappedSource || !selectedSource || !selectedSource.isOriginal) { + return null; + } + + const filename = getFilename(mappedSource); + const tooltip = L10N.getFormatStr( + "sourceFooter.mappedSourceTooltip", + filename + ); + const title = L10N.getFormatStr("sourceFooter.mappedSource", filename); + const mappedSourceLocation = createLocation({ + source: selectedSource, + line: 1, + column: 1, + }); + return ( + <button + className="mapped-source" + onClick={() => jumpToMappedLocation(cx, mappedSourceLocation)} + title={tooltip} + > + <span>{title}</span> + </button> + ); + } + + onCursorChange = event => { + const { line, ch } = event.doc.getCursor(); + this.setState({ cursorPosition: { line, column: ch } }); + }; + + renderCursorPosition() { + if (!this.props.selectedSource) { + return null; + } + + const { line, column } = this.state.cursorPosition; + + const text = L10N.getFormatStr( + "sourceFooter.currentCursorPosition", + line + 1, + column + 1 + ); + const title = L10N.getFormatStr( + "sourceFooter.currentCursorPosition.tooltip", + line + 1, + column + 1 + ); + return ( + <div className="cursor-position" title={title}> + {text} + </div> + ); + } + + render() { + return ( + <div className="source-footer"> + <div className="source-footer-start">{this.renderCommands()}</div> + <div className="source-footer-end"> + {this.renderSourceSummary()} + {this.renderCursorPosition()} + {this.renderToggleButton()} + </div> + </div> + ); + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + const selectedLocation = getSelectedLocation(state); + const sourceTextContent = getSelectedSourceTextContent(state); + + return { + cx: getContext(state), + selectedSource, + isSelectedSourceBlackBoxed: selectedSource + ? isSourceBlackBoxed(state, selectedSource) + : null, + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, selectedSource), + sourceLoaded: !!sourceTextContent, + mappedSource: getGeneratedSource(state, selectedSource), + prettySource: getPrettySource( + state, + selectedSource ? selectedSource.id : null + ), + endPanelCollapsed: getPaneCollapse(state, "end"), + canPrettyPrint: selectedLocation + ? canPrettyPrintSource(state, selectedLocation) + : false, + prettyPrintMessage: selectedLocation + ? getPrettyPrintMessage(state, selectedLocation) + : null, + }; +}; + +export default connect(mapStateToProps, { + togglePrettyPrint: actions.togglePrettyPrint, + toggleBlackBox: actions.toggleBlackBox, + jumpToMappedLocation: actions.jumpToMappedLocation, + togglePaneCollapse: actions.togglePaneCollapse, +})(SourceFooter); diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.css b/devtools/client/debugger/src/components/Editor/HighlightCalls.css new file mode 100644 index 0000000000..b7e0402cab --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.css @@ -0,0 +1,15 @@ +/* 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/>. */ + +.highlight-function-calls { + background-color: rgba(202, 227, 255, 0.5); +} + +.theme-dark .highlight-function-calls { + background-color: #743884; +} + +.highlight-function-calls:hover { + cursor: default; +} diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.js b/devtools/client/debugger/src/components/Editor/HighlightCalls.js new file mode 100644 index 0000000000..0063f66c7a --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.js @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import { + getHighlightedCalls, + getThreadContext, + getCurrentThread, +} from "../../selectors"; +import { getSourceLocationFromMouseEvent } from "../../utils/editor"; +import actions from "../../actions"; +import "./HighlightCalls.css"; + +export class HighlightCalls extends Component { + previousCalls = null; + + static get propTypes() { + return { + continueToHere: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + highlightedCalls: PropTypes.array, + selectedSource: PropTypes.object, + }; + } + + componentDidUpdate() { + this.unhighlightFunctionCalls(); + this.highlightFunctioCalls(); + } + + markCall = call => { + const { editor } = this.props; + const startLine = call.location.start.line - 1; + const endLine = call.location.end.line - 1; + const startColumn = call.location.start.column; + const endColumn = call.location.end.column; + const markedCall = editor.codeMirror.markText( + { line: startLine, ch: startColumn }, + { line: endLine, ch: endColumn }, + { className: "highlight-function-calls" } + ); + return markedCall; + }; + + onClick = e => { + const { editor, selectedSource, cx, continueToHere } = this.props; + + if (selectedSource) { + const location = getSourceLocationFromMouseEvent( + editor, + selectedSource, + e + ); + continueToHere(cx, location); + editor.codeMirror.execCommand("singleSelection"); + editor.codeMirror.execCommand("goGroupLeft"); + } + }; + + highlightFunctioCalls() { + const { highlightedCalls } = this.props; + + if (!highlightedCalls) { + return; + } + + let markedCalls = []; + markedCalls = highlightedCalls.map(this.markCall); + + const allMarkedElements = document.getElementsByClassName( + "highlight-function-calls" + ); + + for (let i = 0; i < allMarkedElements.length; i++) { + allMarkedElements[i].addEventListener("click", this.onClick); + } + + this.previousCalls = markedCalls; + } + + unhighlightFunctionCalls() { + if (!this.previousCalls) { + return; + } + this.previousCalls.forEach(call => call.clear()); + this.previousCalls = null; + } + + render() { + return null; + } +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + return { + highlightedCalls: getHighlightedCalls(state, thread), + cx: getThreadContext(state), + }; +}; + +const { continueToHere } = actions; + +const mapDispatchToProps = { continueToHere }; + +export default connect(mapStateToProps, mapDispatchToProps)(HighlightCalls); diff --git a/devtools/client/debugger/src/components/Editor/HighlightLine.js b/devtools/client/debugger/src/components/Editor/HighlightLine.js new file mode 100644 index 0000000000..3df0142127 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightLine.js @@ -0,0 +1,183 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { Component } from "react"; +import PropTypes from "prop-types"; +import { toEditorLine, endOperation, startOperation } from "../../utils/editor"; +import { getDocument, hasDocument } from "../../utils/editor/source-documents"; + +import { connect } from "../../utils/connect"; +import { + getVisibleSelectedFrame, + getSelectedLocation, + getSelectedSourceTextContent, + getPauseCommand, + getCurrentThread, +} from "../../selectors"; + +function isDebugLine(selectedFrame, selectedLocation) { + if (!selectedFrame) { + return false; + } + + return ( + selectedFrame.location.sourceId == selectedLocation.sourceId && + selectedFrame.location.line == selectedLocation.line + ); +} + +function isDocumentReady(selectedLocation, selectedSourceTextContent) { + return ( + selectedLocation && + selectedSourceTextContent && + hasDocument(selectedLocation.sourceId) + ); +} + +export class HighlightLine extends Component { + isStepping = false; + previousEditorLine = null; + + static get propTypes() { + return { + pauseCommand: PropTypes.oneOf([ + "expression", + "resume", + "stepOver", + "stepIn", + "stepOut", + ]), + selectedFrame: PropTypes.object, + selectedLocation: PropTypes.object.isRequired, + selectedSourceTextContent: PropTypes.object.isRequired, + }; + } + + shouldComponentUpdate(nextProps) { + const { selectedLocation, selectedSourceTextContent } = nextProps; + return this.shouldSetHighlightLine( + selectedLocation, + selectedSourceTextContent + ); + } + + componentDidUpdate(prevProps) { + this.completeHighlightLine(prevProps); + } + + componentDidMount() { + this.completeHighlightLine(null); + } + + shouldSetHighlightLine(selectedLocation, selectedSourceTextContent) { + const { sourceId, line } = selectedLocation; + const editorLine = toEditorLine(sourceId, line); + + if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) { + return false; + } + + if (this.isStepping && editorLine === this.previousEditorLine) { + return false; + } + + return true; + } + + completeHighlightLine(prevProps) { + const { + pauseCommand, + selectedLocation, + selectedFrame, + selectedSourceTextContent, + } = this.props; + if (pauseCommand) { + this.isStepping = true; + } + + startOperation(); + if (prevProps) { + this.clearHighlightLine( + prevProps.selectedLocation, + prevProps.selectedSourceTextContent + ); + } + this.setHighlightLine( + selectedLocation, + selectedFrame, + selectedSourceTextContent + ); + endOperation(); + } + + setHighlightLine(selectedLocation, selectedFrame, selectedSourceTextContent) { + const { sourceId, line } = selectedLocation; + if ( + !this.shouldSetHighlightLine(selectedLocation, selectedSourceTextContent) + ) { + return; + } + + this.isStepping = false; + const editorLine = toEditorLine(sourceId, line); + this.previousEditorLine = editorLine; + + if (!line || isDebugLine(selectedFrame, selectedLocation)) { + return; + } + + const doc = getDocument(sourceId); + doc.addLineClass(editorLine, "wrap", "highlight-line"); + this.resetHighlightLine(doc, editorLine); + } + + resetHighlightLine(doc, editorLine) { + const editorWrapper = document.querySelector(".editor-wrapper"); + + if (editorWrapper === null) { + return; + } + + const duration = parseInt( + getComputedStyle(editorWrapper).getPropertyValue( + "--highlight-line-duration" + ), + 10 + ); + + setTimeout( + () => doc && doc.removeLineClass(editorLine, "wrap", "highlight-line"), + duration + ); + } + + clearHighlightLine(selectedLocation, selectedSourceTextContent) { + if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) { + return; + } + + const { line, sourceId } = selectedLocation; + const editorLine = toEditorLine(sourceId, line); + const doc = getDocument(sourceId); + doc.removeLineClass(editorLine, "wrap", "highlight-line"); + } + + render() { + return null; + } +} + +export default connect(state => { + const selectedLocation = getSelectedLocation(state); + + if (!selectedLocation) { + throw new Error("must have selected location"); + } + return { + pauseCommand: getPauseCommand(state, getCurrentThread(state)), + selectedFrame: getVisibleSelectedFrame(state), + selectedLocation, + selectedSourceTextContent: getSelectedSourceTextContent(state), + }; +})(HighlightLine); diff --git a/devtools/client/debugger/src/components/Editor/HighlightLines.js b/devtools/client/debugger/src/components/Editor/HighlightLines.js new file mode 100644 index 0000000000..bffa209e7d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightLines.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { Component } from "react"; +import PropTypes from "prop-types"; + +class HighlightLines extends Component { + static get propTypes() { + return { + editor: PropTypes.object.isRequired, + range: PropTypes.object.isRequired, + }; + } + + componentDidMount() { + this.highlightLineRange(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillUpdate() { + this.clearHighlightRange(); + } + + componentDidUpdate() { + this.highlightLineRange(); + } + + componentWillUnmount() { + this.clearHighlightRange(); + } + + clearHighlightRange() { + const { range, editor } = this.props; + + const { codeMirror } = editor; + + if (!range || !codeMirror) { + return; + } + + const { start, end } = range; + codeMirror.operation(() => { + for (let line = start - 1; line < end; line++) { + codeMirror.removeLineClass(line, "wrap", "highlight-lines"); + } + }); + } + + highlightLineRange = () => { + const { range, editor } = this.props; + + const { codeMirror } = editor; + + if (!range || !codeMirror) { + return; + } + + const { start, end } = range; + + codeMirror.operation(() => { + editor.alignLine(start); + for (let line = start - 1; line < end; line++) { + codeMirror.addLineClass(line, "wrap", "highlight-lines"); + } + }); + }; + + render() { + return null; + } +} + +export default HighlightLines; diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.css b/devtools/client/debugger/src/components/Editor/InlinePreview.css new file mode 100644 index 0000000000..13f1b5e23c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreview.css @@ -0,0 +1,29 @@ +/* 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/>. */ + +.inline-preview { + display: inline-block; + margin-inline-start: 8px; + user-select: none; +} + +.inline-preview-outer { + background-color: var(--theme-inline-preview-background); + border: 1px solid var(--theme-inline-preview-border-color); + border-radius: 3px; + font-size: 10px; + margin-right: 5px; + white-space: nowrap; +} + +.inline-preview-label { + padding: 0px 2px 0px 4px; + border-radius: 2px 0 0 2px; + color: var(--theme-inline-preview-label-color); + background-color: var(--theme-inline-preview-label-background); +} + +.inline-preview-value { + padding: 2px 6px; +} diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.js b/devtools/client/debugger/src/components/Editor/InlinePreview.js new file mode 100644 index 0000000000..f978965134 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreview.js @@ -0,0 +1,66 @@ +/* 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, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import Reps from "devtools/client/shared/components/reps/index"; + +const { + REPS: { + Rep, + ElementNode: { supportsObject: isElement }, + }, + MODE, +} = Reps; + +// Renders single variable preview inside a codemirror line widget +class InlinePreview extends PureComponent { + static get propTypes() { + return { + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + value: PropTypes.any, + variable: PropTypes.string.isRequired, + }; + } + + showInScopes(variable) { + // TODO: focus on variable value in the scopes sidepanel + // we will need more info from parent comp + } + + render() { + const { + value, + variable, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const mode = isElement(value) ? MODE.TINY : MODE.SHORT; + + return ( + <span + className="inline-preview-outer" + onClick={() => this.showInScopes(variable)} + > + <span className="inline-preview-label">{variable}:</span> + <span className="inline-preview-value"> + <Rep + object={value} + mode={mode} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + /> + </span> + </span> + ); + } +} + +export default InlinePreview; diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js new file mode 100644 index 0000000000..ad2631e01e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js @@ -0,0 +1,101 @@ +/* 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, { PureComponent } from "react"; +import ReactDOM from "react-dom"; + +import actions from "../../actions"; +import assert from "../../utils/assert"; +import { connect } from "../../utils/connect"; +import InlinePreview from "./InlinePreview"; + +import "./InlinePreview.css"; + +// Handles rendering for each line ( row ) +// * Renders single widget for each line in codemirror +// * Renders InlinePreview for each preview inside the widget +class InlinePreviewRow extends PureComponent { + bookmark; + widgetNode; + + componentDidMount() { + this.updatePreviewWidget(this.props, null); + } + + componentDidUpdate(prevProps) { + this.updatePreviewWidget(this.props, prevProps); + } + + componentWillUnmount() { + this.updatePreviewWidget(null, this.props); + } + + updatePreviewWidget(props, prevProps) { + if ( + this.bookmark && + prevProps && + (!props || + prevProps.editor !== props.editor || + prevProps.line !== props.line) + ) { + this.bookmark.clear(); + this.bookmark = null; + this.widgetNode = null; + } + + if (!props) { + assert(!this.bookmark, "Inline Preview widget shouldn't be present."); + return; + } + + const { + editor, + line, + previews, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = props; + + if (!this.bookmark) { + this.widgetNode = document.createElement("div"); + this.widgetNode.classList.add("inline-preview"); + } + + ReactDOM.render( + <React.Fragment> + {previews.map(preview => ( + <InlinePreview + line={line} + key={`${line}-${preview.name}`} + variable={preview.name} + value={preview.value} + openElementInInspector={openElementInInspector} + highlightDomElement={highlightDomElement} + unHighlightDomElement={unHighlightDomElement} + /> + ))} + </React.Fragment>, + this.widgetNode + ); + + this.bookmark = editor.codeMirror.setBookmark( + { + line, + ch: Infinity, + }, + this.widgetNode + ); + } + + render() { + return null; + } +} + +export default connect(() => ({}), { + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, +})(InlinePreviewRow); diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviews.js b/devtools/client/debugger/src/components/Editor/InlinePreviews.js new file mode 100644 index 0000000000..8778cb373c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreviews.js @@ -0,0 +1,83 @@ +/* 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 InlinePreviewRow from "./InlinePreviewRow"; +import { connect } from "../../utils/connect"; +import { + getSelectedFrame, + getCurrentThread, + getInlinePreviews, +} from "../../selectors"; + +function hasPreviews(previews) { + return !!previews && !!Object.keys(previews).length; +} + +class InlinePreviews extends Component { + static get propTypes() { + return { + editor: PropTypes.object.isRequired, + previews: PropTypes.object, + selectedFrame: PropTypes.object.isRequired, + selectedSource: PropTypes.object.isRequired, + }; + } + + shouldComponentUpdate({ previews }) { + return hasPreviews(previews); + } + + render() { + const { editor, selectedFrame, selectedSource, previews } = this.props; + + // Render only if currently open file is the one where debugger is paused + if ( + !selectedFrame || + selectedFrame.location.sourceId !== selectedSource.id || + !hasPreviews(previews) + ) { + return null; + } + const previewsObj = previews; + + let inlinePreviewRows; + editor.codeMirror.operation(() => { + inlinePreviewRows = Object.keys(previewsObj).map(line => { + const lineNum = parseInt(line, 10); + + return ( + <InlinePreviewRow + editor={editor} + key={line} + line={lineNum} + previews={previewsObj[line]} + /> + ); + }); + }); + + return <div>{inlinePreviewRows}</div>; + } +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + const selectedFrame = getSelectedFrame(state, thread); + + if (!selectedFrame) { + return { + selectedFrame: null, + previews: null, + }; + } + + return { + selectedFrame, + previews: getInlinePreviews(state, thread, selectedFrame.id), + }; +}; + +export default connect(mapStateToProps)(InlinePreviews); diff --git a/devtools/client/debugger/src/components/Editor/Preview.css b/devtools/client/debugger/src/components/Editor/Preview.css new file mode 100644 index 0000000000..35b874315e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview.css @@ -0,0 +1,111 @@ +/* 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/>. */ + +.popover .preview { + background: var(--theme-body-background); + width: 350px; + border: 1px solid var(--theme-splitter-color); + padding: 10px; + height: auto; + min-height: inherit; + max-height: 200px; + overflow: auto; + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.theme-dark .popover .preview { + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.popover .preview .header { + width: 100%; + line-height: 20px; + border-bottom: 1px solid #cccccc; + display: flex; + flex-direction: column; +} + +.popover .preview .header .link { + align-self: flex-end; + color: var(--theme-highlight-blue); + text-decoration: underline; +} + +.selection, +.debug-expression.selection { + background-color: var(--theme-highlight-yellow); +} + +.theme-dark .selection, +.theme-dark .debug-expression.selection { + background-color: #743884; +} + +.theme-dark .cm-s-mozilla .selection, +.theme-dark .cm-s-mozilla .debug-expression.selection { + color: #e7ebee; +} + +.popover .preview .function-signature { + padding-top: 10px; +} + +.theme-dark .popover .preview { + border-color: var(--theme-body-color); +} + +.tooltip { + position: fixed; + z-index: 100; +} + +.tooltip .preview { + background: var(--theme-toolbar-background); + max-width: inherit; + border: 1px solid var(--theme-splitter-color); + box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt); + padding: 5px; + height: auto; + min-height: inherit; + max-height: 200px; + overflow: auto; +} + +.theme-dark .tooltip .preview { + border-color: var(--theme-body-color); +} + +.tooltip .gap { + height: 4px; + padding-top: 4px; +} + +.add-to-expression-bar { + border: 1px solid var(--theme-splitter-color); + border-top: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + font-size: 14px; + line-height: 30px; + background: var(--theme-toolbar-background); + color: var(--theme-text-color-inactive); + padding: 0 4px; +} + +.add-to-expression-bar .prompt { + width: 1em; +} + +.add-to-expression-bar .expression-to-save-label { + width: calc(100% - 4em); +} + +.add-to-expression-bar .expression-to-save-button { + font-size: 14px; + color: var(--theme-comment); +} diff --git a/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js new file mode 100644 index 0000000000..624a78fb8b --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js @@ -0,0 +1,164 @@ +/* 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 Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { StringRep }, +} = Reps; + +import actions from "../../../actions"; + +import { getThreadContext } from "../../../selectors"; + +import AccessibleImage from "../../shared/AccessibleImage"; + +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const classnames = require("devtools/client/shared/classnames.js"); + +const POPUP_SELECTOR = ".preview-popup.exception-popup"; +const ANONYMOUS_FN_NAME = "<anonymous>"; + +// The exception popup works in two modes: +// a. when the stacktrace is closed the exception popup +// gets closed when the mouse leaves the popup. +// b. when the stacktrace is opened the exception popup +// gets closed only by clicking outside the popup. +class ExceptionPopup extends Component { + constructor(props) { + super(props); + this.state = { + isStacktraceExpanded: false, + }; + } + + static get propTypes() { + return { + clearPreview: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + mouseout: PropTypes.func.isRequired, + selectSourceURL: PropTypes.func.isRequired, + exception: PropTypes.object.isRequired, + }; + } + + updateTopWindow() { + // The ChromeWindow is used when the stacktrace is expanded to capture all clicks + // outside the popup so the popup can be closed only by clicking outside of it. + if (this.topWindow) { + this.topWindow.removeEventListener( + "mousedown", + this.onTopWindowClick, + true + ); + this.topWindow = null; + } + this.topWindow = DevToolsUtils.getTopWindow(window.parent); + this.topWindow.addEventListener("mousedown", this.onTopWindowClick, true); + } + + onTopWindowClick = e => { + const { cx, clearPreview } = this.props; + + // When the stactrace is expaned the exception popup gets closed + // only by clicking ouside the popup. + if (!e.target.closest(POPUP_SELECTOR)) { + clearPreview(cx); + } + }; + + onExceptionMessageClick() { + const isStacktraceExpanded = this.state.isStacktraceExpanded; + + this.updateTopWindow(); + this.setState({ isStacktraceExpanded: !isStacktraceExpanded }); + } + + buildStackFrame(frame) { + const { cx, selectSourceURL } = this.props; + const { filename, lineNumber } = frame; + const functionName = frame.functionName || ANONYMOUS_FN_NAME; + + return ( + <div + className="frame" + onClick={() => selectSourceURL(cx, filename, { line: lineNumber })} + > + <span className="title">{functionName}</span> + <span className="location"> + <span className="filename">{filename}</span>: + <span className="line">{lineNumber}</span> + </span> + </div> + ); + } + + renderStacktrace(stacktrace) { + const isStacktraceExpanded = this.state.isStacktraceExpanded; + + if (stacktrace.length && isStacktraceExpanded) { + return ( + <div className="exception-stacktrace"> + {stacktrace.map(frame => this.buildStackFrame(frame))} + </div> + ); + } + return null; + } + + renderArrowIcon(stacktrace) { + if (stacktrace.length) { + return ( + <AccessibleImage + className={classnames("arrow", { + expanded: this.state.isStacktraceExpanded, + })} + /> + ); + } + return null; + } + + render() { + const { + exception: { stacktrace, errorMessage }, + mouseout, + } = this.props; + + return ( + <div + className="preview-popup exception-popup" + dir="ltr" + onMouseLeave={() => mouseout(true, this.state.isStacktraceExpanded)} + > + <div + className="exception-message" + onClick={() => this.onExceptionMessageClick()} + > + {this.renderArrowIcon(stacktrace)} + {StringRep.rep({ + object: errorMessage, + useQuotes: false, + className: "exception-text", + })} + </div> + {this.renderStacktrace(stacktrace)} + </div> + ); + } +} + +const mapStateToProps = state => ({ + cx: getThreadContext(state), +}); + +const mapDispatchToProps = { + selectSourceURL: actions.selectSourceURL, + clearPreview: actions.clearPreview, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ExceptionPopup); diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.css b/devtools/client/debugger/src/components/Editor/Preview/Popup.css new file mode 100644 index 0000000000..3e578becf1 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.css @@ -0,0 +1,209 @@ +/* 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/>. */ + +.popover .preview-popup { + background: var(--theme-body-background); + width: 350px; + border: 1px solid var(--theme-splitter-color); + padding: 10px; + height: auto; + overflow: auto; + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.preview-popup .tree { + /* Setting a fixed line height to avoid issues in custom formatters changing + * the line height like the CLJS DevTools */ + line-height: 15px; +} + +.gap svg { + pointer-events: none; +} + +.gap polygon { + pointer-events: auto; +} + +.theme-dark .popover .preview-popup { + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.popover .preview-popup .header-container { + width: 100%; + line-height: 15px; + display: flex; + flex-direction: row; + margin-bottom: 5px; +} + +.popover .preview-popup .logo { + width: 20px; + margin-right: 5px; +} + +.popover .preview-popup .header-container h3 { + margin: 0; + margin-bottom: 5px; + font-weight: normal; + font-size: 14px; + line-height: 20px; + margin-left: 4px; +} + +.popover .preview-popup .header .link { + align-self: flex-end; + color: var(--theme-highlight-blue); + text-decoration: underline; +} + +.popover .preview-popup .object-node { + padding-inline-start: 0px; +} + +.preview-token:hover { + cursor: default; +} + +.preview-token, +.debug-expression.preview-token { + background-color: var(--theme-highlight-yellow); +} + +.theme-dark .preview-token, +.theme-dark .debug-expression.preview-token { + background-color: #743884; +} + +.theme-dark .cm-s-mozilla .preview-token, +.theme-dark .cm-s-mozilla .debug-expression.preview-token { + color: #e7ebee; +} + +.popover .preview-popup .function-signature { + padding-top: 10px; +} + +.theme-dark .popover .preview-popup { + border-color: var(--theme-body-color); +} + +.tooltip { + position: fixed; + z-index: 100; +} + +.tooltip .preview-popup { + background: var(--theme-toolbar-background); + max-width: inherit; + border: 1px solid var(--theme-splitter-color); + box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt); + padding: 5px; + height: auto; + min-height: inherit; + max-height: 200px; + overflow: auto; +} + +.theme-dark .tooltip .preview-popup { + border-color: var(--theme-body-color); +} + +.tooltip .gap { + height: 4px; + padding-top: 0px; +} + +.add-to-expression-bar { + border: 1px solid var(--theme-splitter-color); + border-top: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + font-size: 14px; + line-height: 30px; + background: var(--theme-toolbar-background); + color: var(--theme-text-color-inactive); + padding: 0 4px; +} + +.add-to-expression-bar .prompt { + width: 1em; +} + +.add-to-expression-bar .expression-to-save-label { + width: calc(100% - 4em); +} + +.add-to-expression-bar .expression-to-save-button { + font-size: 14px; + color: var(--theme-comment); +} + +/* Exception popup */ +.exception-popup .exception-text { + color: var(--red-70); +} + +.theme-dark .exception-popup .exception-text { + color: var(--red-20); +} + +.exception-popup .exception-message { + display: flex; + align-items: center; +} + +.exception-message .arrow { + margin-inline-end: 4px; +} + +.exception-popup .exception-stacktrace { + display: grid; + grid-template-columns: auto 1fr; + grid-column-gap: 8px; + padding-inline: 2px 3px; + line-height: var(--theme-code-line-height); +} + +.exception-stacktrace .frame { + display: contents; + cursor: pointer; +} + +.exception-stacktrace .title { + grid-column: 1/2; + color: var(--grey-90); +} + +.theme-dark .exception-stacktrace .title { + color: white; +} + +.exception-stacktrace .location { + grid-column: -1/-2; + color: var(--theme-highlight-purple); + direction: rtl; + text-align: end; + white-space: nowrap; + /* Force the location to be on one line and crop at start if wider then max-width */ + overflow: hidden; + text-overflow: ellipsis; + max-width: 350px; +} + +.theme-dark .exception-stacktrace .location { + color: var(--blue-40); +} + +.exception-stacktrace .line { + color: var(--theme-highlight-blue); +} + +.theme-dark .exception-stacktrace .line { + color: hsl(210, 40%, 60%); +} diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.js b/devtools/client/debugger/src/components/Editor/Preview/Popup.js new file mode 100644 index 0000000000..3097d3c945 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js @@ -0,0 +1,382 @@ +/* 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 Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { Rep }, + MODE, + objectInspector, +} = Reps; + +const { ObjectInspector, utils } = objectInspector; + +const { + node: { nodeIsPrimitive, nodeIsFunction, nodeIsObject }, +} = utils; + +import ExceptionPopup from "./ExceptionPopup"; + +import actions from "../../../actions"; +import { getThreadContext } from "../../../selectors"; +import Popover from "../../shared/Popover"; +import PreviewFunction from "../../shared/PreviewFunction"; + +import "./Popup.css"; + +export class Popup extends Component { + constructor(props) { + super(props); + } + + static get propTypes() { + return { + clearPreview: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editorRef: PropTypes.object.isRequired, + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openLink: PropTypes.func.isRequired, + preview: PropTypes.object.isRequired, + selectSourceURL: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + }; + } + + componentDidMount() { + this.addHighlightToToken(); + } + + componentWillUnmount() { + this.removeHighlightFromToken(); + } + + addHighlightToToken() { + const { target } = this.props.preview; + if (target) { + target.classList.add("preview-token"); + addHighlightToTargetSiblings(target, this.props); + } + } + + removeHighlightFromToken() { + const { target } = this.props.preview; + if (target) { + target.classList.remove("preview-token"); + removeHighlightForTargetSiblings(target); + } + } + + calculateMaxHeight = () => { + const { editorRef } = this.props; + if (!editorRef) { + return "auto"; + } + + const { height, top } = editorRef.getBoundingClientRect(); + const maxHeight = height + top; + if (maxHeight < 250) { + return maxHeight; + } + + return 250; + }; + + createElement(element) { + return document.createElement(element); + } + + renderFunctionPreview() { + const { + cx, + selectSourceURL, + preview: { resultGrip }, + } = this.props; + + if (!resultGrip) { + return null; + } + + const { location } = resultGrip; + + return ( + <div + className="preview-popup" + onClick={() => + location && + selectSourceURL(cx, location.url, { + line: location.line, + }) + } + > + <PreviewFunction func={resultGrip} /> + </div> + ); + } + + renderObjectPreview() { + const { + preview: { root, properties }, + openLink, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const usesCustomFormatter = + root?.contents?.value?.useCustomFormatter ?? false; + + if (!properties.length) { + return ( + <div className="preview-popup"> + <span className="label">{L10N.getStr("preview.noProperties")}</span> + </div> + ); + } + + const roots = usesCustomFormatter ? [root] : properties; + + return ( + <div + className="preview-popup" + style={{ maxHeight: this.calculateMaxHeight() }} + > + <ObjectInspector + roots={roots} + autoExpandDepth={0} + autoReleaseObjectActors={false} + mode={usesCustomFormatter ? MODE.LONG : null} + disableWrap={true} + focusable={false} + openLink={openLink} + createElement={this.createElement} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + mayUseCustomFormatter={true} + /> + </div> + ); + } + + renderSimplePreview() { + const { + openLink, + preview: { resultGrip }, + } = this.props; + return ( + <div className="preview-popup"> + {Rep({ + object: resultGrip, + mode: MODE.LONG, + openLink, + })} + </div> + ); + } + + renderExceptionPreview(exception) { + return ( + <ExceptionPopup + exception={exception} + mouseout={this.onMouseOutException} + /> + ); + } + + renderPreview() { + // We don't have to check and + // return on `false`, `""`, `0`, `undefined` etc, + // these falsy simple typed value because we want to + // do `renderSimplePreview` on these values below. + const { + preview: { root, exception }, + } = this.props; + + if (nodeIsFunction(root)) { + return this.renderFunctionPreview(); + } + + if (nodeIsObject(root)) { + return <div>{this.renderObjectPreview()}</div>; + } + + if (exception) { + return this.renderExceptionPreview(exception); + } + + return this.renderSimplePreview(); + } + + getPreviewType() { + const { + preview: { root, properties, exception }, + } = this.props; + if ( + exception || + nodeIsPrimitive(root) || + nodeIsFunction(root) || + !Array.isArray(properties) || + properties.length === 0 + ) { + return "tooltip"; + } + + return "popover"; + } + + onMouseOut = () => { + const { clearPreview, cx } = this.props; + + clearPreview(cx); + }; + + onMouseOutException = (shouldClearOnMouseout, isExceptionStactraceOpen) => { + // onMouseOutException can be called: + // a. when the mouse leaves Popover element + // b. when the mouse leaves ExceptionPopup element + // We want to prevent closing the popup when the stacktrace + // is expanded and the mouse leaves either the Popover element + // or the ExceptionPopup element. + const { clearPreview, cx } = this.props; + + if (shouldClearOnMouseout) { + this.isExceptionStactraceOpen = isExceptionStactraceOpen; + } + + if (!this.isExceptionStactraceOpen) { + clearPreview(cx); + } + }; + + render() { + const { + preview: { cursorPos, resultGrip, exception }, + editorRef, + } = this.props; + + if ( + !exception && + (typeof resultGrip == "undefined" || resultGrip?.optimizedOut) + ) { + return null; + } + + const type = this.getPreviewType(); + return ( + <Popover + targetPosition={cursorPos} + type={type} + editorRef={editorRef} + target={this.props.preview.target} + mouseout={exception ? this.onMouseOutException : this.onMouseOut} + > + {this.renderPreview()} + </Popover> + ); + } +} + +export function addHighlightToTargetSiblings(target, props) { + // This function searches for related tokens that should also be highlighted when previewed. + // Here is the process: + // It conducts a search on the target's next siblings and then another search for the previous siblings. + // If a sibling is not an element node (nodeType === 1), the highlight is not added and the search is short-circuited. + // If the element sibling is the same token type as the target, and is also found in the preview expression, the highlight class is added. + + const tokenType = target.classList.item(0); + const previewExpression = props.preview.expression; + + if ( + tokenType && + previewExpression && + target.innerHTML !== previewExpression + ) { + let nextSibling = target.nextSibling; + let nextElementSibling = target.nextElementSibling; + + // Note: Declaring previous/next ELEMENT siblings as well because + // properties like innerHTML can't be checked on nextSibling + // without creating a flow error even if the node is an element type. + while ( + nextSibling && + nextElementSibling && + nextSibling.nodeType === 1 && + nextElementSibling.className.includes(tokenType) && + previewExpression.includes(nextElementSibling.innerHTML) + ) { + // All checks passed, add highlight and continue the search. + nextElementSibling.classList.add("preview-token"); + + nextSibling = nextSibling.nextSibling; + nextElementSibling = nextElementSibling.nextElementSibling; + } + + let previousSibling = target.previousSibling; + let previousElementSibling = target.previousElementSibling; + + while ( + previousSibling && + previousElementSibling && + previousSibling.nodeType === 1 && + previousElementSibling.className.includes(tokenType) && + previewExpression.includes(previousElementSibling.innerHTML) + ) { + // All checks passed, add highlight and continue the search. + previousElementSibling.classList.add("preview-token"); + + previousSibling = previousSibling.previousSibling; + previousElementSibling = previousElementSibling.previousElementSibling; + } + } +} + +export function removeHighlightForTargetSiblings(target) { + // Look at target's previous and next token siblings. + // If they also have the highlight class 'preview-token', + // remove that class. + let nextSibling = target.nextElementSibling; + while (nextSibling && nextSibling.className.includes("preview-token")) { + nextSibling.classList.remove("preview-token"); + nextSibling = nextSibling.nextElementSibling; + } + let previousSibling = target.previousElementSibling; + while ( + previousSibling && + previousSibling.className.includes("preview-token") + ) { + previousSibling.classList.remove("preview-token"); + previousSibling = previousSibling.previousElementSibling; + } +} + +const mapStateToProps = state => ({ + cx: getThreadContext(state), +}); + +const { + addExpression, + selectSourceURL, + openLink, + openElementInInspectorCommand, + highlightDomElement, + unHighlightDomElement, + clearPreview, +} = actions; + +const mapDispatchToProps = { + addExpression, + selectSourceURL, + openLink, + openElementInInspector: openElementInInspectorCommand, + highlightDomElement, + unHighlightDomElement, + clearPreview, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Popup); diff --git a/devtools/client/debugger/src/components/Editor/Preview/index.js b/devtools/client/debugger/src/components/Editor/Preview/index.js new file mode 100644 index 0000000000..0e2c70c557 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/index.js @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import PropTypes from "prop-types"; +import React, { PureComponent } from "react"; +import { connect } from "../../../utils/connect"; + +import Popup from "./Popup"; + +import { + getPreview, + getThreadContext, + getCurrentThread, + getHighlightedCalls, + getIsCurrentThreadPaused, +} from "../../../selectors"; +import actions from "../../../actions"; + +const EXCEPTION_MARKER = "mark-text-exception"; + +class Preview extends PureComponent { + target = null; + constructor(props) { + super(props); + this.state = { selecting: false }; + } + + static get propTypes() { + return { + clearPreview: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + editorRef: PropTypes.object.isRequired, + highlightedCalls: PropTypes.array, + isPaused: PropTypes.bool.isRequired, + preview: PropTypes.object, + setExceptionPreview: PropTypes.func.isRequired, + updatePreview: PropTypes.func.isRequired, + }; + } + + componentDidMount() { + this.updateListeners(); + } + + componentWillUnmount() { + const { codeMirror } = this.props.editor; + const codeMirrorWrapper = codeMirror.getWrapperElement(); + + codeMirror.off("tokenenter", this.onTokenEnter); + codeMirror.off("scroll", this.onScroll); + codeMirrorWrapper.removeEventListener("mouseup", this.onMouseUp); + codeMirrorWrapper.removeEventListener("mousedown", this.onMouseDown); + } + + updateListeners(prevProps) { + const { codeMirror } = this.props.editor; + const codeMirrorWrapper = codeMirror.getWrapperElement(); + codeMirror.on("tokenenter", this.onTokenEnter); + codeMirror.on("scroll", this.onScroll); + codeMirrorWrapper.addEventListener("mouseup", this.onMouseUp); + codeMirrorWrapper.addEventListener("mousedown", this.onMouseDown); + } + + onTokenEnter = ({ target, tokenPos }) => { + const { cx, editor, updatePreview, highlightedCalls, setExceptionPreview } = + this.props; + + const isTargetException = target.classList.contains(EXCEPTION_MARKER); + + if (isTargetException) { + setExceptionPreview(cx, target, tokenPos, editor.codeMirror); + return; + } + + if ( + this.props.isPaused && + !this.state.selecting && + highlightedCalls === null && + !isTargetException + ) { + updatePreview(cx, target, tokenPos, editor.codeMirror); + } + }; + + onMouseUp = () => { + if (this.props.isPaused) { + this.setState({ selecting: false }); + } + }; + + onMouseDown = () => { + if (this.props.isPaused) { + this.setState({ selecting: true }); + } + }; + + onScroll = () => { + if (this.props.isPaused) { + this.props.clearPreview(this.props.cx); + } + }; + + render() { + const { preview } = this.props; + if (!preview || this.state.selecting) { + return null; + } + + return ( + <Popup + preview={preview} + editor={this.props.editor} + editorRef={this.props.editorRef} + /> + ); + } +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + return { + highlightedCalls: getHighlightedCalls(state, thread), + cx: getThreadContext(state), + preview: getPreview(state), + isPaused: getIsCurrentThreadPaused(state), + }; +}; + +export default connect(mapStateToProps, { + clearPreview: actions.clearPreview, + addExpression: actions.addExpression, + updatePreview: actions.updatePreview, + setExceptionPreview: actions.setExceptionPreview, +})(Preview); diff --git a/devtools/client/debugger/src/components/Editor/Preview/moz.build b/devtools/client/debugger/src/components/Editor/Preview/moz.build new file mode 100644 index 0000000000..362faadc42 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/moz.build @@ -0,0 +1,12 @@ +# 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( + "ExceptionPopup.js", + "index.js", + "Popup.js", +) diff --git a/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js new file mode 100644 index 0000000000..8c58fe9c63 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js @@ -0,0 +1,107 @@ +/* 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 { + addHighlightToTargetSiblings, + removeHighlightForTargetSiblings, +} from "../Popup"; + +describe("addHighlightToTargetSiblings", () => { + it("should add preview highlight class to related target siblings", async () => { + const div = document.createElement("div"); + const divChildren = ["a", "divided", "token"]; + divChildren.forEach(function (span) { + const child = document.createElement("span"); + const text = document.createTextNode(span); + child.appendChild(text); + child.classList.add("cm-property"); + div.appendChild(child); + }); + + const target = div.children[1]; + const props = { + preview: { + expression: "adividedtoken", + }, + }; + + addHighlightToTargetSiblings(target, props); + + const previous = target.previousElementSibling; + if (previous && previous.className) { + expect(previous.className.includes("preview-token")).toEqual(true); + } + + const next = target.nextElementSibling; + if (next && next.className) { + expect(next.className.includes("preview-token")).toEqual(true); + } + }); + + it("should not add preview highlight class to target's related siblings after non-element nodes", () => { + const div = document.createElement("div"); + + const elementBeforePeriod = document.createElement("span"); + elementBeforePeriod.innerHTML = "object"; + elementBeforePeriod.classList.add("cm-property"); + div.appendChild(elementBeforePeriod); + + const period = document.createTextNode("."); + div.appendChild(period); + + const target = document.createElement("span"); + target.innerHTML = "property"; + target.classList.add("cm-property"); + div.appendChild(target); + + const anotherPeriod = document.createTextNode("."); + div.appendChild(anotherPeriod); + + const elementAfterPeriod = document.createElement("span"); + elementAfterPeriod.innerHTML = "anotherProperty"; + elementAfterPeriod.classList.add("cm-property"); + div.appendChild(elementAfterPeriod); + + const props = { + preview: { + expression: "object.property.anotherproperty", + }, + }; + addHighlightToTargetSiblings(target, props); + + expect(elementBeforePeriod.className.includes("preview-token")).toEqual( + false + ); + expect(elementAfterPeriod.className.includes("preview-token")).toEqual( + false + ); + }); +}); + +describe("removeHighlightForTargetSiblings", () => { + it("should remove preview highlight class from target's related siblings", async () => { + const div = document.createElement("div"); + const divChildren = ["a", "divided", "token"]; + divChildren.forEach(function (span) { + const child = document.createElement("span"); + const text = document.createTextNode(span); + child.appendChild(text); + child.classList.add("preview-token"); + div.appendChild(child); + }); + const target = div.children[1]; + + removeHighlightForTargetSiblings(target); + + const previous = target.previousElementSibling; + if (previous && previous.className) { + expect(previous.className.includes("preview-token")).toEqual(false); + } + + const next = target.nextElementSibling; + if (next && next.className) { + expect(next.className.includes("preview-token")).toEqual(false); + } + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.css b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css new file mode 100644 index 0000000000..0f75783c00 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css @@ -0,0 +1,39 @@ +/* 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-bar { + position: relative; + display: flex; + border-top: 1px solid var(--theme-splitter-color); + height: var(--editor-searchbar-height); +} + +/* display a fake outline above the search bar's top border, and above + the source footer's top border */ +.search-bar::before { + content: ""; + position: absolute; + z-index: 10; + top: -1px; + left: 0; + right: 0; + bottom: -1px; + border: solid 1px var(--blue-50); + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease-out; +} + +.search-bar:focus-within::before { + opacity: 1; +} + +.search-bar .search-outline { + flex-grow: 1; + border-width: 0; +} + +.search-bar .result-list { + max-height: 230px; +} diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.js b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js new file mode 100644 index 0000000000..80a6d28fb0 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js @@ -0,0 +1,371 @@ +/* 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 PropTypes from "prop-types"; +import React, { Component } from "react"; +import { connect } from "../../utils/connect"; +import actions from "../../actions"; +import { + getActiveSearch, + getSelectedSource, + getContext, + getSelectedSourceTextContent, + getSearchOptions, +} from "../../selectors"; + +import { searchKeys } from "../../constants"; +import { scrollList } from "../../utils/result-list"; + +import SearchInput from "../shared/SearchInput"; +import "./SearchInFileBar.css"; + +const { PluralForm } = require("devtools/shared/plural-form"); +const { debounce } = require("devtools/shared/debounce"); +import { renderWasmText } from "../../utils/wasm"; +import { + clearSearch, + find, + findNext, + findPrev, + removeOverlay, +} from "../../utils/editor"; +import { isFulfilled } from "../../utils/async-value"; + +function getSearchShortcut() { + return L10N.getStr("sourceSearch.search.key2"); +} + +class SearchInFileBar extends Component { + constructor(props) { + super(props); + this.state = { + query: "", + selectedResultIndex: 0, + results: { + matches: [], + matchIndex: -1, + count: 0, + index: -1, + }, + inputFocused: false, + }; + } + + static get propTypes() { + return { + closeFileSearch: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + editor: PropTypes.object, + modifiers: PropTypes.object.isRequired, + searchInFileEnabled: PropTypes.bool.isRequired, + selectedSourceTextContent: PropTypes.bool.isRequired, + selectedSource: PropTypes.object.isRequired, + setActiveSearch: PropTypes.func.isRequired, + querySearchWorker: PropTypes.func.isRequired, + }; + } + + componentWillUnmount() { + const { shortcuts } = this.context; + + shortcuts.off(getSearchShortcut(), this.toggleSearch); + shortcuts.off("Escape", this.onEscape); + + this.doSearch.cancel(); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { query } = this.state; + // If a new source is selected update the file search results + if ( + this.props.selectedSource && + nextProps.selectedSource !== this.props.selectedSource && + this.props.searchInFileEnabled && + query + ) { + this.doSearch(query, false); + } + } + + componentDidMount() { + // overwrite this.doSearch with debounced version to + // reduce frequency of queries + this.doSearch = debounce(this.doSearch, 100); + const { shortcuts } = this.context; + + shortcuts.on(getSearchShortcut(), this.toggleSearch); + shortcuts.on("Escape", this.onEscape); + } + + componentDidUpdate(prevProps, prevState) { + if (this.refs.resultList && this.refs.resultList.refs) { + scrollList(this.refs.resultList.refs, this.state.selectedResultIndex); + } + } + + onEscape = e => { + this.closeSearch(e); + }; + + clearSearch = () => { + const { editor: ed } = this.props; + if (ed) { + const ctx = { ed, cm: ed.codeMirror }; + removeOverlay(ctx, this.state.query); + } + }; + + closeSearch = e => { + const { cx, closeFileSearch, editor, searchInFileEnabled } = this.props; + this.clearSearch(); + if (editor && searchInFileEnabled) { + closeFileSearch(cx, editor); + e.stopPropagation(); + e.preventDefault(); + } + this.setState({ inputFocused: false }); + }; + + toggleSearch = e => { + e.stopPropagation(); + e.preventDefault(); + const { editor, searchInFileEnabled, setActiveSearch } = this.props; + + // Set inputFocused to false, so that search query is highlighted whenever search shortcut is used, even if the input already has focus. + this.setState({ inputFocused: false }); + + if (!searchInFileEnabled) { + setActiveSearch("file"); + } + + if (searchInFileEnabled && editor) { + const query = editor.codeMirror.getSelection() || this.state.query; + + if (query !== "") { + this.setState({ query, inputFocused: true }); + this.doSearch(query); + } else { + this.setState({ query: "", inputFocused: true }); + } + } + }; + + doSearch = async (query, focusFirstResult = true) => { + const { editor, modifiers, selectedSourceTextContent } = this.props; + if ( + !editor || + !selectedSourceTextContent || + !isFulfilled(selectedSourceTextContent) || + !modifiers + ) { + return; + } + const selectedContent = selectedSourceTextContent.value; + + const ctx = { ed: editor, cm: editor.codeMirror }; + + if (!query) { + clearSearch(ctx.cm, query); + return; + } + + let text; + if (selectedContent.type === "wasm") { + text = renderWasmText(this.props.selectedSource.id, selectedContent).join( + "\n" + ); + } else { + text = selectedContent.value; + } + + const matches = await this.props.querySearchWorker(query, text, modifiers); + + const res = find(ctx, query, true, modifiers, focusFirstResult); + if (!res) { + return; + } + + const { ch, line } = res; + + const matchIndex = matches.findIndex( + elm => elm.line === line && elm.ch === ch + ); + this.setState({ + results: { + matches, + matchIndex, + count: matches.length, + index: ch, + }, + }); + }; + + traverseResults = (e, reverse = false) => { + e.stopPropagation(); + e.preventDefault(); + const { editor } = this.props; + + if (!editor) { + return; + } + + const ctx = { ed: editor, cm: editor.codeMirror }; + + const { modifiers } = this.props; + const { query } = this.state; + const { matches } = this.state.results; + + if (query === "" && !this.props.searchInFileEnabled) { + this.props.setActiveSearch("file"); + } + + if (modifiers) { + const findArgs = [ctx, query, true, modifiers]; + const results = reverse ? findPrev(...findArgs) : findNext(...findArgs); + + if (!results) { + return; + } + const { ch, line } = results; + const matchIndex = matches.findIndex( + elm => elm.line === line && elm.ch === ch + ); + this.setState({ + results: { + matches, + matchIndex, + count: matches.length, + index: ch, + }, + }); + } + }; + + // Handlers + + onChange = e => { + this.setState({ query: e.target.value }); + + return this.doSearch(e.target.value); + }; + + onFocus = e => { + this.setState({ inputFocused: true }); + }; + + onBlur = e => { + this.setState({ inputFocused: false }); + }; + + onKeyDown = e => { + if (e.key !== "Enter" && e.key !== "F3") { + return; + } + + this.traverseResults(e, e.shiftKey); + e.preventDefault(); + this.doSearch(e.target.value); + }; + + onHistoryScroll = query => { + this.setState({ query }); + this.doSearch(query); + }; + + // Renderers + buildSummaryMsg() { + const { + query, + results: { matchIndex, count, index }, + } = this.state; + + if (query.trim() == "") { + return ""; + } + + if (count == 0) { + return L10N.getStr("editor.noResultsFound"); + } + + if (index == -1) { + const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary1"); + return PluralForm.get(count, resultsSummaryString).replace("#1", count); + } + + const searchResultsString = L10N.getStr("editor.searchResults1"); + return PluralForm.get(count, searchResultsString) + .replace("#1", count) + .replace("%d", matchIndex + 1); + } + + shouldShowErrorEmoji() { + const { + query, + results: { count }, + } = this.state; + return !!query && !count; + } + + render() { + const { searchInFileEnabled } = this.props; + const { + results: { count }, + } = this.state; + + if (!searchInFileEnabled) { + return <div />; + } + + return ( + <div className="search-bar"> + <SearchInput + query={this.state.query} + count={count} + placeholder={L10N.getStr("sourceSearch.search.placeholder2")} + summaryMsg={this.buildSummaryMsg()} + isLoading={false} + onChange={this.onChange} + onFocus={this.onFocus} + onBlur={this.onBlur} + showErrorEmoji={this.shouldShowErrorEmoji()} + onKeyDown={this.onKeyDown} + onHistoryScroll={this.onHistoryScroll} + handleNext={e => this.traverseResults(e, false)} + handlePrev={e => this.traverseResults(e, true)} + shouldFocus={this.state.inputFocused} + showClose={true} + showExcludePatterns={false} + handleClose={this.closeSearch} + showSearchModifiers={true} + searchKey={searchKeys.FILE_SEARCH} + onToggleSearchModifier={() => this.doSearch(this.state.query)} + /> + </div> + ); + } +} + +SearchInFileBar.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = (state, p) => { + const selectedSource = getSelectedSource(state); + + return { + cx: getContext(state), + searchInFileEnabled: getActiveSearch(state) === "file", + selectedSource, + selectedSourceTextContent: getSelectedSourceTextContent(state), + modifiers: getSearchOptions(state, "file-search"), + }; +}; + +export default connect(mapStateToProps, { + setFileSearchQuery: actions.setFileSearchQuery, + setActiveSearch: actions.setActiveSearch, + closeFileSearch: actions.closeFileSearch, + querySearchWorker: actions.querySearchWorker, +})(SearchInFileBar); diff --git a/devtools/client/debugger/src/components/Editor/Tab.js b/devtools/client/debugger/src/components/Editor/Tab.js new file mode 100644 index 0000000000..2f296f9346 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tab.js @@ -0,0 +1,282 @@ +/* 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, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import { showMenu, buildMenu } from "../../context-menu/menu"; + +import SourceIcon from "../shared/SourceIcon"; +import { CloseButton } from "../shared/Button"; +import { copyToTheClipboard } from "../../utils/clipboard"; + +import actions from "../../actions"; + +import { + getDisplayPath, + getFileURL, + getRawSourceURL, + getSourceQueryString, + getTruncatedFileName, + isPretty, + shouldBlackbox, +} from "../../utils/source"; +import { getTabMenuItems } from "../../utils/tabs"; +import { createLocation } from "../../utils/location"; + +import { + getSelectedLocation, + getActiveSearch, + getSourcesForTabs, + isSourceBlackBoxed, + getContext, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); + +class Tab extends PureComponent { + static get propTypes() { + return { + activeSearch: PropTypes.string, + closeTab: PropTypes.func.isRequired, + closeTabs: PropTypes.func.isRequired, + copyToClipboard: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + onDragEnd: PropTypes.func.isRequired, + onDragOver: PropTypes.func.isRequired, + onDragStart: PropTypes.func.isRequired, + selectSource: PropTypes.func.isRequired, + selectedLocation: PropTypes.object, + showSource: PropTypes.func.isRequired, + source: PropTypes.object.isRequired, + sourceActor: PropTypes.object.isRequired, + tabSources: PropTypes.array.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + togglePrettyPrint: PropTypes.func.isRequired, + isBlackBoxed: PropTypes.bool.isRequired, + isSourceOnIgnoreList: PropTypes.bool.isRequired, + }; + } + + onTabContextMenu = (event, tab) => { + event.preventDefault(); + this.showContextMenu(event, tab); + }; + + showContextMenu(e, tab) { + const { + cx, + closeTab, + closeTabs, + copyToClipboard, + tabSources, + showSource, + toggleBlackBox, + togglePrettyPrint, + selectedLocation, + source, + isBlackBoxed, + isSourceOnIgnoreList, + } = this.props; + + const tabCount = tabSources.length; + const otherTabs = tabSources.filter(t => t.id !== tab); + const sourceTab = tabSources.find(t => t.id == tab); + const tabURLs = tabSources.map(t => t.url); + const otherTabURLs = otherTabs.map(t => t.url); + + if (!sourceTab || !selectedLocation || !selectedLocation.sourceId) { + return; + } + + const tabMenuItems = getTabMenuItems(); + const items = [ + { + item: { + ...tabMenuItems.closeTab, + click: () => closeTab(cx, sourceTab), + }, + }, + { + item: { + ...tabMenuItems.closeOtherTabs, + click: () => closeTabs(cx, otherTabURLs), + disabled: otherTabURLs.length === 0, + }, + }, + { + item: { + ...tabMenuItems.closeTabsToEnd, + click: () => { + const tabIndex = tabSources.findIndex(t => t.id == tab); + closeTabs( + cx, + tabURLs.filter((t, i) => i > tabIndex) + ); + }, + disabled: + tabCount === 1 || + tabSources.some((t, i) => t === tab && tabCount - 1 === i), + }, + }, + { + item: { + ...tabMenuItems.closeAllTabs, + click: () => closeTabs(cx, tabURLs), + }, + }, + { item: { type: "separator" } }, + { + item: { + ...tabMenuItems.copySource, + disabled: selectedLocation.sourceId !== tab, + click: () => copyToClipboard(sourceTab), + }, + }, + { + item: { + ...tabMenuItems.copySourceUri2, + disabled: !selectedLocation.sourceUrl, + click: () => copyToTheClipboard(getRawSourceURL(sourceTab.url)), + }, + }, + { + item: { + ...tabMenuItems.showSource, + disabled: !selectedLocation.sourceUrl, + click: () => showSource(cx, tab), + }, + }, + { + item: { + ...tabMenuItems.toggleBlackBox, + label: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore") + : L10N.getStr("ignoreContextItem.ignore"), + disabled: isSourceOnIgnoreList || !shouldBlackbox(source), + click: () => toggleBlackBox(cx, source), + }, + }, + { + item: { + ...tabMenuItems.prettyPrint, + click: () => togglePrettyPrint(cx, tab), + disabled: isPretty(sourceTab), + }, + }, + ]; + + showMenu(e, buildMenu(items)); + } + + isSourceSearchEnabled() { + return this.props.activeSearch === "source"; + } + + render() { + const { + cx, + selectedLocation, + selectSource, + closeTab, + source, + sourceActor, + tabSources, + onDragOver, + onDragStart, + onDragEnd, + } = this.props; + const sourceId = source.id; + const active = + selectedLocation && + sourceId == selectedLocation.sourceId && + !this.isSourceSearchEnabled(); + const isPrettyCode = isPretty(source); + + function onClickClose(e) { + e.stopPropagation(); + closeTab(cx, source); + } + + function handleTabClick(e) { + e.preventDefault(); + e.stopPropagation(); + return selectSource(cx, source, sourceActor); + } + + const className = classnames("source-tab", { + active, + pretty: isPrettyCode, + blackboxed: this.props.isBlackBoxed, + }); + + const path = getDisplayPath(source, tabSources); + const query = getSourceQueryString(source); + + return ( + <div + draggable + onDragOver={onDragOver} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + className={className} + key={sourceId} + onClick={handleTabClick} + // Accommodate middle click to close tab + onMouseUp={e => e.button === 1 && closeTab(cx, source)} + onContextMenu={e => this.onTabContextMenu(e, sourceId)} + title={getFileURL(source, false)} + > + <SourceIcon + location={createLocation({ source, sourceActor })} + forTab={true} + modifier={icon => + ["file", "javascript"].includes(icon) ? null : icon + } + /> + <div className="filename"> + {getTruncatedFileName(source, query)} + {path && <span>{`../${path}/..`}</span>} + </div> + <CloseButton + handleClick={onClickClose} + tooltip={L10N.getStr("sourceTabs.closeTabButtonTooltip")} + /> + </div> + ); + } +} + +const mapStateToProps = (state, { source }) => { + return { + cx: getContext(state), + tabSources: getSourcesForTabs(state), + selectedLocation: getSelectedLocation(state), + isBlackBoxed: isSourceBlackBoxed(state, source), + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source), + activeSearch: getActiveSearch(state), + }; +}; + +export default connect( + mapStateToProps, + { + selectSource: actions.selectSource, + copyToClipboard: actions.copyToClipboard, + closeTab: actions.closeTab, + closeTabs: actions.closeTabs, + togglePrettyPrint: actions.togglePrettyPrint, + showSource: actions.showSource, + toggleBlackBox: actions.toggleBlackBox, + }, + null, + { + withRef: true, + } +)(Tab); diff --git a/devtools/client/debugger/src/components/Editor/Tabs.css b/devtools/client/debugger/src/components/Editor/Tabs.css new file mode 100644 index 0000000000..565d8588f1 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tabs.css @@ -0,0 +1,125 @@ +/* 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/>. */ + +.source-header { + display: flex; + width: 100%; + height: var(--editor-header-height); + border-bottom: 1px solid var(--theme-splitter-color); + background-color: var(--theme-toolbar-background); +} + +.source-header * { + user-select: none; +} + +.source-header .command-bar { + flex: initial; + flex-shrink: 0; + border-bottom: 0; + border-inline-start: 1px solid var(--theme-splitter-color); +} + +.source-tabs { + flex: auto; + align-self: flex-start; + align-items: flex-start; + /* Reserve space for the overflow button (even if not visible) */ + padding-inline-end: 28px; +} + +.source-tab { + display: inline-flex; + align-items: center; + position: relative; + min-width: 40px; + max-width: 100%; + overflow: hidden; + padding: 4px 10px; + cursor: default; + height: calc(var(--editor-header-height) - 1px); + font-size: 12px; + background-color: transparent; + vertical-align: bottom; +} + +.source-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-tab.active { + --tab-line-color: var(--tab-line-selected-color); + color: var(--theme-toolbar-selected-color); + border-bottom-color: transparent; +} + +.source-tab:not(.active):hover { + --tab-line-color: var(--tab-line-hover-color); + background-color: var(--theme-toolbar-hover); +} + +.source-tab:hover::before, +.source-tab.active::before { + opacity: 1; + transform: scaleX(1); +} + +.source-tab .img:is(.prettyPrint,.blackBox) { + mask-size: 14px; +} + +.source-tab .img.prettyPrint { + background-color: currentColor; +} + +.source-tab .img.source-icon.blackBox { + background-color: #806414; +} + +.source-tab .filename { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-inline-end: 4px; +} + +.source-tab .filename span { + opacity: 0.7; + padding-inline-start: 4px; +} + +.source-tab .close-btn { + visibility: hidden; + margin-inline-end: -6px; +} + +.source-tab.active .close-btn { + color: inherit; +} + +.source-tab.active .close-btn, +.source-tab:hover .close-btn { + visibility: visible; +} + +.source-tab.active .source-icon { + background-color: currentColor; +} + +.source-tab .close-btn:hover, +.source-tab .close-btn:focus { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} diff --git a/devtools/client/debugger/src/components/Editor/Tabs.js b/devtools/client/debugger/src/components/Editor/Tabs.js new file mode 100644 index 0000000000..3f38f216a0 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tabs.js @@ -0,0 +1,332 @@ +/* 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, { PureComponent } from "react"; +import ReactDOM from "react-dom"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import { + getSourceTabs, + getSelectedSource, + getSourcesForTabs, + getIsPaused, + getCurrentThread, + getContext, + getBlackBoxRanges, +} from "../../selectors"; +import { isVisible } from "../../utils/ui"; + +import { getHiddenTabs } from "../../utils/tabs"; +import { getFilename, isPretty, getFileURL } from "../../utils/source"; +import actions from "../../actions"; + +import "./Tabs.css"; + +import Tab from "./Tab"; +import { PaneToggleButton } from "../shared/Button"; +import Dropdown from "../shared/Dropdown"; +import AccessibleImage from "../shared/AccessibleImage"; +import CommandBar from "../SecondaryPanes/CommandBar"; + +const { debounce } = require("devtools/shared/debounce"); + +function haveTabSourcesChanged(tabSources, prevTabSources) { + if (tabSources.length !== prevTabSources.length) { + return true; + } + + for (let i = 0; i < tabSources.length; ++i) { + if (tabSources[i].id !== prevTabSources[i].id) { + return true; + } + } + + return false; +} + +class Tabs extends PureComponent { + constructor(props) { + super(props); + this.state = { + dropdownShown: false, + hiddenTabs: [], + }; + + this.onResize = debounce(() => { + this.updateHiddenTabs(); + }); + } + + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + horizontal: PropTypes.bool.isRequired, + isPaused: PropTypes.bool.isRequired, + moveTab: PropTypes.func.isRequired, + moveTabBySourceId: PropTypes.func.isRequired, + selectSource: PropTypes.func.isRequired, + selectedSource: PropTypes.object, + blackBoxRanges: PropTypes.object.isRequired, + startPanelCollapsed: PropTypes.bool.isRequired, + tabSources: PropTypes.array.isRequired, + tabs: PropTypes.array.isRequired, + togglePaneCollapse: PropTypes.func.isRequired, + }; + } + + get draggedSource() { + return this._draggedSource == null + ? { url: null, id: null } + : this._draggedSource; + } + + set draggedSource(source) { + this._draggedSource = source; + } + + get draggedSourceIndex() { + return this._draggedSourceIndex == null ? -1 : this._draggedSourceIndex; + } + + set draggedSourceIndex(index) { + this._draggedSourceIndex = index; + } + + componentDidUpdate(prevProps) { + if ( + this.props.selectedSource !== prevProps.selectedSource || + haveTabSourcesChanged(this.props.tabSources, prevProps.tabSources) + ) { + this.updateHiddenTabs(); + } + } + + componentDidMount() { + window.requestIdleCallback(this.updateHiddenTabs); + window.addEventListener("resize", this.onResize); + window.document + .querySelector(".editor-pane") + .addEventListener("resizeend", this.onResize); + } + + componentWillUnmount() { + window.removeEventListener("resize", this.onResize); + window.document + .querySelector(".editor-pane") + .removeEventListener("resizeend", this.onResize); + } + + /* + * Updates the hiddenSourceTabs state, by + * finding the source tabs which are wrapped and are not on the top row. + */ + updateHiddenTabs = () => { + if (!this.refs.sourceTabs) { + return; + } + const { selectedSource, tabSources, moveTab } = this.props; + const sourceTabEls = this.refs.sourceTabs.children; + const hiddenTabs = getHiddenTabs(tabSources, sourceTabEls); + + if ( + selectedSource && + isVisible() && + hiddenTabs.find(tab => tab.id == selectedSource.id) + ) { + moveTab(selectedSource.url, 0); + return; + } + + this.setState({ hiddenTabs }); + }; + + toggleSourcesDropdown() { + this.setState(prevState => ({ + dropdownShown: !prevState.dropdownShown, + })); + } + + getIconClass(source) { + if (isPretty(source)) { + return "prettyPrint"; + } + if (this.props.blackBoxRanges[source.url]) { + return "blackBox"; + } + return "file"; + } + + renderDropdownSource = source => { + const { cx, selectSource } = this.props; + const filename = getFilename(source); + + const onClick = () => selectSource(cx, source); + return ( + <li key={source.id} onClick={onClick} title={getFileURL(source, false)}> + <AccessibleImage + className={`dropdown-icon ${this.getIconClass(source)}`} + /> + <span className="dropdown-label">{filename}</span> + </li> + ); + }; + + onTabDragStart = (source, index) => { + this.draggedSource = source; + this.draggedSourceIndex = index; + }; + + onTabDragEnd = () => { + this.draggedSource = null; + this.draggedSourceIndex = null; + }; + + onTabDragOver = (e, source, hoveredTabIndex) => { + const { moveTabBySourceId } = this.props; + if (hoveredTabIndex === this.draggedSourceIndex) { + return; + } + + const tabDOM = ReactDOM.findDOMNode( + this.refs[`tab_${source.id}`].getWrappedInstance() + ); + + const tabDOMRect = tabDOM.getBoundingClientRect(); + const { pageX: mouseCursorX } = e; + if ( + /* Case: the mouse cursor moves into the left half of any target tab */ + mouseCursorX - tabDOMRect.left < + tabDOMRect.width / 2 + ) { + // The current tab goes to the left of the target tab + const targetTab = + hoveredTabIndex > this.draggedSourceIndex + ? hoveredTabIndex - 1 + : hoveredTabIndex; + moveTabBySourceId(this.draggedSource.id, targetTab); + this.draggedSourceIndex = targetTab; + } else if ( + /* Case: the mouse cursor moves into the right half of any target tab */ + mouseCursorX - tabDOMRect.left >= + tabDOMRect.width / 2 + ) { + // The current tab goes to the right of the target tab + const targetTab = + hoveredTabIndex < this.draggedSourceIndex + ? hoveredTabIndex + 1 + : hoveredTabIndex; + moveTabBySourceId(this.draggedSource.id, targetTab); + this.draggedSourceIndex = targetTab; + } + }; + + renderTabs() { + const { tabs } = this.props; + if (!tabs) { + return null; + } + + return ( + <div className="source-tabs" ref="sourceTabs"> + {tabs.map(({ source, sourceActor }, index) => { + return ( + <Tab + onDragStart={_ => this.onTabDragStart(source, index)} + onDragOver={e => { + this.onTabDragOver(e, source, index); + e.preventDefault(); + }} + onDragEnd={this.onTabDragEnd} + key={index} + source={source} + sourceActor={sourceActor} + ref={`tab_${source.id}`} + /> + ); + })} + </div> + ); + } + + renderDropdown() { + const { hiddenTabs } = this.state; + if (!hiddenTabs || !hiddenTabs.length) { + return null; + } + + const Panel = <ul>{hiddenTabs.map(this.renderDropdownSource)}</ul>; + const icon = <AccessibleImage className="more-tabs" />; + + return <Dropdown panel={Panel} icon={icon} />; + } + + renderCommandBar() { + const { horizontal, endPanelCollapsed, isPaused } = this.props; + if (!endPanelCollapsed || !isPaused) { + return null; + } + + return <CommandBar horizontal={horizontal} />; + } + + renderStartPanelToggleButton() { + return ( + <PaneToggleButton + position="start" + collapsed={this.props.startPanelCollapsed} + handleClick={this.props.togglePaneCollapse} + /> + ); + } + + renderEndPanelToggleButton() { + const { horizontal, endPanelCollapsed, togglePaneCollapse } = this.props; + if (!horizontal) { + return null; + } + + return ( + <PaneToggleButton + position="end" + collapsed={endPanelCollapsed} + handleClick={togglePaneCollapse} + horizontal={horizontal} + /> + ); + } + + render() { + return ( + <div className="source-header"> + {this.renderStartPanelToggleButton()} + {this.renderTabs()} + {this.renderDropdown()} + {this.renderEndPanelToggleButton()} + {this.renderCommandBar()} + </div> + ); + } +} + +const mapStateToProps = state => { + return { + cx: getContext(state), + selectedSource: getSelectedSource(state), + tabSources: getSourcesForTabs(state), + tabs: getSourceTabs(state), + blackBoxRanges: getBlackBoxRanges(state), + isPaused: getIsPaused(state, getCurrentThread(state)), + }; +}; + +export default connect(mapStateToProps, { + selectSource: actions.selectSource, + moveTab: actions.moveTab, + moveTabBySourceId: actions.moveTabBySourceId, + closeTab: actions.closeTab, + togglePaneCollapse: actions.togglePaneCollapse, + showSource: actions.showSource, +})(Tabs); diff --git a/devtools/client/debugger/src/components/Editor/index.js b/devtools/client/debugger/src/components/Editor/index.js new file mode 100644 index 0000000000..fcaa129944 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/index.js @@ -0,0 +1,808 @@ +/* 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 PropTypes from "prop-types"; +import React, { PureComponent } from "react"; +import { bindActionCreators } from "redux"; +import ReactDOM from "react-dom"; +import { connect } from "../../utils/connect"; + +import { getLineText, isLineBlackboxed } from "./../../utils/source"; +import { createLocation } from "./../../utils/location"; +import { features } from "../../utils/prefs"; +import { getIndentation } from "../../utils/indentation"; + +import { showMenu } from "../../context-menu/menu"; +import { + createBreakpointItems, + breakpointItemActions, +} from "./menus/breakpoints"; + +import { + continueToHereItem, + editorItemActions, + blackBoxLineMenuItem, +} from "./menus/editor"; + +import { + getActiveSearch, + getSelectedLocation, + getSelectedSource, + getSelectedSourceTextContent, + getSelectedBreakableLines, + getConditionalPanelLocation, + getSymbols, + getIsCurrentThreadPaused, + getCurrentThread, + getThreadContext, + getSkipPausing, + getInlinePreview, + getEditorWrapping, + getHighlightedCalls, + getBlackBoxRanges, + isSourceBlackBoxed, + getHighlightedLineRangeForSelectedSource, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../selectors"; + +// Redux actions +import actions from "../../actions"; + +import SearchInFileBar from "./SearchInFileBar"; +import HighlightLines from "./HighlightLines"; +import Preview from "./Preview"; +import Breakpoints from "./Breakpoints"; +import ColumnBreakpoints from "./ColumnBreakpoints"; +import DebugLine from "./DebugLine"; +import HighlightLine from "./HighlightLine"; +import EmptyLines from "./EmptyLines"; +import EditorMenu from "./EditorMenu"; +import ConditionalPanel from "./ConditionalPanel"; +import InlinePreviews from "./InlinePreviews"; +import HighlightCalls from "./HighlightCalls"; +import Exceptions from "./Exceptions"; +import BlackboxLines from "./BlackboxLines"; + +import { + showSourceText, + showLoading, + showErrorMessage, + getEditor, + clearEditor, + getCursorLine, + getCursorColumn, + lineAtHeight, + toSourceLine, + getDocument, + scrollToColumn, + toEditorPosition, + getSourceLocationFromMouseEvent, + hasDocument, + onMouseOver, + startOperation, + endOperation, +} from "../../utils/editor"; + +import { resizeToggleButton, resizeBreakpointGutter } from "../../utils/ui"; + +const { debounce } = require("devtools/shared/debounce"); +const classnames = require("devtools/client/shared/classnames.js"); + +const { appinfo } = Services; +const isMacOS = appinfo.OS === "Darwin"; + +function isSecondary(ev) { + return isMacOS && ev.ctrlKey && ev.button === 0; +} + +function isCmd(ev) { + return isMacOS ? ev.metaKey : ev.ctrlKey; +} + +import "./Editor.css"; +import "./Breakpoints.css"; +import "./InlinePreview.css"; + +const cssVars = { + searchbarHeight: "var(--editor-searchbar-height)", +}; + +class Editor extends PureComponent { + static get propTypes() { + return { + selectedSource: PropTypes.object, + selectedSourceTextContent: PropTypes.object, + selectedSourceIsBlackBoxed: PropTypes.bool, + cx: PropTypes.object.isRequired, + closeTab: PropTypes.func.isRequired, + toggleBreakpointAtLine: PropTypes.func.isRequired, + conditionalPanelLocation: PropTypes.object, + closeConditionalPanel: PropTypes.func.isRequired, + openConditionalPanel: PropTypes.func.isRequired, + updateViewport: PropTypes.func.isRequired, + isPaused: PropTypes.bool.isRequired, + highlightCalls: PropTypes.func.isRequired, + unhighlightCalls: PropTypes.func.isRequired, + breakpointActions: PropTypes.object.isRequired, + editorActions: PropTypes.object.isRequired, + addBreakpointAtLine: PropTypes.func.isRequired, + continueToHere: PropTypes.func.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + updateCursorPosition: PropTypes.func.isRequired, + jumpToMappedLocation: PropTypes.func.isRequired, + selectedLocation: PropTypes.object, + symbols: PropTypes.object, + startPanelSize: PropTypes.number.isRequired, + endPanelSize: PropTypes.number.isRequired, + searchInFileEnabled: PropTypes.bool.isRequired, + inlinePreviewEnabled: PropTypes.bool.isRequired, + editorWrappingEnabled: PropTypes.bool.isRequired, + skipPausing: PropTypes.bool.isRequired, + blackboxedRanges: PropTypes.object.isRequired, + breakableLines: PropTypes.object.isRequired, + highlightedLineRange: PropTypes.object, + isSourceOnIgnoreList: PropTypes.bool, + }; + } + + $editorWrapper; + constructor(props) { + super(props); + + this.state = { + editor: null, + contextMenu: null, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + let { editor } = this.state; + + if (!editor && nextProps.selectedSource) { + editor = this.setupEditor(); + } + + const shouldUpdateText = + nextProps.selectedSource !== this.props.selectedSource || + nextProps.selectedSourceTextContent !== + this.props.selectedSourceTextContent || + nextProps.symbols !== this.props.symbols; + + const shouldUpdateSize = + nextProps.startPanelSize !== this.props.startPanelSize || + nextProps.endPanelSize !== this.props.endPanelSize; + + const shouldScroll = + nextProps.selectedLocation && + this.shouldScrollToLocation(nextProps, editor); + + if (shouldUpdateText || shouldUpdateSize || shouldScroll) { + startOperation(); + if (shouldUpdateText) { + this.setText(nextProps, editor); + } + if (shouldUpdateSize) { + editor.codeMirror.setSize(); + } + if (shouldScroll) { + this.scrollToLocation(nextProps, editor); + } + endOperation(); + } + + if (this.props.selectedSource != nextProps.selectedSource) { + this.props.updateViewport(); + resizeBreakpointGutter(editor.codeMirror); + resizeToggleButton(editor.codeMirror); + } + } + + setupEditor() { + const editor = getEditor(); + + // disables the default search shortcuts + editor._initShortcuts = () => {}; + + const node = ReactDOM.findDOMNode(this); + if (node instanceof HTMLElement) { + editor.appendToLocalElement(node.querySelector(".editor-mount")); + } + + const { codeMirror } = editor; + const codeMirrorWrapper = codeMirror.getWrapperElement(); + + codeMirror.on("gutterClick", this.onGutterClick); + + if (features.commandClick) { + document.addEventListener("keydown", this.commandKeyDown); + document.addEventListener("keyup", this.commandKeyUp); + } + + // Set code editor wrapper to be focusable + codeMirrorWrapper.tabIndex = 0; + codeMirrorWrapper.addEventListener("keydown", e => this.onKeyDown(e)); + codeMirrorWrapper.addEventListener("click", e => this.onClick(e)); + codeMirrorWrapper.addEventListener("mouseover", onMouseOver(codeMirror)); + + const toggleFoldMarkerVisibility = e => { + if (node instanceof HTMLElement) { + node + .querySelectorAll(".CodeMirror-guttermarker-subtle") + .forEach(elem => { + elem.classList.toggle("visible"); + }); + } + }; + + const codeMirrorGutter = codeMirror.getGutterElement(); + codeMirrorGutter.addEventListener("mouseleave", toggleFoldMarkerVisibility); + codeMirrorGutter.addEventListener("mouseenter", toggleFoldMarkerVisibility); + codeMirrorWrapper.addEventListener("contextmenu", event => + this.openMenu(event) + ); + + codeMirror.on("scroll", this.onEditorScroll); + this.onEditorScroll(); + this.setState({ editor }); + return editor; + } + + componentDidMount() { + const { shortcuts } = this.context; + + shortcuts.on(L10N.getStr("toggleBreakpoint.key"), this.onToggleBreakpoint); + shortcuts.on( + L10N.getStr("toggleCondPanel.breakpoint.key"), + this.onToggleConditionalPanel + ); + shortcuts.on( + L10N.getStr("toggleCondPanel.logPoint.key"), + this.onToggleConditionalPanel + ); + shortcuts.on( + L10N.getStr("sourceTabs.closeTab.key"), + this.onCloseShortcutPress + ); + shortcuts.on("Esc", this.onEscape); + } + + onCloseShortcutPress = e => { + const { cx, selectedSource } = this.props; + if (selectedSource) { + e.preventDefault(); + e.stopPropagation(); + this.props.closeTab(cx, selectedSource, "shortcut"); + } + }; + + componentWillUnmount() { + const { editor } = this.state; + if (editor) { + editor.destroy(); + editor.codeMirror.off("scroll", this.onEditorScroll); + this.setState({ editor: null }); + } + + const { shortcuts } = this.context; + shortcuts.off(L10N.getStr("sourceTabs.closeTab.key")); + shortcuts.off(L10N.getStr("toggleBreakpoint.key")); + shortcuts.off(L10N.getStr("toggleCondPanel.breakpoint.key")); + shortcuts.off(L10N.getStr("toggleCondPanel.logPoint.key")); + } + + getCurrentLine() { + const { codeMirror } = this.state.editor; + const { selectedSource } = this.props; + if (!selectedSource) { + return null; + } + + const line = getCursorLine(codeMirror); + return toSourceLine(selectedSource.id, line); + } + + onToggleBreakpoint = e => { + e.preventDefault(); + e.stopPropagation(); + + const line = this.getCurrentLine(); + if (typeof line !== "number") { + return; + } + + this.props.toggleBreakpointAtLine(this.props.cx, line); + }; + + onToggleConditionalPanel = e => { + e.stopPropagation(); + e.preventDefault(); + + const { + conditionalPanelLocation, + closeConditionalPanel, + openConditionalPanel, + selectedSource, + } = this.props; + + const line = this.getCurrentLine(); + + const { codeMirror } = this.state.editor; + // add one to column for correct position in editor. + const column = getCursorColumn(codeMirror) + 1; + + if (conditionalPanelLocation) { + return closeConditionalPanel(); + } + + if (!selectedSource || typeof line !== "number") { + return null; + } + + return openConditionalPanel( + createLocation({ + line, + column, + source: selectedSource, + }), + false + ); + }; + + onEditorScroll = debounce(this.props.updateViewport, 75); + + commandKeyDown = e => { + const { key } = e; + if (this.props.isPaused && key === "Meta") { + const { cx, highlightCalls } = this.props; + highlightCalls(cx); + } + }; + + commandKeyUp = e => { + const { key } = e; + if (key === "Meta") { + const { cx, unhighlightCalls } = this.props; + unhighlightCalls(cx); + } + }; + + onKeyDown(e) { + const { codeMirror } = this.state.editor; + const { key, target } = e; + const codeWrapper = codeMirror.getWrapperElement(); + const textArea = codeWrapper.querySelector("textArea"); + + if (key === "Escape" && target == textArea) { + e.stopPropagation(); + e.preventDefault(); + codeWrapper.focus(); + } else if (key === "Enter" && target == codeWrapper) { + e.preventDefault(); + // Focus into editor's text area + textArea.focus(); + } + } + + /* + * The default Esc command is overridden in the CodeMirror keymap to allow + * the Esc keypress event to be catched by the toolbox and trigger the + * split console. Restore it here, but preventDefault if and only if there + * is a multiselection. + */ + onEscape = e => { + if (!this.state.editor) { + return; + } + + const { codeMirror } = this.state.editor; + if (codeMirror.listSelections().length > 1) { + codeMirror.execCommand("singleSelection"); + e.preventDefault(); + } + }; + + openMenu(event) { + event.stopPropagation(); + event.preventDefault(); + + const { + cx, + selectedSource, + selectedSourceTextContent, + breakpointActions, + editorActions, + isPaused, + conditionalPanelLocation, + closeConditionalPanel, + isSourceOnIgnoreList, + blackboxedRanges, + } = this.props; + const { editor } = this.state; + if (!selectedSource || !editor) { + return; + } + + // only allow one conditionalPanel location. + if (conditionalPanelLocation) { + closeConditionalPanel(); + } + + const target = event.target; + const { id: sourceId } = selectedSource; + const line = lineAtHeight(editor, sourceId, event); + + if (typeof line != "number") { + return; + } + + const location = createLocation({ + line, + column: undefined, + source: selectedSource, + }); + + if (target.classList.contains("CodeMirror-linenumber")) { + const lineText = getLineText( + sourceId, + selectedSourceTextContent, + line + ).trim(); + + showMenu(event, [ + ...createBreakpointItems(cx, location, breakpointActions, lineText), + { type: "separator" }, + continueToHereItem(cx, location, isPaused, editorActions), + { type: "separator" }, + blackBoxLineMenuItem( + cx, + selectedSource, + editorActions, + editor, + blackboxedRanges, + isSourceOnIgnoreList, + line + ), + ]); + return; + } + + if (target.getAttribute("id") === "columnmarker") { + return; + } + + this.setState({ contextMenu: event }); + } + + clearContextMenu = () => { + this.setState({ contextMenu: null }); + }; + + onGutterClick = (cm, line, gutter, ev) => { + const { + cx, + selectedSource, + conditionalPanelLocation, + closeConditionalPanel, + addBreakpointAtLine, + continueToHere, + breakableLines, + blackboxedRanges, + isSourceOnIgnoreList, + } = this.props; + + // ignore right clicks in the gutter + if (isSecondary(ev) || ev.button === 2 || !selectedSource) { + return; + } + + if (conditionalPanelLocation) { + closeConditionalPanel(); + return; + } + + if (gutter === "CodeMirror-foldgutter") { + return; + } + + const sourceLine = toSourceLine(selectedSource.id, line); + if (typeof sourceLine !== "number") { + return; + } + + // ignore clicks on a non-breakable line + if (!breakableLines.has(sourceLine)) { + return; + } + + if (isCmd(ev)) { + continueToHere( + cx, + createLocation({ + line: sourceLine, + column: undefined, + source: selectedSource, + }) + ); + return; + } + + addBreakpointAtLine( + cx, + sourceLine, + ev.altKey, + ev.shiftKey || + isLineBlackboxed( + blackboxedRanges[selectedSource.url], + sourceLine, + isSourceOnIgnoreList + ) + ); + }; + + onGutterContextMenu = event => { + this.openMenu(event); + }; + + onClick(e) { + const { cx, selectedSource, updateCursorPosition, jumpToMappedLocation } = + this.props; + + if (selectedSource) { + const sourceLocation = getSourceLocationFromMouseEvent( + this.state.editor, + selectedSource, + e + ); + + if (e.metaKey && e.altKey) { + jumpToMappedLocation(cx, sourceLocation); + } + + updateCursorPosition(sourceLocation); + } + } + + shouldScrollToLocation(nextProps, editor) { + const { selectedLocation, selectedSource, selectedSourceTextContent } = + this.props; + if ( + !editor || + !nextProps.selectedSource || + !nextProps.selectedLocation || + !nextProps.selectedLocation.line || + !nextProps.selectedSourceTextContent + ) { + return false; + } + + const isFirstLoad = + (!selectedSource || !selectedSourceTextContent) && + nextProps.selectedSourceTextContent; + const locationChanged = selectedLocation !== nextProps.selectedLocation; + const symbolsChanged = nextProps.symbols != this.props.symbols; + + return isFirstLoad || locationChanged || symbolsChanged; + } + + scrollToLocation(nextProps, editor) { + const { selectedLocation, selectedSource } = nextProps; + + let { line, column } = toEditorPosition(selectedLocation); + + if (selectedSource && hasDocument(selectedSource.id)) { + const doc = getDocument(selectedSource.id); + const lineText = doc.getLine(line); + column = Math.max(column, getIndentation(lineText)); + } + + scrollToColumn(editor.codeMirror, line, column); + } + + setText(props, editor) { + const { selectedSource, selectedSourceTextContent, symbols } = props; + + if (!editor) { + return; + } + + // check if we previously had a selected source + if (!selectedSource) { + this.clearEditor(); + return; + } + + if (!selectedSourceTextContent?.value) { + showLoading(editor); + return; + } + + if (selectedSourceTextContent.state === "rejected") { + let { value } = selectedSourceTextContent; + if (typeof value !== "string") { + value = "Unexpected source error"; + } + + this.showErrorMessage(value); + return; + } + + showSourceText(editor, selectedSource, selectedSourceTextContent, symbols); + } + + clearEditor() { + const { editor } = this.state; + if (!editor) { + return; + } + + clearEditor(editor); + } + + showErrorMessage(msg) { + const { editor } = this.state; + if (!editor) { + return; + } + + showErrorMessage(editor, msg); + } + + getInlineEditorStyles() { + const { searchInFileEnabled } = this.props; + + if (searchInFileEnabled) { + return { + height: `calc(100% - ${cssVars.searchbarHeight})`, + }; + } + + return { + height: "100%", + }; + } + + renderItems() { + const { + cx, + selectedSource, + conditionalPanelLocation, + isPaused, + inlinePreviewEnabled, + editorWrappingEnabled, + highlightedLineRange, + blackboxedRanges, + isSourceOnIgnoreList, + selectedSourceIsBlackBoxed, + } = this.props; + const { editor, contextMenu } = this.state; + + if (!selectedSource || !editor || !getDocument(selectedSource.id)) { + return null; + } + + return ( + <div> + <HighlightCalls editor={editor} selectedSource={selectedSource} /> + <DebugLine /> + <HighlightLine /> + <EmptyLines editor={editor} /> + <Breakpoints editor={editor} cx={cx} /> + <Preview editor={editor} editorRef={this.$editorWrapper} /> + {highlightedLineRange ? ( + <HighlightLines editor={editor} range={highlightedLineRange} /> + ) : null} + {isSourceOnIgnoreList || selectedSourceIsBlackBoxed ? ( + <BlackboxLines + editor={editor} + selectedSource={selectedSource} + isSourceOnIgnoreList={isSourceOnIgnoreList} + blackboxedRangesForSelectedSource={ + blackboxedRanges[selectedSource.url] + } + /> + ) : null} + <Exceptions /> + <EditorMenu + editor={editor} + contextMenu={contextMenu} + clearContextMenu={this.clearContextMenu} + selectedSource={selectedSource} + editorWrappingEnabled={editorWrappingEnabled} + /> + {conditionalPanelLocation ? <ConditionalPanel editor={editor} /> : null} + <ColumnBreakpoints editor={editor} /> + {isPaused && inlinePreviewEnabled ? ( + <InlinePreviews editor={editor} selectedSource={selectedSource} /> + ) : null} + </div> + ); + } + + renderSearchInFileBar() { + if (!this.props.selectedSource) { + return null; + } + + return <SearchInFileBar editor={this.state.editor} />; + } + + render() { + const { selectedSourceIsBlackBoxed, skipPausing } = this.props; + return ( + <div + className={classnames("editor-wrapper", { + blackboxed: selectedSourceIsBlackBoxed, + "skip-pausing": skipPausing, + })} + ref={c => (this.$editorWrapper = c)} + > + <div + className="editor-mount devtools-monospace" + style={this.getInlineEditorStyles()} + /> + {this.renderSearchInFileBar()} + {this.renderItems()} + </div> + ); + } +} + +Editor.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + const selectedLocation = getSelectedLocation(state); + + return { + cx: getThreadContext(state), + selectedLocation, + selectedSource, + selectedSourceTextContent: getSelectedSourceTextContent(state), + selectedSourceIsBlackBoxed: selectedSource + ? isSourceBlackBoxed(state, selectedSource) + : null, + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, selectedSource), + searchInFileEnabled: getActiveSearch(state) === "file", + conditionalPanelLocation: getConditionalPanelLocation(state), + symbols: getSymbols(state, selectedLocation), + isPaused: getIsCurrentThreadPaused(state), + skipPausing: getSkipPausing(state), + inlinePreviewEnabled: getInlinePreview(state), + editorWrappingEnabled: getEditorWrapping(state), + highlightedCalls: getHighlightedCalls(state, getCurrentThread(state)), + blackboxedRanges: getBlackBoxRanges(state), + breakableLines: getSelectedBreakableLines(state), + highlightedLineRange: getHighlightedLineRangeForSelectedSource(state), + }; +}; + +const mapDispatchToProps = dispatch => ({ + ...bindActionCreators( + { + openConditionalPanel: actions.openConditionalPanel, + closeConditionalPanel: actions.closeConditionalPanel, + continueToHere: actions.continueToHere, + toggleBreakpointAtLine: actions.toggleBreakpointAtLine, + addBreakpointAtLine: actions.addBreakpointAtLine, + jumpToMappedLocation: actions.jumpToMappedLocation, + updateViewport: actions.updateViewport, + updateCursorPosition: actions.updateCursorPosition, + closeTab: actions.closeTab, + toggleBlackBox: actions.toggleBlackBox, + highlightCalls: actions.highlightCalls, + unhighlightCalls: actions.unhighlightCalls, + }, + dispatch + ), + breakpointActions: breakpointItemActions(dispatch), + editorActions: editorItemActions(dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Editor); diff --git a/devtools/client/debugger/src/components/Editor/menus/breakpoints.js b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js new file mode 100644 index 0000000000..b130d8a9b7 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js @@ -0,0 +1,293 @@ +/* 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 actions from "../../../actions"; +import { bindActionCreators } from "redux"; +import { features } from "../../../utils/prefs"; +import { formatKeyShortcut } from "../../../utils/text"; +import { isLineBlackboxed } from "../../../utils/source"; + +export const addBreakpointItem = (cx, location, breakpointActions) => ({ + id: "node-menu-add-breakpoint", + label: L10N.getStr("editor.addBreakpoint"), + accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.addBreakpoint(cx, location), + accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")), +}); + +export const removeBreakpointItem = (cx, breakpoint, breakpointActions) => ({ + id: "node-menu-remove-breakpoint", + label: L10N.getStr("editor.removeBreakpoint"), + accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.removeBreakpoint(cx, breakpoint), + accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")), +}); + +export const addConditionalBreakpointItem = (location, breakpointActions) => ({ + id: "node-menu-add-conditional-breakpoint", + label: L10N.getStr("editor.addConditionBreakpoint"), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")), + accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location), +}); + +export const editConditionalBreakpointItem = (location, breakpointActions) => ({ + id: "node-menu-edit-conditional-breakpoint", + label: L10N.getStr("editor.editConditionBreakpoint"), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")), + accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location), +}); + +export const conditionalBreakpointItem = ( + breakpoint, + location, + breakpointActions +) => { + const { + options: { condition }, + } = breakpoint; + return condition + ? editConditionalBreakpointItem(location, breakpointActions) + : addConditionalBreakpointItem(location, breakpointActions); +}; + +export const addLogPointItem = (location, breakpointActions) => ({ + id: "node-menu-add-log-point", + label: L10N.getStr("editor.addLogPoint"), + accesskey: L10N.getStr("editor.addLogPoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location, true), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), +}); + +export const editLogPointItem = (location, breakpointActions) => ({ + id: "node-menu-edit-log-point", + label: L10N.getStr("editor.editLogPoint"), + accesskey: L10N.getStr("editor.editLogPoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location, true), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), +}); + +export const logPointItem = (breakpoint, location, breakpointActions) => { + const { + options: { logValue }, + } = breakpoint; + return logValue + ? editLogPointItem(location, breakpointActions) + : addLogPointItem(location, breakpointActions); +}; + +export const toggleDisabledBreakpointItem = ( + cx, + breakpoint, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList +) => { + return { + accesskey: L10N.getStr("editor.disableBreakpoint.accesskey"), + disabled: isLineBlackboxed( + blackboxedRangesForSelectedSource, + breakpoint.location.line, + isSelectedSourceOnIgnoreList + ), + click: () => breakpointActions.toggleDisabledBreakpoint(cx, breakpoint), + ...(breakpoint.disabled + ? { + id: "node-menu-enable-breakpoint", + label: L10N.getStr("editor.enableBreakpoint"), + } + : { + id: "node-menu-disable-breakpoint", + label: L10N.getStr("editor.disableBreakpoint"), + }), + }; +}; + +export const toggleDbgStatementItem = ( + cx, + location, + breakpointActions, + breakpoint +) => { + if (breakpoint && breakpoint.options.condition === "false") { + return { + disabled: false, + id: "node-menu-enable-dbgStatement", + label: L10N.getStr("breakpointMenuItem.enabledbg.label"), + click: () => + breakpointActions.setBreakpointOptions(cx, location, { + ...breakpoint.options, + condition: null, + }), + }; + } + + return { + disabled: false, + id: "node-menu-disable-dbgStatement", + label: L10N.getStr("breakpointMenuItem.disabledbg.label"), + click: () => + breakpointActions.setBreakpointOptions(cx, location, { + condition: "false", + }), + }; +}; + +export function breakpointItems( + cx, + breakpoint, + selectedLocation, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList +) { + const items = [ + removeBreakpointItem(cx, breakpoint, breakpointActions), + toggleDisabledBreakpointItem( + cx, + breakpoint, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList + ), + ]; + + if (breakpoint.originalText.startsWith("debugger")) { + items.push( + { type: "separator" }, + toggleDbgStatementItem( + cx, + selectedLocation, + breakpointActions, + breakpoint + ) + ); + } + + items.push( + { type: "separator" }, + removeBreakpointsOnLineItem(cx, selectedLocation, breakpointActions), + breakpoint.disabled + ? enableBreakpointsOnLineItem( + cx, + selectedLocation, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList + ) + : disableBreakpointsOnLineItem(cx, selectedLocation, breakpointActions), + { type: "separator" } + ); + + items.push( + conditionalBreakpointItem(breakpoint, selectedLocation, breakpointActions) + ); + items.push(logPointItem(breakpoint, selectedLocation, breakpointActions)); + + return items; +} + +export function createBreakpointItems( + cx, + location, + breakpointActions, + lineText +) { + const items = [ + addBreakpointItem(cx, location, breakpointActions), + addConditionalBreakpointItem(location, breakpointActions), + ]; + + if (features.logPoints) { + items.push(addLogPointItem(location, breakpointActions)); + } + + if (lineText && lineText.startsWith("debugger")) { + items.push(toggleDbgStatementItem(cx, location, breakpointActions)); + } + return items; +} + +// ToDo: Only enable if there are more than one breakpoints on a line? +export const removeBreakpointsOnLineItem = ( + cx, + location, + breakpointActions +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.removeAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.removeAllAtLine.accesskey"), + disabled: false, + click: () => + breakpointActions.removeBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export const enableBreakpointsOnLineItem = ( + cx, + location, + breakpointActions, + blackboxedRangesForSelectedSource, + isSelectedSourceOnIgnoreList +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.enableAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.enableAllAtLine.accesskey"), + disabled: isLineBlackboxed( + blackboxedRangesForSelectedSource, + location.line, + isSelectedSourceOnIgnoreList + ), + click: () => + breakpointActions.enableBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export const disableBreakpointsOnLineItem = ( + cx, + location, + breakpointActions +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.disableAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.disableAllAtLine.accesskey"), + disabled: false, + click: () => + breakpointActions.disableBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export function breakpointItemActions(dispatch) { + return bindActionCreators( + { + addBreakpoint: actions.addBreakpoint, + removeBreakpoint: actions.removeBreakpoint, + removeBreakpointsAtLine: actions.removeBreakpointsAtLine, + enableBreakpointsAtLine: actions.enableBreakpointsAtLine, + disableBreakpointsAtLine: actions.disableBreakpointsAtLine, + disableBreakpoint: actions.disableBreakpoint, + toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint, + toggleBreakpointsAtLine: actions.toggleBreakpointsAtLine, + setBreakpointOptions: actions.setBreakpointOptions, + openConditionalPanel: actions.openConditionalPanel, + }, + dispatch + ); +} diff --git a/devtools/client/debugger/src/components/Editor/menus/editor.js b/devtools/client/debugger/src/components/Editor/menus/editor.js new file mode 100644 index 0000000000..5ed3c96f6f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/editor.js @@ -0,0 +1,403 @@ +/* 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 { bindActionCreators } from "redux"; + +import { copyToTheClipboard } from "../../../utils/clipboard"; +import { + getRawSourceURL, + getFilename, + shouldBlackbox, + findBlackBoxRange, +} from "../../../utils/source"; +import { toSourceLine } from "../../../utils/editor"; +import { downloadFile } from "../../../utils/utils"; +import { features } from "../../../utils/prefs"; + +import { isFulfilled } from "../../../utils/async-value"; +import actions from "../../../actions"; + +// Menu Items +export const continueToHereItem = (cx, location, isPaused, editorActions) => ({ + accesskey: L10N.getStr("editor.continueToHere.accesskey"), + disabled: !isPaused, + click: () => editorActions.continueToHere(cx, location), + id: "node-menu-continue-to-here", + label: L10N.getStr("editor.continueToHere.label"), +}); + +const copyToClipboardItem = (selectionText, editorActions) => ({ + id: "node-menu-copy-to-clipboard", + label: L10N.getStr("copyToClipboard.label"), + accesskey: L10N.getStr("copyToClipboard.accesskey"), + disabled: selectionText.length === 0, + click: () => copyToTheClipboard(selectionText), +}); + +const copySourceItem = (selectedContent, editorActions) => ({ + id: "node-menu-copy-source", + label: L10N.getStr("copySource.label"), + accesskey: L10N.getStr("copySource.accesskey"), + disabled: false, + click: () => + selectedContent.type === "text" && + copyToTheClipboard(selectedContent.value), +}); + +const copySourceUri2Item = (selectedSource, editorActions) => ({ + id: "node-menu-copy-source-url", + label: L10N.getStr("copySourceUri2"), + accesskey: L10N.getStr("copySourceUri2.accesskey"), + disabled: !selectedSource.url, + click: () => copyToTheClipboard(getRawSourceURL(selectedSource.url)), +}); + +const jumpToMappedLocationItem = ( + cx, + selectedSource, + location, + hasMappedLocation, + editorActions +) => ({ + id: "node-menu-jump", + label: L10N.getFormatStr( + "editor.jumpToMappedLocation1", + selectedSource.isOriginal + ? L10N.getStr("generated") + : L10N.getStr("original") + ), + accesskey: L10N.getStr("editor.jumpToMappedLocation1.accesskey"), + disabled: !hasMappedLocation, + click: () => editorActions.jumpToMappedLocation(cx, location), +}); + +const showSourceMenuItem = (cx, selectedSource, editorActions) => ({ + id: "node-menu-show-source", + label: L10N.getStr("sourceTabs.revealInTree"), + accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"), + disabled: !selectedSource.url, + click: () => editorActions.showSource(cx, selectedSource.id), +}); + +const blackBoxMenuItem = ( + cx, + selectedSource, + blackboxedRanges, + editorActions, + isSourceOnIgnoreList +) => { + const isBlackBoxed = !!blackboxedRanges[selectedSource.url]; + return { + id: "node-menu-blackbox", + label: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore") + : L10N.getStr("ignoreContextItem.ignore"), + accesskey: isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore.accesskey") + : L10N.getStr("ignoreContextItem.ignore.accesskey"), + disabled: isSourceOnIgnoreList || !shouldBlackbox(selectedSource), + click: () => editorActions.toggleBlackBox(cx, selectedSource), + }; +}; + +export const blackBoxLineMenuItem = ( + cx, + selectedSource, + editorActions, + editor, + blackboxedRanges, + isSourceOnIgnoreList, + // the clickedLine is passed when the context menu + // is opened from the gutter, it is not available when the + // the context menu is opened from the editor. + clickedLine = null +) => { + const { codeMirror } = editor; + const from = codeMirror.getCursor("from"); + const to = codeMirror.getCursor("to"); + + const startLine = clickedLine ?? toSourceLine(selectedSource.id, from.line); + const endLine = clickedLine ?? toSourceLine(selectedSource.id, to.line); + + const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const selectedLineIsBlackBoxed = !!blackboxRange; + + const isSingleLine = selectedLineIsBlackBoxed + ? blackboxRange.start.line == blackboxRange.end.line + : startLine == endLine; + + const isSourceFullyBlackboxed = + blackboxedRanges[selectedSource.url] && + !blackboxedRanges[selectedSource.url].length; + + // The ignore/unignore line context menu item should be disabled when + // 1) The source is on the sourcemap ignore list + // 2) The whole source is blackboxed or + // 3) Multiple lines are blackboxed or + // 4) Multiple lines are selected in the editor + const shouldDisable = + isSourceOnIgnoreList || isSourceFullyBlackboxed || !isSingleLine; + + return { + id: "node-menu-blackbox-line", + label: !selectedLineIsBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLine") + : L10N.getStr("ignoreContextItem.unignoreLine"), + accesskey: !selectedLineIsBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLine.accesskey") + : L10N.getStr("ignoreContextItem.unignoreLine.accesskey"), + disabled: shouldDisable, + click: () => { + const selectionRange = { + start: { + line: startLine, + column: clickedLine == null ? from.ch : 0, + }, + end: { + line: endLine, + column: clickedLine == null ? to.ch : 0, + }, + }; + + editorActions.toggleBlackBox( + cx, + selectedSource, + !selectedLineIsBlackBoxed, + selectedLineIsBlackBoxed ? [blackboxRange] : [selectionRange] + ); + }, + }; +}; + +const blackBoxLinesMenuItem = ( + cx, + selectedSource, + editorActions, + editor, + blackboxedRanges, + isSourceOnIgnoreList +) => { + const { codeMirror } = editor; + const from = codeMirror.getCursor("from"); + const to = codeMirror.getCursor("to"); + + const startLine = toSourceLine(selectedSource.id, from.line); + const endLine = toSourceLine(selectedSource.id, to.line); + + const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const selectedLinesAreBlackBoxed = !!blackboxRange; + + return { + id: "node-menu-blackbox-lines", + label: !selectedLinesAreBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLines") + : L10N.getStr("ignoreContextItem.unignoreLines"), + accesskey: !selectedLinesAreBlackBoxed + ? L10N.getStr("ignoreContextItem.ignoreLines.accesskey") + : L10N.getStr("ignoreContextItem.unignoreLines.accesskey"), + disabled: isSourceOnIgnoreList, + click: () => { + const selectionRange = { + start: { + line: startLine, + column: from.ch, + }, + end: { + line: endLine, + column: to.ch, + }, + }; + + editorActions.toggleBlackBox( + cx, + selectedSource, + !selectedLinesAreBlackBoxed, + selectedLinesAreBlackBoxed ? [blackboxRange] : [selectionRange] + ); + }, + }; +}; + +const watchExpressionItem = ( + cx, + selectedSource, + selectionText, + editorActions +) => ({ + id: "node-menu-add-watch-expression", + label: L10N.getStr("expressions.label"), + accesskey: L10N.getStr("expressions.accesskey"), + click: () => editorActions.addExpression(cx, selectionText), +}); + +const evaluateInConsoleItem = ( + selectedSource, + selectionText, + editorActions +) => ({ + id: "node-menu-evaluate-in-console", + label: L10N.getStr("evaluateInConsole.label"), + click: () => editorActions.evaluateInConsole(selectionText), +}); + +const downloadFileItem = (selectedSource, selectedContent, editorActions) => ({ + id: "node-menu-download-file", + label: L10N.getStr("downloadFile.label"), + accesskey: L10N.getStr("downloadFile.accesskey"), + click: () => downloadFile(selectedContent, getFilename(selectedSource)), +}); + +const inlinePreviewItem = editorActions => ({ + id: "node-menu-inline-preview", + label: features.inlinePreview + ? L10N.getStr("inlinePreview.hide.label") + : L10N.getStr("inlinePreview.show.label"), + click: () => editorActions.toggleInlinePreview(!features.inlinePreview), +}); + +const editorWrappingItem = (editorActions, editorWrappingEnabled) => ({ + id: "node-menu-editor-wrapping", + label: editorWrappingEnabled + ? L10N.getStr("editorWrapping.hide.label") + : L10N.getStr("editorWrapping.show.label"), + click: () => editorActions.toggleEditorWrapping(!editorWrappingEnabled), +}); + +export function editorMenuItems({ + cx, + editorActions, + selectedSource, + blackboxedRanges, + location, + selectionText, + hasMappedLocation, + isTextSelected, + isPaused, + editorWrappingEnabled, + editor, + isSourceOnIgnoreList, +}) { + const items = []; + + const content = + selectedSource.content && isFulfilled(selectedSource.content) + ? selectedSource.content.value + : null; + + items.push( + jumpToMappedLocationItem( + cx, + selectedSource, + location, + hasMappedLocation, + editorActions + ), + continueToHereItem(cx, location, isPaused, editorActions), + { type: "separator" }, + copyToClipboardItem(selectionText, editorActions), + ...(!selectedSource.isWasm + ? [ + ...(content ? [copySourceItem(content, editorActions)] : []), + copySourceUri2Item(selectedSource, editorActions), + ] + : []), + ...(content + ? [downloadFileItem(selectedSource, content, editorActions)] + : []), + { type: "separator" }, + showSourceMenuItem(cx, selectedSource, editorActions), + { type: "separator" }, + blackBoxMenuItem( + cx, + selectedSource, + blackboxedRanges, + editorActions, + isSourceOnIgnoreList + ) + ); + + const startLine = toSourceLine( + selectedSource.id, + editor.codeMirror.getCursor("from").line + ); + const endLine = toSourceLine( + selectedSource.id, + editor.codeMirror.getCursor("to").line + ); + + // Find any blackbox ranges that exist for the selected lines + const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, { + start: startLine, + end: endLine, + }); + + const isMultiLineSelection = blackboxRange + ? blackboxRange.start.line !== blackboxRange.end.line + : startLine !== endLine; + + // When the range is defined and is an empty array, + // the whole source is blackboxed + const theWholeSourceIsBlackBoxed = + blackboxedRanges[selectedSource.url] && + !blackboxedRanges[selectedSource.url].length; + + if (!theWholeSourceIsBlackBoxed) { + const blackBoxSourceLinesMenuItem = isMultiLineSelection + ? blackBoxLinesMenuItem + : blackBoxLineMenuItem; + + items.push( + blackBoxSourceLinesMenuItem( + cx, + selectedSource, + editorActions, + editor, + blackboxedRanges, + isSourceOnIgnoreList + ) + ); + } + + if (isTextSelected) { + items.push( + { type: "separator" }, + watchExpressionItem(cx, selectedSource, selectionText, editorActions), + evaluateInConsoleItem(selectedSource, selectionText, editorActions) + ); + } + + items.push( + { type: "separator" }, + inlinePreviewItem(editorActions), + editorWrappingItem(editorActions, editorWrappingEnabled) + ); + + return items; +} + +export function editorItemActions(dispatch) { + return bindActionCreators( + { + addExpression: actions.addExpression, + continueToHere: actions.continueToHere, + evaluateInConsole: actions.evaluateInConsole, + flashLineRange: actions.flashLineRange, + jumpToMappedLocation: actions.jumpToMappedLocation, + showSource: actions.showSource, + toggleBlackBox: actions.toggleBlackBox, + toggleBlackBoxLines: actions.toggleBlackBoxLines, + toggleInlinePreview: actions.toggleInlinePreview, + toggleEditorWrapping: actions.toggleEditorWrapping, + }, + dispatch + ); +} diff --git a/devtools/client/debugger/src/components/Editor/menus/moz.build b/devtools/client/debugger/src/components/Editor/menus/moz.build new file mode 100644 index 0000000000..18009aa2db --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/moz.build @@ -0,0 +1,12 @@ +# 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( + "breakpoints.js", + "editor.js", + "source.js", +) diff --git a/devtools/client/debugger/src/components/Editor/menus/source.js b/devtools/client/debugger/src/components/Editor/menus/source.js new file mode 100644 index 0000000000..0ba8834e6f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/source.js @@ -0,0 +1,3 @@ +/* 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/>. */ diff --git a/devtools/client/debugger/src/components/Editor/moz.build b/devtools/client/debugger/src/components/Editor/moz.build new file mode 100644 index 0000000000..b31918f2e0 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/moz.build @@ -0,0 +1,34 @@ +# 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 += [ + "menus", + "Preview", +] + +CompiledModules( + "BlackboxLines.js", + "Breakpoint.js", + "Breakpoints.js", + "ColumnBreakpoint.js", + "ColumnBreakpoints.js", + "ConditionalPanel.js", + "DebugLine.js", + "EditorMenu.js", + "EmptyLines.js", + "Exception.js", + "Exceptions.js", + "Footer.js", + "HighlightCalls.js", + "HighlightLine.js", + "HighlightLines.js", + "index.js", + "InlinePreview.js", + "InlinePreviewRow.js", + "InlinePreviews.js", + "SearchInFileBar.js", + "Tab.js", + "Tabs.js", +) diff --git a/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js new file mode 100644 index 0000000000..915b812dff --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js @@ -0,0 +1,54 @@ +/* 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 { shallow } from "enzyme"; +import Breakpoints from "../Breakpoints"; + +const BreakpointsComponent = Breakpoints.WrappedComponent; + +function generateDefaults(overrides) { + const sourceId = "server1.conn1.child1/source1"; + const matchingBreakpoints = [{ location: { source: { id: sourceId } } }]; + + return { + selectedSource: { sourceId, get: () => false }, + editor: { + codeMirror: { + setGutterMarker: jest.fn(), + }, + }, + blackboxedRanges: {}, + cx: {}, + breakpointActions: {}, + editorActions: {}, + breakpoints: matchingBreakpoints, + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<BreakpointsComponent {...props} />); + return { component, props }; +} + +describe("Breakpoints Component", () => { + it("should render breakpoints without columns", async () => { + const sourceId = "server1.conn1.child1/source1"; + const breakpoints = [{ location: { source: { id: sourceId } } }]; + + const { component, props } = render({ breakpoints }); + expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length); + }); + + it("should render breakpoints with columns", async () => { + const sourceId = "server1.conn1.child1/source1"; + const breakpoints = [{ location: { column: 2, source: { id: sourceId } } }]; + + const { component, props } = render({ breakpoints }); + expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js new file mode 100644 index 0000000000..05e4dcb727 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js @@ -0,0 +1,77 @@ +/* eslint max-nested-callbacks: ["error", 7] */ +/* 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 { mount } from "enzyme"; +import { ConditionalPanel } from "../ConditionalPanel"; +import * as mocks from "../../../utils/test-mockup"; + +const source = mocks.makeMockSource(); + +function generateDefaults(overrides, log, line, column, condition, logValue) { + const breakpoint = mocks.makeMockBreakpoint(source, line, column); + breakpoint.options.condition = condition; + breakpoint.options.logValue = logValue; + + return { + editor: { + CodeMirror: { + fromTextArea: jest.fn(() => { + return { + on: jest.fn(), + getWrapperElement: jest.fn(() => { + return { + addEventListener: jest.fn(), + }; + }), + focus: jest.fn(), + setCursor: jest.fn(), + lineCount: jest.fn(), + }; + }), + }, + codeMirror: { + addLineWidget: jest.fn(), + }, + }, + location: breakpoint.location, + source, + breakpoint, + log, + getDefaultValue: jest.fn(), + openConditionalPanel: jest.fn(), + closeConditionalPanel: jest.fn(), + ...overrides, + }; +} + +function render(log, line, column, condition, logValue, overrides = {}) { + const defaults = generateDefaults( + overrides, + log, + line, + column, + condition, + logValue + ); + const props = { ...defaults, ...overrides }; + const wrapper = mount(<ConditionalPanel {...props} />); + return { wrapper, props }; +} + +describe("ConditionalPanel", () => { + it("it should render at location of selected breakpoint", () => { + const { wrapper } = render(false, 2, 2); + expect(wrapper).toMatchSnapshot(); + }); + it("it should render with condition at selected breakpoint location", () => { + const { wrapper } = render(false, 3, 3, "I'm a condition", "not a log"); + expect(wrapper).toMatchSnapshot(); + }); + it("it should render with logpoint at selected breakpoint location", () => { + const { wrapper } = render(true, 4, 4, "not a condition", "I'm a log"); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js new file mode 100644 index 0000000000..a7fcb53a2d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js @@ -0,0 +1,85 @@ +/* eslint max-nested-callbacks: ["error", 7] */ +/* 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 { shallow } from "enzyme"; + +import DebugLine from "../DebugLine"; + +import { setDocument } from "../../../utils/editor"; + +function createMockDocument(clear) { + const doc = { + addLineClass: jest.fn(), + removeLineClass: jest.fn(), + markText: jest.fn(() => ({ clear })), + getLine: line => "", + }; + + return doc; +} + +function generateDefaults(editor, overrides) { + return { + editor, + pauseInfo: { + why: { type: "breakpoint" }, + }, + frame: null, + sourceTextContent: null, + ...overrides, + }; +} + +function createLocation(line) { + return { + source: { + id: "foo", + }, + sourceId: "foo", + line, + column: 2, + }; +} + +function render(overrides = {}) { + const clear = jest.fn(); + const editor = { codeMirror: {} }; + const props = generateDefaults(editor, overrides); + + const doc = createMockDocument(clear); + setDocument("foo", doc); + + const component = shallow(<DebugLine.WrappedComponent {...props} />, { + lifecycleExperimental: true, + }); + return { component, props, clear, editor, doc }; +} + +describe("DebugLine Component", () => { + describe("pausing at the first location", () => { + describe("when there is no selected frame", () => { + it("should not set the debug line", () => { + const { component, props, doc } = render({ frame: null }); + const line = 2; + const location = createLocation(line); + + component.setProps({ ...props, location }); + expect(doc.removeLineClass).not.toHaveBeenCalled(); + }); + }); + + describe("when there is a different source", () => { + it("should not set the debug line", async () => { + const { component, doc } = render(); + const newSelectedFrame = { location: { sourceId: "bar" } }; + expect(doc.removeLineClass).not.toHaveBeenCalled(); + + component.setProps({ frame: newSelectedFrame }); + expect(doc.removeLineClass).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js new file mode 100644 index 0000000000..b58ba45cb3 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js @@ -0,0 +1,67 @@ +/* eslint max-nested-callbacks: ["error", 7] */ +/* 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 { shallow } from "enzyme"; + +import SourceFooter from "../Footer"; +import { createSourceObject } from "../../../utils/test-head"; +import { setDocument } from "../../../utils/editor"; + +function createMockDocument(clear, position) { + const doc = { + getCursor: jest.fn(() => position), + }; + return doc; +} + +function generateDefaults(overrides) { + return { + editor: { + codeMirror: { + doc: {}, + cursorActivity: jest.fn(), + on: jest.fn(), + }, + }, + endPanelCollapsed: false, + selectedSource: { + ...createSourceObject("foo"), + content: null, + }, + ...overrides, + }; +} + +function render(overrides = {}, position = { line: 0, column: 0 }) { + const clear = jest.fn(); + const props = generateDefaults(overrides); + + const doc = createMockDocument(clear, position); + setDocument(props.selectedSource.id, doc); + + const component = shallow(<SourceFooter.WrappedComponent {...props} />, { + lifecycleExperimental: true, + }); + return { component, props, clear, doc }; +} + +describe("SourceFooter Component", () => { + describe("default case", () => { + it("should render", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + }); + + describe("move cursor", () => { + it("should render new cursor position", () => { + const { component } = render(); + component.setState({ cursorPosition: { line: 5, column: 10 } }); + + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap new file mode 100644 index 0000000000..48cda915a4 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Breakpoints Component should render breakpoints with columns 1`] = ` +<div> + <Breakpoint + breakpoint={ + Object { + "location": Object { + "column": 2, + "source": Object { + "id": "server1.conn1.child1/source1", + }, + }, + } + } + breakpointActions={Object {}} + cx={Object {}} + editor={ + Object { + "codeMirror": Object { + "setGutterMarker": [MockFunction], + }, + } + } + editorActions={Object {}} + key="undefined:undefined:2" + selectedSource={ + Object { + "get": [Function], + "sourceId": "server1.conn1.child1/source1", + } + } + /> +</div> +`; diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap new file mode 100644 index 0000000000..d2f52bb6e3 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap @@ -0,0 +1,630 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConditionalPanel it should render at location of selected breakpoint 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 2, + "line": 2, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 2, + "line": 2, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "options": Object { + "condition": undefined, + "logValue": undefined, + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea />, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Breakpoint condition, e.g. items.length > 0", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + }, + "codeMirror": Object { + "addLineWidget": [MockFunction] { + "calls": Array [ + Array [ + 1, + <div> + <div + class="conditional-breakpoint-panel" + > + <div + class="prompt" + > + » + </div> + <textarea /> + </div> + </div>, + Object { + "coverGutter": true, + "noHScroll": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 2, + "line": 2, + "source": Object { + "id": "source", + }, + "sourceId": "source", + } + } + log={false} + openConditionalPanel={[MockFunction]} + source={ + Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + } + } +/> +`; + +exports[`ConditionalPanel it should render with condition at selected breakpoint location 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 3, + "line": 3, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 3, + "line": 3, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "options": Object { + "condition": "I'm a condition", + "logValue": "not a log", + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea> + I'm a condition + </textarea>, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Breakpoint condition, e.g. items.length > 0", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + }, + "codeMirror": Object { + "addLineWidget": [MockFunction] { + "calls": Array [ + Array [ + 2, + <div> + <div + class="conditional-breakpoint-panel" + > + <div + class="prompt" + > + » + </div> + <textarea> + I'm a condition + </textarea> + </div> + </div>, + Object { + "coverGutter": true, + "noHScroll": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 3, + "line": 3, + "source": Object { + "id": "source", + }, + "sourceId": "source", + } + } + log={false} + openConditionalPanel={[MockFunction]} + source={ + Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + } + } +/> +`; + +exports[`ConditionalPanel it should render with logpoint at selected breakpoint location 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 4, + "line": 4, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 4, + "line": 4, + "source": Object { + "id": "source", + }, + "sourceId": "source", + }, + "options": Object { + "condition": "not a condition", + "logValue": "I'm a log", + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea> + I'm a log + </textarea>, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Log message, e.g. displayName", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + }, + "codeMirror": Object { + "addLineWidget": [MockFunction] { + "calls": Array [ + Array [ + 3, + <div> + <div + class="conditional-breakpoint-panel log-point" + > + <div + class="prompt" + > + » + </div> + <textarea> + I'm a log + </textarea> + </div> + </div>, + Object { + "coverGutter": true, + "noHScroll": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 4, + "line": 4, + "source": Object { + "id": "source", + }, + "sourceId": "source", + } + } + log={true} + openConditionalPanel={[MockFunction]} + source={ + Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + } + } +/> +`; diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap new file mode 100644 index 0000000000..d6123d4c67 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SourceFooter Component default case should render 1`] = ` +<div + className="source-footer" +> + <div + className="source-footer-start" + > + <div + className="commands" + > + <button + aria-label="Ignore source" + className="action black-box" + key="black-box" + onClick={[Function]} + title="Ignore source" + > + <AccessibleImage + className="blackBox" + /> + </button> + <button + className="action prettyPrint" + disabled={true} + key="prettyPrint" + onClick={[Function]} + > + <AccessibleImage + className="prettyPrint" + /> + </button> + </div> + </div> + <div + className="source-footer-end" + > + <div + className="cursor-position" + title="(Line 1, column 1)" + > + (1, 1) + </div> + <PaneToggleButton + collapsed={false} + horizontal={false} + key="toggle" + position="end" + /> + </div> +</div> +`; + +exports[`SourceFooter Component move cursor should render new cursor position 1`] = ` +<div + className="source-footer" +> + <div + className="source-footer-start" + > + <div + className="commands" + > + <button + aria-label="Ignore source" + className="action black-box" + key="black-box" + onClick={[Function]} + title="Ignore source" + > + <AccessibleImage + className="blackBox" + /> + </button> + <button + className="action prettyPrint" + disabled={true} + key="prettyPrint" + onClick={[Function]} + > + <AccessibleImage + className="prettyPrint" + /> + </button> + </div> + </div> + <div + className="source-footer-end" + > + <div + className="cursor-position" + title="(Line 6, column 11)" + > + (6, 11) + </div> + <PaneToggleButton + collapsed={false} + horizontal={false} + key="toggle" + position="end" + /> + </div> +</div> +`; 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> +`; diff --git a/devtools/client/debugger/src/components/QuickOpenModal.css b/devtools/client/debugger/src/components/QuickOpenModal.css new file mode 100644 index 0000000000..5a2627b99f --- /dev/null +++ b/devtools/client/debugger/src/components/QuickOpenModal.css @@ -0,0 +1,28 @@ +/* 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/>. */ + +.result-item .title .highlight { + font-weight: bold; + background-color: transparent; +} + +.selected .highlight { + color: white; +} + +.result-item .subtitle .highlight { + color: var(--grey-90); + font-weight: 500; + background-color: transparent; +} + +.theme-dark .result-item .title .highlight, +.theme-dark .result-item .subtitle .highlight { + color: white; +} + +.loading-indicator { + padding: 5px 0 5px 0; + text-align: center; +} diff --git a/devtools/client/debugger/src/components/QuickOpenModal.js b/devtools/client/debugger/src/components/QuickOpenModal.js new file mode 100644 index 0000000000..f993b0f6c1 --- /dev/null +++ b/devtools/client/debugger/src/components/QuickOpenModal.js @@ -0,0 +1,524 @@ +/* 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 fuzzyAldrin from "fuzzaldrin-plus"; +import { basename } from "../utils/path"; +import { createLocation } from "../utils/location"; + +const { throttle } = require("devtools/shared/throttle"); + +import actions from "../actions"; +import { + getDisplayedSourcesList, + getQuickOpenEnabled, + getQuickOpenQuery, + getQuickOpenType, + getSelectedSource, + getSelectedLocation, + getSettledSourceTextContent, + getSymbols, + getTabs, + getContext, + getBlackBoxRanges, + getProjectDirectoryRoot, +} from "../selectors"; +import { memoizeLast } from "../utils/memoizeLast"; +import { scrollList } from "../utils/result-list"; +import { searchKeys } from "../constants"; +import { + formatSymbols, + parseLineColumn, + formatShortcutResults, + formatSourceForList, +} from "../utils/quick-open"; +import Modal from "./shared/Modal"; +import SearchInput from "./shared/SearchInput"; +import ResultList from "./shared/ResultList"; + +import "./QuickOpenModal.css"; + +const maxResults = 100; + +const SIZE_BIG = { size: "big" }; +const SIZE_DEFAULT = {}; + +function filter(values, query) { + const preparedQuery = fuzzyAldrin.prepareQuery(query); + + return fuzzyAldrin.filter(values, query, { + key: "value", + maxResults, + preparedQuery, + }); +} + +export class QuickOpenModal extends Component { + // Put it on the class so it can be retrieved in tests + static UPDATE_RESULTS_THROTTLE = 100; + + constructor(props) { + super(props); + this.state = { results: null, selectedIndex: 0 }; + } + + static get propTypes() { + return { + closeQuickOpen: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + displayedSources: PropTypes.array.isRequired, + blackBoxRanges: PropTypes.object.isRequired, + enabled: PropTypes.bool.isRequired, + highlightLineRange: PropTypes.func.isRequired, + clearHighlightLineRange: PropTypes.func.isRequired, + query: PropTypes.string.isRequired, + searchType: PropTypes.oneOf([ + "functions", + "goto", + "gotoSource", + "other", + "shortcuts", + "sources", + "variables", + ]).isRequired, + selectSpecificLocation: PropTypes.func.isRequired, + selectedContentLoaded: PropTypes.bool, + selectedSource: PropTypes.object, + setQuickOpenQuery: PropTypes.func.isRequired, + shortcutsModalEnabled: PropTypes.bool.isRequired, + symbols: PropTypes.object.isRequired, + symbolsLoading: PropTypes.bool.isRequired, + tabUrls: PropTypes.array.isRequired, + toggleShortcutsModal: PropTypes.func.isRequired, + projectDirectoryRoot: PropTypes.string, + }; + } + + setResults(results) { + if (results) { + results = results.slice(0, maxResults); + } + this.setState({ results }); + } + + componentDidMount() { + const { query, shortcutsModalEnabled, toggleShortcutsModal } = this.props; + + this.updateResults(query); + + if (shortcutsModalEnabled) { + toggleShortcutsModal(); + } + } + + componentDidUpdate(prevProps) { + const nowEnabled = !prevProps.enabled && this.props.enabled; + const queryChanged = prevProps.query !== this.props.query; + + if (this.refs.resultList && this.refs.resultList.refs) { + scrollList(this.refs.resultList.refs, this.state.selectedIndex); + } + + if (nowEnabled || queryChanged) { + this.updateResults(this.props.query); + } + } + + closeModal = () => { + this.props.closeQuickOpen(); + }; + + dropGoto = query => { + const index = query.indexOf(":"); + return index !== -1 ? query.slice(0, index) : query; + }; + + formatSources = memoizeLast( + (displayedSources, tabUrls, blackBoxRanges, projectDirectoryRoot) => { + // Note that we should format all displayed sources, + // the actual filtering will only be done late from `searchSources()` + return displayedSources.map(source => { + const isBlackBoxed = !!blackBoxRanges[source.url]; + const hasTabOpened = tabUrls.includes(source.url); + return formatSourceForList( + source, + hasTabOpened, + isBlackBoxed, + projectDirectoryRoot + ); + }); + } + ); + + searchSources = query => { + const { displayedSources, tabUrls, blackBoxRanges, projectDirectoryRoot } = + this.props; + + const sources = this.formatSources( + displayedSources, + tabUrls, + blackBoxRanges, + projectDirectoryRoot + ); + const results = + query == "" ? sources : filter(sources, this.dropGoto(query)); + return this.setResults(results); + }; + + searchSymbols = query => { + const { + symbols: { functions }, + } = this.props; + + let results = functions; + results = results.filter(result => result.title !== "anonymous"); + + if (query === "@" || query === "#") { + return this.setResults(results); + } + results = filter(results, query.slice(1)); + return this.setResults(results); + }; + + searchShortcuts = query => { + const results = formatShortcutResults(); + if (query == "?") { + this.setResults(results); + } else { + this.setResults(filter(results, query.slice(1))); + } + }; + + /** + * This method is called when we just opened the modal and the query input is empty + */ + showTopSources = () => { + const { tabUrls, blackBoxRanges, projectDirectoryRoot } = this.props; + let { displayedSources } = this.props; + + // If there is some tabs opened, only show tab's sources. + // Otherwise, we display all visible sources (per SourceTree definition), + // setResults will restrict the number of results to a maximum limit. + if (tabUrls.length) { + displayedSources = displayedSources.filter( + source => !!source.url && tabUrls.includes(source.url) + ); + } + + this.setResults( + this.formatSources( + displayedSources, + tabUrls, + blackBoxRanges, + projectDirectoryRoot + ) + ); + }; + + updateResults = throttle(query => { + if (this.isGotoQuery()) { + return; + } + + if (query == "" && !this.isShortcutQuery()) { + this.showTopSources(); + return; + } + + if (this.isSymbolSearch()) { + this.searchSymbols(query); + return; + } + + if (this.isShortcutQuery()) { + this.searchShortcuts(query); + return; + } + + this.searchSources(query); + }, QuickOpenModal.UPDATE_RESULTS_THROTTLE); + + setModifier = item => { + if (["@", "#", ":"].includes(item.id)) { + this.props.setQuickOpenQuery(item.id); + } + }; + + selectResultItem = (e, item) => { + if (item == null) { + return; + } + + if (this.isShortcutQuery()) { + this.setModifier(item); + return; + } + + if (this.isGotoSourceQuery()) { + const location = parseLineColumn(this.props.query); + this.gotoLocation({ ...location, source: item.source }); + return; + } + + if (this.isSymbolSearch()) { + this.gotoLocation({ + line: + item.location && item.location.start ? item.location.start.line : 0, + }); + return; + } + + this.gotoLocation({ source: item.source, line: 0 }); + }; + + onSelectResultItem = item => { + const { selectedSource, highlightLineRange, clearHighlightLineRange } = + this.props; + if ( + selectedSource == null || + !this.isSymbolSearch() || + !this.isFunctionQuery() + ) { + return; + } + + if (item.location) { + highlightLineRange({ + start: item.location.start.line, + end: item.location.end.line, + sourceId: selectedSource.id, + }); + } else { + clearHighlightLineRange(); + } + }; + + traverseResults = e => { + const direction = e.key === "ArrowUp" ? -1 : 1; + const { selectedIndex, results } = this.state; + const resultCount = this.getResultCount(); + const index = selectedIndex + direction; + const nextIndex = (index + resultCount) % resultCount || 0; + + this.setState({ selectedIndex: nextIndex }); + + if (results != null) { + this.onSelectResultItem(results[nextIndex]); + } + }; + + gotoLocation = location => { + const { cx, selectSpecificLocation, selectedSource } = this.props; + + if (location != null) { + selectSpecificLocation( + cx, + createLocation({ + source: location.source || selectedSource, + line: location.line, + column: location.column, + }) + ); + this.closeModal(); + } + }; + + onChange = e => { + const { selectedSource, selectedContentLoaded, setQuickOpenQuery } = + this.props; + setQuickOpenQuery(e.target.value); + const noSource = !selectedSource || !selectedContentLoaded; + if ((noSource && this.isSymbolSearch()) || this.isGotoQuery()) { + return; + } + + // Wait for the next tick so that reducer updates are complete. + const targetValue = e.target.value; + setTimeout(() => this.updateResults(targetValue), 0); + }; + + onKeyDown = e => { + const { enabled, query } = this.props; + const { results, selectedIndex } = this.state; + const isGoToQuery = this.isGotoQuery(); + + if ((!enabled || !results) && !isGoToQuery) { + return; + } + + if (e.key === "Enter") { + if (isGoToQuery) { + const location = parseLineColumn(query); + this.gotoLocation(location); + return; + } + + if (results) { + this.selectResultItem(e, results[selectedIndex]); + return; + } + } + + if (e.key === "Tab") { + this.closeModal(); + return; + } + + if (["ArrowUp", "ArrowDown"].includes(e.key)) { + e.preventDefault(); + this.traverseResults(e); + } + }; + + getResultCount = () => { + const { results } = this.state; + return results && results.length ? results.length : 0; + }; + + // Query helpers + isFunctionQuery = () => this.props.searchType === "functions"; + isSymbolSearch = () => this.isFunctionQuery(); + isGotoQuery = () => this.props.searchType === "goto"; + isGotoSourceQuery = () => this.props.searchType === "gotoSource"; + isShortcutQuery = () => this.props.searchType === "shortcuts"; + isSourcesQuery = () => this.props.searchType === "sources"; + isSourceSearch = () => this.isSourcesQuery() || this.isGotoSourceQuery(); + + /* eslint-disable react/no-danger */ + renderHighlight(candidateString, query, name) { + const options = { + wrap: { + tagOpen: '<mark class="highlight">', + tagClose: "</mark>", + }, + }; + const html = fuzzyAldrin.wrap(candidateString, query, options); + return <div dangerouslySetInnerHTML={{ __html: html }} />; + } + + highlightMatching = (query, results) => { + let newQuery = query; + if (newQuery === "") { + return results; + } + newQuery = query.replace(/[@:#?]/gi, " "); + + return results.map(result => { + if (typeof result.title == "string") { + return { + ...result, + title: this.renderHighlight( + result.title, + basename(newQuery), + "title" + ), + }; + } + return result; + }); + }; + + shouldShowErrorEmoji() { + const { query } = this.props; + if (this.isGotoQuery()) { + return !/^:\d*$/.test(query); + } + return !!query && !this.getResultCount(); + } + + getSummaryMessage() { + let summaryMsg = ""; + if (this.isGotoQuery()) { + summaryMsg = L10N.getStr("shortcuts.gotoLine"); + } else if (this.isFunctionQuery() && this.props.symbolsLoading) { + summaryMsg = L10N.getStr("loadingText"); + } + return summaryMsg; + } + + render() { + const { enabled, query } = this.props; + const { selectedIndex, results } = this.state; + + if (!enabled) { + return null; + } + const items = this.highlightMatching(query, results || []); + const expanded = !!items && !!items.length; + + return ( + <Modal in={enabled} handleClose={this.closeModal}> + <SearchInput + query={query} + hasPrefix={true} + count={this.getResultCount()} + placeholder={L10N.getStr("sourceSearch.search2")} + summaryMsg={this.getSummaryMessage()} + showErrorEmoji={this.shouldShowErrorEmoji()} + isLoading={false} + onChange={this.onChange} + onKeyDown={this.onKeyDown} + handleClose={this.closeModal} + expanded={expanded} + showClose={false} + searchKey={searchKeys.QUICKOPEN_SEARCH} + showExcludePatterns={false} + showSearchModifiers={false} + selectedItemId={ + expanded && items[selectedIndex] ? items[selectedIndex].id : "" + } + {...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT)} + /> + {results && ( + <ResultList + key="results" + items={items} + selected={selectedIndex} + selectItem={this.selectResultItem} + ref="resultList" + expanded={expanded} + {...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT)} + /> + )} + </Modal> + ); + } +} + +/* istanbul ignore next: ignoring testing of redux connection stuff */ +function mapStateToProps(state) { + const selectedSource = getSelectedSource(state); + const location = getSelectedLocation(state); + const displayedSources = getDisplayedSourcesList(state); + const tabs = getTabs(state); + const tabUrls = [...new Set(tabs.map(tab => tab.url))]; + const symbols = getSymbols(state, location); + + return { + cx: getContext(state), + enabled: getQuickOpenEnabled(state), + displayedSources, + blackBoxRanges: getBlackBoxRanges(state), + projectDirectoryRoot: getProjectDirectoryRoot(state), + selectedSource, + selectedContentLoaded: location + ? !!getSettledSourceTextContent(state, location) + : undefined, + symbols: formatSymbols(symbols, maxResults), + symbolsLoading: !symbols, + query: getQuickOpenQuery(state), + searchType: getQuickOpenType(state), + tabUrls, + }; +} + +export default connect(mapStateToProps, { + selectSpecificLocation: actions.selectSpecificLocation, + setQuickOpenQuery: actions.setQuickOpenQuery, + highlightLineRange: actions.highlightLineRange, + clearHighlightLineRange: actions.clearHighlightLineRange, + closeQuickOpen: actions.closeQuickOpen, +})(QuickOpenModal); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js new file mode 100644 index 0000000000..368170bed7 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js @@ -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/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../../utils/connect"; +import { createSelector } from "reselect"; +import actions from "../../../actions"; + +import showContextMenu from "./BreakpointsContextMenu"; +import { CloseButton } from "../../shared/Button"; + +import { getSelectedText, makeBreakpointId } from "../../../utils/breakpoint"; +import { getSelectedLocation } from "../../../utils/selected-location"; +import { isLineBlackboxed } from "../../../utils/source"; + +import { + getBreakpointsList, + getSelectedFrame, + getSelectedSource, + getCurrentThread, + getContext, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, +} from "../../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); + +class Breakpoint extends PureComponent { + static get propTypes() { + return { + breakpoint: PropTypes.object.isRequired, + cx: PropTypes.object.isRequired, + disableBreakpoint: PropTypes.func.isRequired, + editor: PropTypes.object.isRequired, + enableBreakpoint: PropTypes.func.isRequired, + frame: PropTypes.object, + openConditionalPanel: PropTypes.func.isRequired, + removeBreakpoint: PropTypes.func.isRequired, + selectSpecificLocation: PropTypes.func.isRequired, + selectedSource: PropTypes.object, + source: PropTypes.object.isRequired, + blackboxedRangesForSource: PropTypes.array.isRequired, + checkSourceOnIgnoreList: PropTypes.func.isRequired, + }; + } + + onContextMenu = e => { + showContextMenu({ ...this.props, contextMenuEvent: e }); + }; + + get selectedLocation() { + const { breakpoint, selectedSource } = this.props; + return getSelectedLocation(breakpoint, selectedSource); + } + + onDoubleClick = () => { + const { breakpoint, openConditionalPanel } = this.props; + if (breakpoint.options.condition) { + openConditionalPanel(this.selectedLocation); + } else if (breakpoint.options.logValue) { + openConditionalPanel(this.selectedLocation, true); + } + }; + + selectBreakpoint = event => { + event.preventDefault(); + const { cx, selectSpecificLocation } = this.props; + selectSpecificLocation(cx, this.selectedLocation); + }; + + removeBreakpoint = event => { + const { cx, removeBreakpoint, breakpoint } = this.props; + event.stopPropagation(); + removeBreakpoint(cx, breakpoint); + }; + + handleBreakpointCheckbox = () => { + const { cx, breakpoint, enableBreakpoint, disableBreakpoint } = this.props; + if (breakpoint.disabled) { + enableBreakpoint(cx, breakpoint); + } else { + disableBreakpoint(cx, breakpoint); + } + }; + + isCurrentlyPausedAtBreakpoint() { + const { frame } = this.props; + if (!frame) { + return false; + } + + const bpId = makeBreakpointId(this.selectedLocation); + const frameId = makeBreakpointId(frame.selectedLocation); + return bpId == frameId; + } + + getBreakpointLocation() { + const { source } = this.props; + const { column, line } = this.selectedLocation; + + const isWasm = source?.isWasm; + const columnVal = column ? `:${column}` : ""; + const bpLocation = isWasm + ? `0x${line.toString(16).toUpperCase()}` + : `${line}${columnVal}`; + + return bpLocation; + } + + getBreakpointText() { + const { breakpoint, selectedSource } = this.props; + const { condition, logValue } = breakpoint.options; + return logValue || condition || getSelectedText(breakpoint, selectedSource); + } + + highlightText(text = "", editor) { + const node = document.createElement("div"); + editor.CodeMirror.runMode(text, "application/javascript", node); + return { __html: node.innerHTML }; + } + + render() { + const { + breakpoint, + editor, + blackboxedRangesForSource, + checkSourceOnIgnoreList, + } = this.props; + const text = this.getBreakpointText(); + const labelId = `${breakpoint.id}-label`; + + return ( + <div + className={classnames({ + breakpoint, + paused: this.isCurrentlyPausedAtBreakpoint(), + disabled: breakpoint.disabled, + "is-conditional": !!breakpoint.options.condition, + "is-log": !!breakpoint.options.logValue, + })} + onClick={this.selectBreakpoint} + onDoubleClick={this.onDoubleClick} + onContextMenu={this.onContextMenu} + > + <input + id={breakpoint.id} + type="checkbox" + className="breakpoint-checkbox" + checked={!breakpoint.disabled} + disabled={isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + )} + onChange={this.handleBreakpointCheckbox} + onClick={ev => ev.stopPropagation()} + aria-labelledby={labelId} + /> + <span + id={labelId} + className="breakpoint-label cm-s-mozilla devtools-monospace" + onClick={this.selectBreakpoint} + title={text} + > + <span dangerouslySetInnerHTML={this.highlightText(text, editor)} /> + </span> + <div className="breakpoint-line-close"> + <div className="breakpoint-line devtools-monospace"> + {this.getBreakpointLocation()} + </div> + <CloseButton + handleClick={e => this.removeBreakpoint(e)} + tooltip={L10N.getStr("breakpoints.removeBreakpointTooltip")} + /> + </div> + </div> + ); + } +} + +const getFormattedFrame = createSelector( + getSelectedSource, + getSelectedFrame, + (selectedSource, frame) => { + if (!frame) { + return null; + } + + return { + ...frame, + selectedLocation: getSelectedLocation(frame, selectedSource), + }; + } +); + +const mapStateToProps = (state, p) => ({ + cx: getContext(state), + breakpoints: getBreakpointsList(state), + frame: getFormattedFrame(state, getCurrentThread(state)), + checkSourceOnIgnoreList: source => + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, source), +}); + +export default connect(mapStateToProps, { + enableBreakpoint: actions.enableBreakpoint, + removeBreakpoint: actions.removeBreakpoint, + removeBreakpoints: actions.removeBreakpoints, + removeAllBreakpoints: actions.removeAllBreakpoints, + disableBreakpoint: actions.disableBreakpoint, + selectSpecificLocation: actions.selectSpecificLocation, + setBreakpointOptions: actions.setBreakpointOptions, + toggleAllBreakpoints: actions.toggleAllBreakpoints, + toggleBreakpoints: actions.toggleBreakpoints, + toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint, + openConditionalPanel: actions.openConditionalPanel, +})(Breakpoint); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js new file mode 100644 index 0000000000..c2c29cc258 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../../../utils/connect"; +import actions from "../../../actions"; + +import { + getTruncatedFileName, + getDisplayPath, + getSourceQueryString, + getFileURL, +} from "../../../utils/source"; +import { createLocation } from "../../../utils/location"; +import { + getBreakpointsForSource, + getContext, + getFirstSourceActorForGeneratedSource, +} from "../../../selectors"; + +import SourceIcon from "../../shared/SourceIcon"; + +import showContextMenu from "./BreakpointHeadingsContextMenu"; + +class BreakpointHeading extends PureComponent { + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + sources: PropTypes.array.isRequired, + source: PropTypes.object.isRequired, + firstSourceActor: PropTypes.object, + selectSource: PropTypes.func.isRequired, + }; + } + onContextMenu = e => { + showContextMenu({ ...this.props, contextMenuEvent: e }); + }; + + render() { + const { cx, sources, source, selectSource } = this.props; + + const path = getDisplayPath(source, sources); + const query = getSourceQueryString(source); + + return ( + <div + className="breakpoint-heading" + title={getFileURL(source, false)} + onClick={() => selectSource(cx, source)} + onContextMenu={this.onContextMenu} + > + <SourceIcon + // Breakpoints are displayed per source and may relate to many source actors. + // Arbitrarily pick the first source actor to compute the matching source icon + // The source actor is used to pick one specific source text content and guess + // the related framework icon. + location={createLocation({ + source, + sourceActor: this.props.firstSourceActor, + })} + modifier={icon => + ["file", "javascript"].includes(icon) ? null : icon + } + /> + <div className="filename"> + {getTruncatedFileName(source, query)} + {path && <span>{`../${path}/..`}</span>} + </div> + </div> + ); + } +} + +const mapStateToProps = (state, { source }) => ({ + cx: getContext(state), + breakpointsForSource: getBreakpointsForSource(state, source.id), + firstSourceActor: getFirstSourceActorForGeneratedSource(state, source.id), +}); + +export default connect(mapStateToProps, { + selectSource: actions.selectSource, + enableBreakpointsInSource: actions.enableBreakpointsInSource, + disableBreakpointsInSource: actions.disableBreakpointsInSource, + removeBreakpointsInSource: actions.removeBreakpointsInSource, +})(BreakpointHeading); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js new file mode 100644 index 0000000000..cdd3910b00 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js @@ -0,0 +1,77 @@ +/* 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 { buildMenu, showMenu } from "../../../context-menu/menu"; + +export default function showContextMenu(props) { + const { + cx, + source, + breakpointsForSource, + disableBreakpointsInSource, + enableBreakpointsInSource, + removeBreakpointsInSource, + contextMenuEvent, + } = props; + + contextMenuEvent.preventDefault(); + + const enableInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.enableInSource.label" + ); + const disableInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.disableInSource.label" + ); + const removeInSourceLabel = L10N.getStr( + "breakpointHeadingsMenuItem.removeInSource.label" + ); + const enableInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.enableInSource.accesskey" + ); + const disableInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.disableInSource.accesskey" + ); + const removeInSourceKey = L10N.getStr( + "breakpointHeadingsMenuItem.removeInSource.accesskey" + ); + + const disableInSourceItem = { + id: "node-menu-disable-in-source", + label: disableInSourceLabel, + accesskey: disableInSourceKey, + disabled: false, + click: () => disableBreakpointsInSource(cx, source), + }; + + const enableInSourceItem = { + id: "node-menu-enable-in-source", + label: enableInSourceLabel, + accesskey: enableInSourceKey, + disabled: false, + click: () => enableBreakpointsInSource(cx, source), + }; + + const removeInSourceItem = { + id: "node-menu-enable-in-source", + label: removeInSourceLabel, + accesskey: removeInSourceKey, + disabled: false, + click: () => removeBreakpointsInSource(cx, source), + }; + + const hideDisableInSourceItem = breakpointsForSource.every( + breakpoint => breakpoint.disabled + ); + const hideEnableInSourceItem = breakpointsForSource.every( + breakpoint => !breakpoint.disabled + ); + + const items = [ + { item: disableInSourceItem, hidden: () => hideDisableInSourceItem }, + { item: enableInSourceItem, hidden: () => hideEnableInSourceItem }, + { item: removeInSourceItem, hidden: () => false }, + ]; + + showMenu(contextMenuEvent, buildMenu(items)); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css new file mode 100644 index 0000000000..98075058b8 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css @@ -0,0 +1,249 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.breakpoints-pane > ._content { + overflow-x: auto; +} + +.breakpoints-exceptions-options *, +.breakpoints-list * { + user-select: none; +} + +.breakpoints-list { + padding: 4px 0; +} + +.breakpoints-list .breakpoint-heading { + text-overflow: ellipsis; + width: 100%; + font-size: 12px; + line-height: 16px; +} + +.breakpoint-heading:not(:first-child) { + margin-top: 2px; +} + +.breakpoints-list .breakpoint-heading .filename { + overflow: hidden; + text-overflow: ellipsis; +} + +.breakpoints-list .breakpoint-heading .filename span { + opacity: 0.7; + padding-left: 4px; +} + +.breakpoints-list .breakpoint-heading, +.breakpoints-list .breakpoint { + color: var(--theme-text-color-strong); + position: relative; + cursor: pointer; +} + +.breakpoints-list .breakpoint-heading, +.breakpoints-list .breakpoint, +.breakpoints-exceptions, +.breakpoints-exceptions-caught { + display: flex; + align-items: center; + overflow: hidden; + padding-top: 2px; + padding-bottom: 2px; + padding-inline-start: 16px; + padding-inline-end: 12px; +} + +.breakpoints-exceptions { + padding-bottom: 3px; + padding-top: 3px; + user-select: none; +} + +.breakpoints-exceptions-caught { + padding-bottom: 3px; + padding-top: 3px; + padding-inline-start: 36px; +} + +.breakpoints-exceptions-options { + padding-top: 4px; + padding-bottom: 4px; +} + +.xhr-breakpoints-pane .breakpoints-exceptions-options { + border-bottom: 1px solid var(--theme-splitter-color); +} + +.breakpoints-exceptions-options:not(.empty) { + border-bottom: 1px solid var(--theme-splitter-color); +} + +.breakpoints-exceptions input, +.breakpoints-exceptions-caught input { + padding-inline-start: 2px; + margin-top: 0px; + margin-bottom: 0px; + margin-inline-start: 0; + margin-inline-end: 2px; + vertical-align: text-bottom; +} + +.breakpoint-exceptions-label { + line-height: 14px; + padding-inline-end: 8px; + cursor: default; + overflow: hidden; + text-overflow: ellipsis; +} + +html[dir="rtl"] .breakpoints-list .breakpoint, +html[dir="rtl"] .breakpoints-list .breakpoint-heading, +html[dir="rtl"] .breakpoints-exceptions { + border-right: 4px solid transparent; +} + +html:not([dir="rtl"]) .breakpoints-list .breakpoint, +html:not([dir="rtl"]) .breakpoints-list .breakpoint-heading, +html:not([dir="rtl"]) .breakpoints-exceptions { + border-left: 4px solid transparent; +} + +html .breakpoints-list .breakpoint.is-conditional { + border-inline-start-color: var(--theme-graphs-yellow); +} + +html .breakpoints-list .breakpoint.is-log { + border-inline-start-color: var(--theme-graphs-purple); +} + +html .breakpoints-list .breakpoint.paused { + background-color: var(--theme-toolbar-background-alt); + border-color: var(--breakpoint-active-color); +} + +.breakpoints-list .breakpoint:hover { + background-color: var(--search-overlays-semitransparent); +} + +.breakpoint-line-close { + margin-inline-start: 4px; +} + +.breakpoints-list .breakpoint .breakpoint-line { + font-size: 11px; + color: var(--theme-comment); + min-width: 16px; + text-align: end; + padding-top: 1px; + padding-bottom: 1px; +} + +.breakpoints-list .breakpoint:hover .breakpoint-line, +.breakpoints-list .breakpoint-line-close:focus-within .breakpoint-line { + color: transparent; +} + +.breakpoints-list .breakpoint.paused:hover { + border-color: var(--breakpoint-active-color-hover); +} + +.breakpoints-list .breakpoint-label { + display: inline-block; + cursor: pointer; + flex-grow: 1; + text-overflow: ellipsis; + overflow: hidden; + font-size: 11px; +} + +.breakpoints-list .breakpoint-label span, +.breakpoint-line-close { + display: inline; + line-height: 14px; +} + +.breakpoint-checkbox { + margin-inline-start: 0px; + margin-top: 0px; + margin-bottom: 0px; + vertical-align: text-bottom; +} + +.breakpoint-label .location { + width: 100%; + display: inline-block; + overflow-x: hidden; + text-overflow: ellipsis; + padding: 1px 0; + vertical-align: bottom; +} + +.breakpoints-list .pause-indicator { + flex: 0 1 content; + order: 3; +} + +.breakpoint .close-btn { + position: absolute; + /* hide button outside of row until hovered or focused */ + top: -100px; +} + +[dir="ltr"] .breakpoint .close-btn { + right: 12px; +} + +[dir="rtl"] .breakpoint .close-btn { + left: 12px; +} + +/* Reveal the remove button on hover/focus */ +.breakpoint:hover .close-btn, +.breakpoint .close-btn:focus { + top: calc(50% - 8px); +} + +/* Hide the line number when revealing the remove button (since they're overlayed) */ +.breakpoint-line-close:focus-within .breakpoint-line, +.breakpoint:hover .breakpoint-line { + visibility: hidden; +} + +.CodeMirror.cm-s-mozilla-breakpoint { + cursor: pointer; +} + +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-lines { + padding: 0; +} + +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-sizer { + min-width: initial !important; +} + +.breakpoints-list .breakpoint .CodeMirror.cm-s-mozilla-breakpoint { + transition: opacity 0.15s linear; +} + +.breakpoints-list .breakpoint.disabled .CodeMirror.cm-s-mozilla-breakpoint { + opacity: 0.5; +} + +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-line span[role="presentation"] { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; +} + +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-code, +.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-scroll { + pointer-events: none; +} + +.CodeMirror.cm-s-mozilla-breakpoint { + padding-top: 1px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js new file mode 100644 index 0000000000..c2d8f3ff33 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js @@ -0,0 +1,365 @@ +/* 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 { buildMenu, showMenu } from "../../../context-menu/menu"; +import { getSelectedLocation } from "../../../utils/selected-location"; +import { isLineBlackboxed } from "../../../utils/source"; +import { features } from "../../../utils/prefs"; +import { formatKeyShortcut } from "../../../utils/text"; + +export default function showContextMenu(props) { + const { + cx, + breakpoint, + breakpoints, + selectedSource, + removeBreakpoint, + removeBreakpoints, + removeAllBreakpoints, + toggleBreakpoints, + toggleAllBreakpoints, + toggleDisabledBreakpoint, + selectSpecificLocation, + setBreakpointOptions, + openConditionalPanel, + contextMenuEvent, + blackboxedRangesForSource, + checkSourceOnIgnoreList, + } = props; + + contextMenuEvent.preventDefault(); + + const deleteSelfLabel = L10N.getStr("breakpointMenuItem.deleteSelf2.label"); + const deleteAllLabel = L10N.getStr("breakpointMenuItem.deleteAll2.label"); + const deleteOthersLabel = L10N.getStr( + "breakpointMenuItem.deleteOthers2.label" + ); + const enableSelfLabel = L10N.getStr("breakpointMenuItem.enableSelf2.label"); + const enableAllLabel = L10N.getStr("breakpointMenuItem.enableAll2.label"); + const enableOthersLabel = L10N.getStr( + "breakpointMenuItem.enableOthers2.label" + ); + const disableSelfLabel = L10N.getStr("breakpointMenuItem.disableSelf2.label"); + const disableAllLabel = L10N.getStr("breakpointMenuItem.disableAll2.label"); + const disableOthersLabel = L10N.getStr( + "breakpointMenuItem.disableOthers2.label" + ); + const enableDbgStatementLabel = L10N.getStr( + "breakpointMenuItem.enabledbg.label" + ); + const disableDbgStatementLabel = L10N.getStr( + "breakpointMenuItem.disabledbg.label" + ); + const removeConditionLabel = L10N.getStr( + "breakpointMenuItem.removeCondition2.label" + ); + const addConditionLabel = L10N.getStr( + "breakpointMenuItem.addCondition2.label" + ); + const editConditionLabel = L10N.getStr( + "breakpointMenuItem.editCondition2.label" + ); + + const deleteSelfKey = L10N.getStr("breakpointMenuItem.deleteSelf2.accesskey"); + const deleteAllKey = L10N.getStr("breakpointMenuItem.deleteAll2.accesskey"); + const deleteOthersKey = L10N.getStr( + "breakpointMenuItem.deleteOthers2.accesskey" + ); + const enableSelfKey = L10N.getStr("breakpointMenuItem.enableSelf2.accesskey"); + const enableAllKey = L10N.getStr("breakpointMenuItem.enableAll2.accesskey"); + const enableOthersKey = L10N.getStr( + "breakpointMenuItem.enableOthers2.accesskey" + ); + const disableSelfKey = L10N.getStr( + "breakpointMenuItem.disableSelf2.accesskey" + ); + const disableAllKey = L10N.getStr("breakpointMenuItem.disableAll2.accesskey"); + const disableOthersKey = L10N.getStr( + "breakpointMenuItem.disableOthers2.accesskey" + ); + const removeConditionKey = L10N.getStr( + "breakpointMenuItem.removeCondition2.accesskey" + ); + const editConditionKey = L10N.getStr( + "breakpointMenuItem.editCondition2.accesskey" + ); + const addConditionKey = L10N.getStr( + "breakpointMenuItem.addCondition2.accesskey" + ); + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const otherBreakpoints = breakpoints.filter(b => b.id !== breakpoint.id); + const enabledBreakpoints = breakpoints.filter(b => !b.disabled); + const disabledBreakpoints = breakpoints.filter(b => b.disabled); + const otherEnabledBreakpoints = breakpoints.filter( + b => !b.disabled && b.id !== breakpoint.id + ); + const otherDisabledBreakpoints = breakpoints.filter( + b => b.disabled && b.id !== breakpoint.id + ); + + const deleteSelfItem = { + id: "node-menu-delete-self", + label: deleteSelfLabel, + accesskey: deleteSelfKey, + disabled: false, + click: () => { + removeBreakpoint(cx, breakpoint); + }, + }; + + const deleteAllItem = { + id: "node-menu-delete-all", + label: deleteAllLabel, + accesskey: deleteAllKey, + disabled: false, + click: () => removeAllBreakpoints(cx), + }; + + const deleteOthersItem = { + id: "node-menu-delete-other", + label: deleteOthersLabel, + accesskey: deleteOthersKey, + disabled: false, + click: () => removeBreakpoints(cx, otherBreakpoints), + }; + + const enableSelfItem = { + id: "node-menu-enable-self", + label: enableSelfLabel, + accesskey: enableSelfKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => { + toggleDisabledBreakpoint(cx, breakpoint); + }, + }; + + const enableAllItem = { + id: "node-menu-enable-all", + label: enableAllLabel, + accesskey: enableAllKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => toggleAllBreakpoints(cx, false), + }; + + const enableOthersItem = { + id: "node-menu-enable-others", + label: enableOthersLabel, + accesskey: enableOthersKey, + disabled: isLineBlackboxed( + blackboxedRangesForSource, + breakpoint.location.line, + checkSourceOnIgnoreList(breakpoint.location.source) + ), + click: () => toggleBreakpoints(cx, false, otherDisabledBreakpoints), + }; + + const disableSelfItem = { + id: "node-menu-disable-self", + label: disableSelfLabel, + accesskey: disableSelfKey, + disabled: false, + click: () => { + toggleDisabledBreakpoint(cx, breakpoint); + }, + }; + + const disableAllItem = { + id: "node-menu-disable-all", + label: disableAllLabel, + accesskey: disableAllKey, + disabled: false, + click: () => toggleAllBreakpoints(cx, true), + }; + + const disableOthersItem = { + id: "node-menu-disable-others", + label: disableOthersLabel, + accesskey: disableOthersKey, + click: () => toggleBreakpoints(cx, true, otherEnabledBreakpoints), + }; + + const enableDbgStatementItem = { + id: "node-menu-enable-dbgStatement", + label: enableDbgStatementLabel, + disabled: false, + click: () => + setBreakpointOptions(cx, selectedLocation, { + ...breakpoint.options, + condition: null, + }), + }; + + const disableDbgStatementItem = { + id: "node-menu-disable-dbgStatement", + label: disableDbgStatementLabel, + disabled: false, + click: () => + setBreakpointOptions(cx, selectedLocation, { + ...breakpoint.options, + condition: "false", + }), + }; + + const removeConditionItem = { + id: "node-menu-remove-condition", + label: removeConditionLabel, + accesskey: removeConditionKey, + disabled: false, + click: () => + setBreakpointOptions(cx, selectedLocation, { + ...breakpoint.options, + condition: null, + }), + }; + + const addConditionItem = { + id: "node-menu-add-condition", + label: addConditionLabel, + accesskey: addConditionKey, + click: () => { + selectSpecificLocation(cx, selectedLocation); + openConditionalPanel(selectedLocation); + }, + accelerator: formatKeyShortcut( + L10N.getStr("toggleCondPanel.breakpoint.key") + ), + }; + + const editConditionItem = { + id: "node-menu-edit-condition", + label: editConditionLabel, + accesskey: editConditionKey, + click: () => { + selectSpecificLocation(cx, selectedLocation); + openConditionalPanel(selectedLocation); + }, + accelerator: formatKeyShortcut( + L10N.getStr("toggleCondPanel.breakpoint.key") + ), + }; + + const addLogPointItem = { + id: "node-menu-add-log-point", + label: L10N.getStr("editor.addLogPoint"), + accesskey: L10N.getStr("editor.addLogPoint.accesskey"), + disabled: false, + click: () => { + selectSpecificLocation(cx, selectedLocation); + openConditionalPanel(selectedLocation, true); + }, + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), + }; + + const editLogPointItem = { + id: "node-menu-edit-log-point", + label: L10N.getStr("editor.editLogPoint"), + accesskey: L10N.getStr("editor.editLogPoint.accesskey"), + disabled: false, + click: () => { + selectSpecificLocation(cx, selectedLocation); + openConditionalPanel(selectedLocation, true); + }, + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), + }; + + const removeLogPointItem = { + id: "node-menu-remove-log", + label: L10N.getStr("editor.removeLogPoint.label"), + accesskey: L10N.getStr("editor.removeLogPoint.accesskey"), + disabled: false, + click: () => + setBreakpointOptions(cx, selectedLocation, { + ...breakpoint.options, + logValue: null, + }), + }; + + const logPointItem = breakpoint.options.logValue + ? editLogPointItem + : addLogPointItem; + + const hideEnableSelfItem = !breakpoint.disabled; + const hideEnableAllItem = disabledBreakpoints.length === 0; + const hideEnableOthersItem = otherDisabledBreakpoints.length === 0; + const hideDisableAllItem = enabledBreakpoints.length === 0; + const hideDisableOthersItem = otherEnabledBreakpoints.length === 0; + const hideDisableSelfItem = breakpoint.disabled; + const hideEnableDbgStatementItem = + !breakpoint.originalText.startsWith("debugger") || + (breakpoint.originalText.startsWith("debugger") && + breakpoint.options.condition !== "false"); + const hideDisableDbgStatementItem = + !breakpoint.originalText.startsWith("debugger") || + (breakpoint.originalText.startsWith("debugger") && + breakpoint.options.condition === "false"); + const items = [ + { item: enableSelfItem, hidden: () => hideEnableSelfItem }, + { item: enableAllItem, hidden: () => hideEnableAllItem }, + { item: enableOthersItem, hidden: () => hideEnableOthersItem }, + { + item: { type: "separator" }, + hidden: () => + hideEnableSelfItem && hideEnableAllItem && hideEnableOthersItem, + }, + { item: deleteSelfItem }, + { item: deleteAllItem }, + { item: deleteOthersItem, hidden: () => breakpoints.length === 1 }, + { + item: { type: "separator" }, + hidden: () => + hideDisableSelfItem && hideDisableAllItem && hideDisableOthersItem, + }, + + { item: disableSelfItem, hidden: () => hideDisableSelfItem }, + { item: disableAllItem, hidden: () => hideDisableAllItem }, + { item: disableOthersItem, hidden: () => hideDisableOthersItem }, + { + item: { type: "separator" }, + }, + { + item: enableDbgStatementItem, + hidden: () => hideEnableDbgStatementItem, + }, + { + item: disableDbgStatementItem, + hidden: () => hideDisableDbgStatementItem, + }, + { + item: { type: "separator" }, + hidden: () => hideDisableDbgStatementItem && hideEnableDbgStatementItem, + }, + { + item: addConditionItem, + hidden: () => breakpoint.options.condition, + }, + { + item: editConditionItem, + hidden: () => !breakpoint.options.condition, + }, + { + item: removeConditionItem, + hidden: () => !breakpoint.options.condition, + }, + { + item: logPointItem, + hidden: () => !features.logPoints, + }, + { + item: removeLogPointItem, + hidden: () => !features.logPoints || !breakpoint.options.logValue, + }, + ]; + + showMenu(contextMenuEvent, buildMenu(items)); + return null; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js new file mode 100644 index 0000000000..0b7d70fc62 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import PropTypes from "prop-types"; + +export default function ExceptionOption({ + className, + isChecked = false, + label, + onChange, +}) { + return ( + <div className={className} onClick={onChange}> + <input + type="checkbox" + checked={isChecked ? "checked" : ""} + onChange={e => e.stopPropagation() && onChange()} + /> + <div className="breakpoint-exceptions-label">{label}</div> + </div> + ); +} + +ExceptionOption.propTypes = { + className: PropTypes.string.isRequired, + isChecked: PropTypes.bool.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js new file mode 100644 index 0000000000..3a3cc19afa --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js @@ -0,0 +1,152 @@ +/* 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 ExceptionOption from "./ExceptionOption"; + +import Breakpoint from "./Breakpoint"; +import BreakpointHeading from "./BreakpointHeading"; + +import actions from "../../../actions"; +import { getSelectedLocation } from "../../../utils/selected-location"; +import { createHeadlessEditor } from "../../../utils/editor/create-editor"; + +import { makeBreakpointId } from "../../../utils/breakpoint"; + +import { + getSelectedSource, + getBreakpointSources, + getBlackBoxRanges, +} from "../../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./Breakpoints.css"; + +class Breakpoints extends Component { + static get propTypes() { + return { + breakpointSources: PropTypes.array.isRequired, + pauseOnExceptions: PropTypes.func.isRequired, + selectedSource: PropTypes.object, + shouldPauseOnCaughtExceptions: PropTypes.bool.isRequired, + shouldPauseOnExceptions: PropTypes.bool.isRequired, + blackboxedRanges: PropTypes.array.isRequired, + }; + } + + componentWillUnmount() { + this.removeEditor(); + } + + getEditor() { + if (!this.headlessEditor) { + this.headlessEditor = createHeadlessEditor(); + } + return this.headlessEditor; + } + + removeEditor() { + if (!this.headlessEditor) { + return; + } + this.headlessEditor.destroy(); + this.headlessEditor = null; + } + + renderExceptionsOptions() { + const { + breakpointSources, + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + pauseOnExceptions, + } = this.props; + + const isEmpty = !breakpointSources.length; + + return ( + <div + className={classnames("breakpoints-exceptions-options", { + empty: isEmpty, + })} + > + <ExceptionOption + className="breakpoints-exceptions" + label={L10N.getStr("pauseOnExceptionsItem2")} + isChecked={shouldPauseOnExceptions} + onChange={() => pauseOnExceptions(!shouldPauseOnExceptions, false)} + /> + + {shouldPauseOnExceptions && ( + <ExceptionOption + className="breakpoints-exceptions-caught" + label={L10N.getStr("pauseOnCaughtExceptionsItem")} + isChecked={shouldPauseOnCaughtExceptions} + onChange={() => + pauseOnExceptions(true, !shouldPauseOnCaughtExceptions) + } + /> + )} + </div> + ); + } + + renderBreakpoints() { + const { breakpointSources, selectedSource, blackboxedRanges } = this.props; + if (!breakpointSources.length) { + return null; + } + + const editor = this.getEditor(); + const sources = breakpointSources.map(({ source }) => source); + + return ( + <div className="pane breakpoints-list"> + {breakpointSources.map(({ source, breakpoints }) => { + return [ + <BreakpointHeading + key={source.id} + source={source} + sources={sources} + />, + breakpoints.map(breakpoint => ( + <Breakpoint + breakpoint={breakpoint} + source={source} + blackboxedRangesForSource={blackboxedRanges[source.url]} + selectedSource={selectedSource} + editor={editor} + key={makeBreakpointId( + getSelectedLocation(breakpoint, selectedSource) + )} + /> + )), + ]; + })} + </div> + ); + } + + render() { + return ( + <div className="pane"> + {this.renderExceptionsOptions()} + {this.renderBreakpoints()} + </div> + ); + } +} + +const mapStateToProps = state => ({ + breakpointSources: getBreakpointSources(state), + selectedSource: getSelectedSource(state), + blackboxedRanges: getBlackBoxRanges(state), +}); + +export default connect(mapStateToProps, { + pauseOnExceptions: actions.pauseOnExceptions, +})(Breakpoints); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build new file mode 100644 index 0000000000..2b075efdd4 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/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( + "Breakpoint.js", + "BreakpointHeading.js", + "BreakpointHeadingsContextMenu.js", + "BreakpointsContextMenu.js", + "ExceptionOption.js", + "index.js", +) diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js new file mode 100644 index 0000000000..a28f9b06d5 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js @@ -0,0 +1,104 @@ +/* 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 { shallow } from "enzyme"; + +import Breakpoint from "../Breakpoint"; +import { + createSourceObject, + createOriginalSourceObject, +} from "../../../../utils/test-head"; + +describe("Breakpoint", () => { + it("simple", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("disabled", () => { + const { component } = render({}, makeBreakpoint({ disabled: true })); + expect(component).toMatchSnapshot(); + }); + + it("paused at a generatedLocation", () => { + const { component } = render({ + frame: { selectedLocation: generatedLocation }, + }); + expect(component).toMatchSnapshot(); + }); + + it("paused at an original location", () => { + const source = createSourceObject("foo"); + const origSource = createOriginalSourceObject(source); + + const { component } = render( + { + selectedSource: origSource, + frame: { selectedLocation: location }, + }, + { location, options: {} } + ); + + expect(component).toMatchSnapshot(); + }); + + it("paused at a different", () => { + const { component } = render({ + frame: { selectedLocation: { ...generatedLocation, line: 14 } }, + }); + expect(component).toMatchSnapshot(); + }); +}); + +const generatedLocation = { source: { id: "foo" }, line: 53, column: 73 }; +const location = { source: { id: "foo/original" }, line: 5, column: 7 }; + +function render(overrides = {}, breakpointOverrides = {}) { + const props = generateDefaults(overrides, breakpointOverrides); + const component = shallow(<Breakpoint.WrappedComponent {...props} />); + const defaultState = component.state(); + const instance = component.instance(); + + return { component, props, defaultState, instance }; +} + +function makeBreakpoint(overrides = {}) { + return { + location, + generatedLocation, + disabled: false, + options: {}, + ...overrides, + id: 1, + }; +} + +function generateDefaults(overrides = {}, breakpointOverrides = {}) { + const source = createSourceObject("foo"); + const breakpoint = makeBreakpoint(breakpointOverrides); + const selectedSource = createSourceObject("foo"); + return { + cx: {}, + disableBreakpoint: () => {}, + enableBreakpoint: () => {}, + openConditionalPanel: () => {}, + removeBreakpoint: () => {}, + selectSpecificLocation: () => {}, + blackboxedRangesForSource: [], + checkSourceOnIgnoreList: () => {}, + source, + breakpoint, + selectedSource, + frame: null, + editor: { + CodeMirror: { + runMode: function () { + return ""; + }, + }, + }, + ...overrides, + }; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js new file mode 100644 index 0000000000..87194f762d --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js @@ -0,0 +1,134 @@ +/* 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 { shallow } from "enzyme"; + +import BreakpointsContextMenu from "../BreakpointsContextMenu"; +import { buildMenu } from "../../../../context-menu/menu"; + +import { + makeMockBreakpoint, + makeMockSource, + mockcx, +} from "../../../../utils/test-mockup"; + +jest.mock("../../../../context-menu/menu"); + +function render(disabled = false) { + const props = generateDefaults(disabled); + const component = shallow(<BreakpointsContextMenu {...props} />); + return { component, props }; +} + +function generateDefaults(disabled) { + const source = makeMockSource( + "https://example.com/main.js", + "source-https://example.com/main.js" + ); + const breakpoints = [ + { + ...makeMockBreakpoint(source, 1), + id: "https://example.com/main.js:1:", + disabled, + options: { + condition: "", + logValue: "", + hidden: false, + }, + }, + { + ...makeMockBreakpoint(source, 2), + id: "https://example.com/main.js:2:", + disabled, + options: { + hidden: false, + }, + }, + { + ...makeMockBreakpoint(source, 3), + id: "https://example.com/main.js:3:", + disabled, + }, + ]; + + const props = { + cx: mockcx, + breakpoints, + breakpoint: breakpoints[0], + removeBreakpoint: jest.fn(), + removeBreakpoints: jest.fn(), + removeAllBreakpoints: jest.fn(), + toggleBreakpoints: jest.fn(), + toggleAllBreakpoints: jest.fn(), + toggleDisabledBreakpoint: jest.fn(), + selectSpecificLocation: jest.fn(), + setBreakpointCondition: jest.fn(), + openConditionalPanel: jest.fn(), + contextMenuEvent: { preventDefault: jest.fn() }, + selectedSource: makeMockSource(), + setBreakpointOptions: jest.fn(), + checkSourceOnIgnoreList: jest.fn(), + }; + return props; +} + +describe("BreakpointsContextMenu", () => { + afterEach(() => { + buildMenu.mockReset(); + }); + + describe("context menu actions affecting other breakpoints", () => { + it("'remove others' calls removeBreakpoints with proper arguments", () => { + const { props } = render(); + const menuItems = buildMenu.mock.calls[0][0]; + const deleteOthers = menuItems.find( + item => item.item.id === "node-menu-delete-other" + ); + deleteOthers.item.click(); + + expect(props.removeBreakpoints).toHaveBeenCalled(); + + const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]]; + expect(props.removeBreakpoints.mock.calls[0][1]).toEqual( + otherBreakpoints + ); + }); + + it("'enable others' calls toggleBreakpoints with proper arguments", () => { + const { props } = render(true); + const menuItems = buildMenu.mock.calls[0][0]; + const enableOthers = menuItems.find( + item => item.item.id === "node-menu-enable-others" + ); + enableOthers.item.click(); + + expect(props.toggleBreakpoints).toHaveBeenCalled(); + + expect(props.toggleBreakpoints.mock.calls[0][1]).toBe(false); + + const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]]; + expect(props.toggleBreakpoints.mock.calls[0][2]).toEqual( + otherBreakpoints + ); + }); + + it("'disable others' calls toggleBreakpoints with proper arguments", () => { + const { props } = render(); + const menuItems = buildMenu.mock.calls[0][0]; + const disableOthers = menuItems.find( + item => item.item.id === "node-menu-disable-others" + ); + disableOthers.item.click(); + + expect(props.toggleBreakpoints).toHaveBeenCalled(); + expect(props.toggleBreakpoints.mock.calls[0][1]).toBe(true); + + const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]]; + expect(props.toggleBreakpoints.mock.calls[0][2]).toEqual( + otherBreakpoints + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js new file mode 100644 index 0000000000..238551cc10 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js @@ -0,0 +1,22 @@ +/* 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 { shallow } from "enzyme"; + +import ExceptionOption from "../ExceptionOption"; + +describe("ExceptionOption renders", () => { + it("with values", () => { + const component = shallow( + <ExceptionOption + label="testLabel" + isChecked={true} + onChange={() => null} + className="testClassName" + /> + ); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap new file mode 100644 index 0000000000..45f44e42f7 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap @@ -0,0 +1,231 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Breakpoint disabled 1`] = ` +<div + className="breakpoint disabled" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={false} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 53:73 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; + +exports[`Breakpoint paused at a different 1`] = ` +<div + className="breakpoint" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={true} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 53:73 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; + +exports[`Breakpoint paused at a generatedLocation 1`] = ` +<div + className="breakpoint paused" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={true} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 53:73 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; + +exports[`Breakpoint paused at an original location 1`] = ` +<div + className="breakpoint paused" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={true} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 5:7 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; + +exports[`Breakpoint simple 1`] = ` +<div + className="breakpoint" + onClick={[Function]} + onContextMenu={[Function]} + onDoubleClick={[Function]} +> + <input + aria-labelledby="1-label" + checked={true} + className="breakpoint-checkbox" + disabled={true} + id={1} + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <span + className="breakpoint-label cm-s-mozilla devtools-monospace" + id="1-label" + onClick={[Function]} + > + <span + dangerouslySetInnerHTML={ + Object { + "__html": "", + } + } + /> + </span> + <div + className="breakpoint-line-close" + > + <div + className="breakpoint-line devtools-monospace" + > + 53:73 + </div> + <CloseButton + handleClick={[Function]} + tooltip="Remove breakpoint" + /> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap new file mode 100644 index 0000000000..19b5937676 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExceptionOption renders with values 1`] = ` +<div + className="testClassName" + onClick={[Function]} +> + <input + checked="checked" + onChange={[Function]} + type="checkbox" + /> + <div + className="breakpoint-exceptions-label" + > + testLabel + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css new file mode 100644 index 0000000000..68bd0bfcdd --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.command-bar { + flex: 0 0 29px; + border-bottom: 1px solid var(--theme-splitter-color); + display: flex; + overflow: hidden; + z-index: 1; + background-color: var(--theme-toolbar-background); +} + +html[dir="rtl"] .command-bar { + border-right: 1px solid var(--theme-splitter-color); +} + +.command-bar .filler { + flex-grow: 1; +} + +.command-bar .step-position { + color: var(--theme-text-color-inactive); + padding-top: 8px; + margin-inline-end: 4px; +} + +.command-bar .divider { + width: 1px; + background: var(--theme-splitter-color); + height: 10px; + margin: 11px 6px 0 6px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js new file mode 100644 index 0000000000..a8f4173924 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js @@ -0,0 +1,433 @@ +/* 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 { features, prefs } from "../../utils/prefs"; +import { + getIsWaitingOnBreak, + getSkipPausing, + getCurrentThread, + isTopFrameSelected, + getThreadContext, + getIsCurrentThreadPaused, + getIsThreadCurrentlyTracing, + getJavascriptTracingLogMethod, +} from "../../selectors"; +import { formatKeyShortcut } from "../../utils/text"; +import actions from "../../actions"; +import { debugBtn } from "../shared/Button/CommandBarButton"; +import AccessibleImage from "../shared/AccessibleImage"; +import "./CommandBar.css"; +import { showMenu } from "../../context-menu/menu"; + +const classnames = require("devtools/client/shared/classnames.js"); +const MenuButton = require("devtools/client/shared/components/menu/MenuButton"); +const MenuItem = require("devtools/client/shared/components/menu/MenuItem"); +const MenuList = require("devtools/client/shared/components/menu/MenuList"); + +const isMacOS = Services.appinfo.OS === "Darwin"; + +// NOTE: the "resume" command will call either the resume or breakOnNext action +// depending on whether or not the debugger is paused or running +const COMMANDS = ["resume", "stepOver", "stepIn", "stepOut"]; + +const KEYS = { + WINNT: { + resume: "F8", + stepOver: "F10", + stepIn: "F11", + stepOut: "Shift+F11", + }, + Darwin: { + resume: "Cmd+\\", + stepOver: "Cmd+'", + stepIn: "Cmd+;", + stepOut: "Cmd+Shift+:", + stepOutDisplay: "Cmd+Shift+;", + }, + Linux: { + resume: "F8", + stepOver: "F10", + stepIn: "F11", + stepOut: "Shift+F11", + }, +}; + +const LOG_METHODS = { + CONSOLE: "console", + STDOUT: "stdout", +}; + +function getKey(action) { + return getKeyForOS(Services.appinfo.OS, action); +} + +function getKeyForOS(os, action) { + const osActions = KEYS[os] || KEYS.Linux; + return osActions[action]; +} + +function formatKey(action) { + const key = getKey(`${action}Display`) || getKey(action); + if (isMacOS) { + const winKey = + getKeyForOS("WINNT", `${action}Display`) || getKeyForOS("WINNT", action); + // display both Windows type and Mac specific keys + return formatKeyShortcut([key, winKey].join(" ")); + } + return formatKeyShortcut(key); +} + +class CommandBar extends Component { + constructor() { + super(); + + this.state = {}; + } + static get propTypes() { + return { + breakOnNext: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + horizontal: PropTypes.bool.isRequired, + isPaused: PropTypes.bool.isRequired, + isTracingEnabled: PropTypes.bool.isRequired, + isWaitingOnBreak: PropTypes.bool.isRequired, + javascriptEnabled: PropTypes.bool.isRequired, + trace: PropTypes.func.isRequired, + resume: PropTypes.func.isRequired, + skipPausing: PropTypes.bool.isRequired, + stepIn: PropTypes.func.isRequired, + stepOut: PropTypes.func.isRequired, + stepOver: PropTypes.func.isRequired, + toggleEditorWrapping: PropTypes.func.isRequired, + toggleInlinePreview: PropTypes.func.isRequired, + toggleJavaScriptEnabled: PropTypes.func.isRequired, + toggleSkipPausing: PropTypes.any.isRequired, + toggleSourceMapsEnabled: PropTypes.func.isRequired, + topFrameSelected: PropTypes.bool.isRequired, + toggleTracing: PropTypes.func.isRequired, + logMethod: PropTypes.string.isRequired, + setJavascriptTracingLogMethod: PropTypes.func.isRequired, + setHideOrShowIgnoredSources: PropTypes.func.isRequired, + toggleSourceMapIgnoreList: PropTypes.func.isRequired, + }; + } + + componentWillUnmount() { + const { shortcuts } = this.context; + + COMMANDS.forEach(action => shortcuts.off(getKey(action))); + + if (isMacOS) { + COMMANDS.forEach(action => shortcuts.off(getKeyForOS("WINNT", action))); + } + } + + componentDidMount() { + const { shortcuts } = this.context; + + COMMANDS.forEach(action => + shortcuts.on(getKey(action), e => this.handleEvent(e, action)) + ); + + if (isMacOS) { + // The Mac supports both the Windows Function keys + // as well as the Mac non-Function keys + COMMANDS.forEach(action => + shortcuts.on(getKeyForOS("WINNT", action), e => + this.handleEvent(e, action) + ) + ); + } + } + + handleEvent(e, action) { + const { cx } = this.props; + e.preventDefault(); + e.stopPropagation(); + if (action === "resume") { + this.props.isPaused ? this.props.resume() : this.props.breakOnNext(cx); + } else { + this.props[action](cx); + } + } + + renderStepButtons() { + const { isPaused, topFrameSelected } = this.props; + const className = isPaused ? "active" : "disabled"; + const isDisabled = !isPaused; + + return [ + this.renderTraceButton(), + this.renderPauseButton(), + debugBtn( + () => this.props.stepOver(), + "stepOver", + className, + L10N.getFormatStr("stepOverTooltip", formatKey("stepOver")), + isDisabled + ), + debugBtn( + () => this.props.stepIn(), + "stepIn", + className, + L10N.getFormatStr("stepInTooltip", formatKey("stepIn")), + isDisabled || !topFrameSelected + ), + debugBtn( + () => this.props.stepOut(), + "stepOut", + className, + L10N.getFormatStr("stepOutTooltip", formatKey("stepOut")), + isDisabled + ), + ]; + } + + resume() { + this.props.resume(); + } + + renderTraceButton() { + if (!features.javascriptTracing) { + return null; + } + // Display a button which: + // - on left click, would toggle on/off javascript tracing + // - on right click, would display a context menu allowing to choose the loggin output (console or stdout) + return ( + <button + className={`devtools-button command-bar-button debugger-trace-menu-button ${ + this.props.isTracingEnabled ? "active" : "" + }`} + title={ + this.props.isTracingEnabled + ? L10N.getStr("stopTraceButtonTooltip") + : L10N.getFormatStr("startTraceButtonTooltip", this.props.logMethod) + } + onClick={event => { + this.props.toggleTracing(this.props.logMethod); + }} + onContextMenu={event => { + event.preventDefault(); + event.stopPropagation(); + + // Avoid showing the menu to avoid having to support chaging tracing config "live" + if (this.props.isTracingEnabled) { + return; + } + + const items = [ + { + id: "debugger-trace-menu-item-console", + label: L10N.getStr("traceInWebConsole"), + checked: this.props.logMethod == LOG_METHODS.CONSOLE, + click: () => { + this.props.setJavascriptTracingLogMethod(LOG_METHODS.CONSOLE); + }, + }, + { + id: "debugger-trace-menu-item-stdout", + label: L10N.getStr("traceInStdout"), + checked: this.props.logMethod == LOG_METHODS.STDOUT, + click: () => { + this.props.setJavascriptTracingLogMethod(LOG_METHODS.STDOUT); + }, + }, + ]; + showMenu(event, items); + }} + /> + ); + } + + renderPauseButton() { + const { cx, breakOnNext, isWaitingOnBreak } = this.props; + + if (this.props.isPaused) { + return debugBtn( + () => this.resume(), + "resume", + "active", + L10N.getFormatStr("resumeButtonTooltip", formatKey("resume")) + ); + } + + if (isWaitingOnBreak) { + return debugBtn( + null, + "pause", + "disabled", + L10N.getStr("pausePendingButtonTooltip"), + true + ); + } + + return debugBtn( + () => breakOnNext(cx), + "pause", + "active", + L10N.getFormatStr("pauseButtonTooltip", formatKey("resume")) + ); + } + + renderSkipPausingButton() { + const { skipPausing, toggleSkipPausing } = this.props; + + return ( + <button + className={classnames( + "command-bar-button", + "command-bar-skip-pausing", + { + active: skipPausing, + } + )} + title={ + skipPausing + ? L10N.getStr("undoSkipPausingTooltip.label") + : L10N.getStr("skipPausingTooltip.label") + } + onClick={toggleSkipPausing} + > + <AccessibleImage + className={skipPausing ? "enable-pausing" : "disable-pausing"} + /> + </button> + ); + } + + renderSettingsButton() { + const { toolboxDoc } = this.context; + + return ( + <MenuButton + menuId="debugger-settings-menu-button" + toolboxDoc={toolboxDoc} + className="devtools-button command-bar-button debugger-settings-menu-button" + title={L10N.getStr("settings.button.label")} + > + {() => this.renderSettingsMenuItems()} + </MenuButton> + ); + } + + renderSettingsMenuItems() { + return ( + <MenuList id="debugger-settings-menu-list"> + <MenuItem + key="debugger-settings-menu-item-disable-javascript" + className="menu-item debugger-settings-menu-item-disable-javascript" + checked={!this.props.javascriptEnabled} + label={L10N.getStr("settings.disableJavaScript.label")} + tooltip={L10N.getStr("settings.disableJavaScript.tooltip")} + onClick={() => { + this.props.toggleJavaScriptEnabled(!this.props.javascriptEnabled); + }} + /> + <MenuItem + key="debugger-settings-menu-item-disable-inline-previews" + checked={features.inlinePreview} + label={L10N.getStr("inlinePreview.toggle.label")} + tooltip={L10N.getStr("inlinePreview.toggle.tooltip")} + onClick={() => + this.props.toggleInlinePreview(!features.inlinePreview) + } + /> + <MenuItem + key="debugger-settings-menu-item-disable-wrap-lines" + checked={prefs.editorWrapping} + label={L10N.getStr("editorWrapping.toggle.label")} + tooltip={L10N.getStr("editorWrapping.toggle.tooltip")} + onClick={() => this.props.toggleEditorWrapping(!prefs.editorWrapping)} + /> + <MenuItem + key="debugger-settings-menu-item-disable-sourcemaps" + checked={prefs.clientSourceMapsEnabled} + label={L10N.getStr("settings.toggleSourceMaps.label")} + tooltip={L10N.getStr("settings.toggleSourceMaps.tooltip")} + onClick={() => + this.props.toggleSourceMapsEnabled(!prefs.clientSourceMapsEnabled) + } + /> + <MenuItem + key="debugger-settings-menu-item-hide-ignored-sources" + className="menu-item debugger-settings-menu-item-hide-ignored-sources" + checked={prefs.hideIgnoredSources} + label={L10N.getStr("settings.hideIgnoredSources.label")} + tooltip={L10N.getStr("settings.hideIgnoredSources.tooltip")} + onClick={() => + this.props.setHideOrShowIgnoredSources(!prefs.hideIgnoredSources) + } + /> + <MenuItem + key="debugger-settings-menu-item-enable-sourcemap-ignore-list" + className="menu-item debugger-settings-menu-item-enable-sourcemap-ignore-list" + checked={prefs.sourceMapIgnoreListEnabled} + label={L10N.getStr("settings.enableSourceMapIgnoreList.label")} + tooltip={L10N.getStr("settings.enableSourceMapIgnoreList.tooltip")} + onClick={() => + this.props.toggleSourceMapIgnoreList( + this.props.cx, + !prefs.sourceMapIgnoreListEnabled + ) + } + /> + </MenuList> + ); + } + + render() { + return ( + <div + className={classnames("command-bar", { + vertical: !this.props.horizontal, + })} + > + {this.renderStepButtons()} + <div className="filler" /> + {this.renderSkipPausingButton()} + <div className="devtools-separator" /> + {this.renderSettingsButton()} + </div> + ); + } +} + +CommandBar.contextTypes = { + shortcuts: PropTypes.object, + toolboxDoc: PropTypes.object, +}; + +const mapStateToProps = state => ({ + cx: getThreadContext(state), + isWaitingOnBreak: getIsWaitingOnBreak(state, getCurrentThread(state)), + skipPausing: getSkipPausing(state), + topFrameSelected: isTopFrameSelected(state, getCurrentThread(state)), + javascriptEnabled: state.ui.javascriptEnabled, + isPaused: getIsCurrentThreadPaused(state), + isTracingEnabled: getIsThreadCurrentlyTracing(state, getCurrentThread(state)), + logMethod: getJavascriptTracingLogMethod(state), +}); + +export default connect(mapStateToProps, { + toggleTracing: actions.toggleTracing, + setJavascriptTracingLogMethod: actions.setJavascriptTracingLogMethod, + resume: actions.resume, + stepIn: actions.stepIn, + stepOut: actions.stepOut, + stepOver: actions.stepOver, + breakOnNext: actions.breakOnNext, + pauseOnExceptions: actions.pauseOnExceptions, + toggleSkipPausing: actions.toggleSkipPausing, + toggleInlinePreview: actions.toggleInlinePreview, + toggleEditorWrapping: actions.toggleEditorWrapping, + toggleSourceMapsEnabled: actions.toggleSourceMapsEnabled, + toggleJavaScriptEnabled: actions.toggleJavaScriptEnabled, + setHideOrShowIgnoredSources: actions.setHideOrShowIgnoredSources, + toggleSourceMapIgnoreList: actions.toggleSourceMapIgnoreList, +})(CommandBar); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css new file mode 100644 index 0000000000..b525783984 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css @@ -0,0 +1,76 @@ +/* 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/>. */ + + .dom-mutation-empty { + padding: 6px 20px; + text-align: center; + font-style: italic; + color: var(--theme-body-color); + white-space: normal; + } + + .dom-mutation-empty a { + text-decoration: underline; + color: var(--theme-toolbar-selected-color); + cursor: pointer; + } + +.dom-mutation-list * { + user-select: none; +} + +.dom-mutation-list { + padding: 4px 0; + list-style-type: none; +} + +.dom-mutation-list li { + position: relative; + + display: flex; + align-items: start; + overflow: hidden; + padding-top: 2px; + padding-bottom: 2px; + padding-inline-start: 20px; + padding-inline-end: 12px; +} + +.dom-mutation-list input { + margin: 2px 3px; + + padding-inline-start: 2px; + margin-top: 0px; + margin-bottom: 0px; + margin-inline-start: 0; + margin-inline-end: 2px; + vertical-align: text-bottom; +} + +.dom-mutation-info { + flex-grow: 1; + text-overflow: ellipsis; + overflow: hidden; + margin-inline-end: 20px; +} + +.dom-mutation-list .close-btn { + position: absolute; + /* hide button outside of row until hovered or focused */ + top: -100px; +} + +/* Reveal the remove button on hover/focus */ +.dom-mutation-list li:hover .close-btn, +.dom-mutation-list li .close-btn:focus { + top: calc(50% - 8px); +} + +[dir="ltr"] .dom-mutation-list .close-btn { + right: 12px; +} + +[dir="rtl"] .dom-mutation-list .close-btn { + left: 12px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js new file mode 100644 index 0000000000..375dad5563 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js @@ -0,0 +1,175 @@ +/* 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 Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { Rep }, + MODE, +} = Reps; +import { translateNodeFrontToGrip } from "inspector-shared-utils"; + +import { + deleteDOMMutationBreakpoint, + toggleDOMMutationBreakpointState, +} from "framework-actions"; + +import actions from "../../actions"; +import { connect } from "../../utils/connect"; + +import { CloseButton } from "../shared/Button"; + +import "./DOMMutationBreakpoints.css"; + +const localizationTerms = { + subtree: L10N.getStr("domMutationTypes.subtree"), + attribute: L10N.getStr("domMutationTypes.attribute"), + removal: L10N.getStr("domMutationTypes.removal"), +}; + +class DOMMutationBreakpointsContents extends Component { + static get propTypes() { + return { + breakpoints: PropTypes.array.isRequired, + deleteBreakpoint: PropTypes.func.isRequired, + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openInspector: PropTypes.func.isRequired, + setSkipPausing: PropTypes.func.isRequired, + toggleBreakpoint: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + }; + } + + handleBreakpoint(breakpointId, shouldEnable) { + const { toggleBreakpoint, setSkipPausing } = this.props; + + // The user has enabled a mutation breakpoint so we should no + // longer skip pausing + if (shouldEnable) { + setSkipPausing(false); + } + toggleBreakpoint(breakpointId, shouldEnable); + } + + renderItem(breakpoint) { + const { + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + deleteBreakpoint, + } = this.props; + const { enabled, id: breakpointId, nodeFront, mutationType } = breakpoint; + + return ( + <li key={breakpoint.id}> + <input + type="checkbox" + checked={enabled} + onChange={() => this.handleBreakpoint(breakpointId, !enabled)} + /> + <div className="dom-mutation-info"> + <div className="dom-mutation-label"> + {Rep({ + object: translateNodeFrontToGrip(nodeFront), + mode: MODE.TINY, + onDOMNodeClick: () => openElementInInspector(nodeFront), + onInspectIconClick: () => openElementInInspector(nodeFront), + onDOMNodeMouseOver: () => highlightDomElement(nodeFront), + onDOMNodeMouseOut: () => unHighlightDomElement(), + })} + </div> + <div className="dom-mutation-type"> + {localizationTerms[mutationType] || mutationType} + </div> + </div> + <CloseButton + handleClick={() => deleteBreakpoint(nodeFront, mutationType)} + /> + </li> + ); + } + + /* eslint-disable react/no-danger */ + renderEmpty() { + const { openInspector } = this.props; + const text = L10N.getFormatStr( + "noDomMutationBreakpoints", + `<a>${L10N.getStr("inspectorTool")}</a>` + ); + + return ( + <div className="dom-mutation-empty"> + <div + onClick={() => openInspector()} + dangerouslySetInnerHTML={{ __html: text }} + /> + </div> + ); + } + + render() { + const { breakpoints } = this.props; + + if (breakpoints.length === 0) { + return this.renderEmpty(); + } + + return ( + <ul className="dom-mutation-list"> + {breakpoints.map(breakpoint => this.renderItem(breakpoint))} + </ul> + ); + } +} + +const mapStateToProps = state => ({ + breakpoints: state.domMutationBreakpoints.breakpoints, +}); + +const DOMMutationBreakpointsPanel = connect( + mapStateToProps, + { + deleteBreakpoint: deleteDOMMutationBreakpoint, + toggleBreakpoint: toggleDOMMutationBreakpointState, + }, + undefined, + { storeKey: "toolbox-store" } +)(DOMMutationBreakpointsContents); + +class DomMutationBreakpoints extends Component { + static get propTypes() { + return { + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openInspector: PropTypes.func.isRequired, + setSkipPausing: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + }; + } + + render() { + return ( + <DOMMutationBreakpointsPanel + openElementInInspector={this.props.openElementInInspector} + highlightDomElement={this.props.highlightDomElement} + unHighlightDomElement={this.props.unHighlightDomElement} + setSkipPausing={this.props.setSkipPausing} + openInspector={this.props.openInspector} + /> + ); + } +} + +export default connect(undefined, { + // the debugger-specific action bound to the debugger store + // since there is no `storeKey` + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, + setSkipPausing: actions.setSkipPausing, + openInspector: actions.openInspector, +})(DomMutationBreakpoints); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css new file mode 100644 index 0000000000..2ca0670367 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.event-listeners-content { + padding-block: 4px; +} + +.event-listeners-content ul { + padding: 0; + list-style-type: none; +} + +.event-listeners-content button:hover, +.event-listeners-content button:focus { + background: none; +} + +.event-listener-group { + user-select: none; +} + +.event-listener-header { + display: flex; + align-items: center; +} + +.event-listener-expand { + border: none; + background: none; + padding: 4px 5px; + line-height: 12px; +} + +.event-listener-expand:hover { + background: transparent; +} + +.event-listener-group input[type="checkbox"] { + margin: 0; + margin-inline-end: 4px; +} + +.event-listener-label { + display: flex; + align-items: center; + padding-inline-end: 10px; +} + +.event-listener-category { + padding: 3px 0; + line-height: 14px; +} + +.event-listeners-content .arrow { + margin-inline-end: 0; +} + +.event-listeners-content .arrow.expanded { + transform: rotate(0deg); +} + +.event-listeners-content .arrow.expanded:dir(rtl) { + transform: rotate(90deg); +} + +.event-listeners-list { + border-block-start: 1px; + padding-inline: 18px 20px; +} + +.event-listener-event { + display: flex; + align-items: center; +} + +.event-listeners-list .event-listener-event { + margin-inline-start: 40px; +} + +.event-search-results-list .event-listener-event { + padding-inline: 20px; +} + +.event-listener-name { + line-height: 14px; + padding: 3px 0; +} + +.event-listener-event input { + margin-inline: 0 4px; + margin-block: 0; +} + +.event-search-container { + display: flex; + border: 1px solid transparent; + border-block-end: 1px solid var(--theme-splitter-color); +} + +.event-search-form { + display: flex; + flex-grow: 1; +} + +.event-search-input { + flex-grow: 1; + margin: 0; + font-size: inherit; + background-color: var(--theme-sidebar-background); + border: 0; + outline: 0; + height: 24px; + color: var(--theme-body-color); + background-image: url("chrome://devtools/skin/images/filter-small.svg"); + background-position-x: 4px; + background-position-y: 50%; + background-repeat: no-repeat; + background-size: 12px; + -moz-context-properties: fill; + fill: var(--theme-icon-dimmed-color); + text-align: match-parent; +} + +:root:dir(ltr) .event-search-input { + /* Be explicit about left/right direction to prevent the text/placeholder + * from overlapping the background image when the user changes the text + * direction manually (e.g. via Ctrl+Shift). */ + padding-left: 19px; + padding-right: 12px; +} + +:root:dir(rtl) .event-search-input { + background-position-x: right 4px; + padding-right: 19px; + padding-left: 12px; +} + +.category-label { + color: var(--theme-comment); +} + +.event-search-input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.event-search-container:focus-within { + border: 1px solid var(--theme-highlight-blue); +} + +.devtools-searchinput-clear { + margin-inline-end: 8px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js new file mode 100644 index 0000000000..8b7c9975b0 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js @@ -0,0 +1,295 @@ +/* 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 { + getActiveEventListeners, + getEventListenerBreakpointTypes, + getEventListenerExpanded, +} from "../../selectors"; + +import AccessibleImage from "../shared/AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./EventListeners.css"; + +class EventListeners extends Component { + state = { + searchText: "", + focused: false, + }; + + static get propTypes() { + return { + activeEventListeners: PropTypes.array.isRequired, + addEventListenerExpanded: PropTypes.func.isRequired, + addEventListeners: PropTypes.func.isRequired, + categories: PropTypes.array.isRequired, + expandedCategories: PropTypes.array.isRequired, + removeEventListenerExpanded: PropTypes.func.isRequired, + removeEventListeners: PropTypes.func.isRequired, + }; + } + + hasMatch(eventOrCategoryName, searchText) { + const lowercaseEventOrCategoryName = eventOrCategoryName.toLowerCase(); + const lowercaseSearchText = searchText.toLowerCase(); + + return lowercaseEventOrCategoryName.includes(lowercaseSearchText); + } + + getSearchResults() { + const { searchText } = this.state; + const { categories } = this.props; + const searchResults = categories.reduce((results, cat, index) => { + const category = categories[index]; + + if (this.hasMatch(category.name, searchText)) { + results[category.name] = category.events; + } else { + results[category.name] = category.events.filter(event => + this.hasMatch(event.name, searchText) + ); + } + + return results; + }, {}); + + return searchResults; + } + + onCategoryToggle(category) { + const { + expandedCategories, + removeEventListenerExpanded, + addEventListenerExpanded, + } = this.props; + + if (expandedCategories.includes(category)) { + removeEventListenerExpanded(category); + } else { + addEventListenerExpanded(category); + } + } + + onCategoryClick(category, isChecked) { + const { addEventListeners, removeEventListeners } = this.props; + const eventsIds = category.events.map(event => event.id); + + if (isChecked) { + addEventListeners(eventsIds); + } else { + removeEventListeners(eventsIds); + } + } + + onEventTypeClick(eventId, isChecked) { + const { addEventListeners, removeEventListeners } = this.props; + if (isChecked) { + addEventListeners([eventId]); + } else { + removeEventListeners([eventId]); + } + } + + onInputChange = event => { + this.setState({ searchText: event.currentTarget.value }); + }; + + onKeyDown = event => { + if (event.key === "Escape") { + this.setState({ searchText: "" }); + } + }; + + onFocus = event => { + this.setState({ focused: true }); + }; + + onBlur = event => { + this.setState({ focused: false }); + }; + + renderSearchInput() { + const { focused, searchText } = this.state; + const placeholder = L10N.getStr("eventListenersHeader1.placeholder"); + + return ( + <form className="event-search-form" onSubmit={e => e.preventDefault()}> + <input + className={classnames("event-search-input", { focused })} + placeholder={placeholder} + value={searchText} + onChange={this.onInputChange} + onKeyDown={this.onKeyDown} + onFocus={this.onFocus} + onBlur={this.onBlur} + /> + </form> + ); + } + + renderClearSearchButton() { + const { searchText } = this.state; + + if (!searchText) { + return null; + } + + return ( + <button + onClick={() => this.setState({ searchText: "" })} + className="devtools-searchinput-clear" + /> + ); + } + + renderCategoriesList() { + const { categories } = this.props; + + return ( + <ul className="event-listeners-list"> + {categories.map((category, index) => { + return ( + <li className="event-listener-group" key={index}> + {this.renderCategoryHeading(category)} + {this.renderCategoryListing(category)} + </li> + ); + })} + </ul> + ); + } + + renderSearchResultsList() { + const searchResults = this.getSearchResults(); + + return ( + <ul className="event-search-results-list"> + {Object.keys(searchResults).map(category => { + return searchResults[category].map(event => { + return this.renderListenerEvent(event, category); + }); + })} + </ul> + ); + } + + renderCategoryHeading(category) { + const { activeEventListeners, expandedCategories } = this.props; + const { events } = category; + + const expanded = expandedCategories.includes(category.name); + const checked = events.every(({ id }) => activeEventListeners.includes(id)); + const indeterminate = + !checked && events.some(({ id }) => activeEventListeners.includes(id)); + + return ( + <div className="event-listener-header"> + <button + className="event-listener-expand" + onClick={() => this.onCategoryToggle(category.name)} + > + <AccessibleImage className={classnames("arrow", { expanded })} /> + </button> + <label className="event-listener-label"> + <input + type="checkbox" + value={category.name} + onChange={e => { + this.onCategoryClick( + category, + // Clicking an indeterminate checkbox should always have the + // effect of disabling any selected items. + indeterminate ? false : e.target.checked + ); + }} + checked={checked} + ref={el => el && (el.indeterminate = indeterminate)} + /> + <span className="event-listener-category">{category.name}</span> + </label> + </div> + ); + } + + renderCategoryListing(category) { + const { expandedCategories } = this.props; + + const expanded = expandedCategories.includes(category.name); + if (!expanded) { + return null; + } + + return ( + <ul> + {category.events.map(event => { + return this.renderListenerEvent(event, category.name); + })} + </ul> + ); + } + + renderCategory(category) { + return <span className="category-label">{category} ▸ </span>; + } + + renderListenerEvent(event, category) { + const { activeEventListeners } = this.props; + const { searchText } = this.state; + + return ( + <li className="event-listener-event" key={event.id}> + <label className="event-listener-label"> + <input + type="checkbox" + value={event.id} + onChange={e => this.onEventTypeClick(event.id, e.target.checked)} + checked={activeEventListeners.includes(event.id)} + /> + <span className="event-listener-name"> + {searchText ? this.renderCategory(category) : null} + {event.name} + </span> + </label> + </li> + ); + } + + render() { + const { searchText } = this.state; + + return ( + <div className="event-listeners"> + <div className="event-search-container"> + {this.renderSearchInput()} + {this.renderClearSearchButton()} + </div> + <div className="event-listeners-content"> + {searchText + ? this.renderSearchResultsList() + : this.renderCategoriesList()} + </div> + </div> + ); + } +} + +const mapStateToProps = state => ({ + activeEventListeners: getActiveEventListeners(state), + categories: getEventListenerBreakpointTypes(state), + expandedCategories: getEventListenerExpanded(state), +}); + +export default connect(mapStateToProps, { + addEventListeners: actions.addEventListenerBreakpoints, + removeEventListeners: actions.removeEventListenerBreakpoints, + addEventListenerExpanded: actions.addEventListenerExpanded, + removeEventListenerExpanded: actions.removeEventListenerExpanded, +})(EventListeners); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css new file mode 100644 index 0000000000..c4291c80ff --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css @@ -0,0 +1,175 @@ +/* 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/>. */ + +.expression-input-form { + width: 100%; +} + +.input-expression { + width: 100%; + margin: 0; + font-size: inherit; + border: 1px; + background-color: var(--theme-sidebar-background); + height: 24px; + padding-inline-start: 19px; + padding-inline-end: 12px; + color: var(--theme-body-color); + outline: 0; +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0); + } + 20%, + 60% { + transform: translateX(-10px); + } + 40%, + 80% { + transform: translateX(10px); + } +} + +.input-expression::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.input-expression:focus { + cursor: text; +} + +.expressions-list .expression-input-container { + height: var(--expression-item-height); +} + +.expressions-list .input-expression { + /* Prevent vertical bounce when editing an existing Watch Expression */ + height: 100%; +} + +.expressions-list { + /* TODO: add normalize */ + margin: 0; + padding: 4px 0px; + overflow-x: auto; +} + +.expression-input-container { + display: flex; + border: 1px solid transparent; +} + +.expression-input-container.focused { + border: 1px solid var(--theme-highlight-blue); +} + +:root.theme-dark .expression-input-container.focused { + border: 1px solid var(--blue-50); +} + +.expression-input-container.error { + border: 1px solid red; +} + +.expression-container { + padding-top: 3px; + padding-bottom: 3px; + padding-inline-start: 20px; + padding-inline-end: 12px; + width: 100%; + color: var(--theme-body-color); + background-color: var(--theme-body-background); + display: block; + position: relative; + overflow: hidden; +} + +.expression-container > .tree { + width: 100%; + overflow: hidden; +} + +.expression-container .tree .tree-node[aria-level="1"] { + padding-top: 0px; + /* keep line-height at 14px to prevent row from shifting upon expansion */ + line-height: 14px; +} + +.expression-container .tree-node[aria-level="1"] .object-label { + font-family: var(--monospace-font-family); +} + +:root.theme-light .expression-container:hover { + background-color: var(--search-overlays-semitransparent); +} + +:root.theme-dark .expression-container:hover { + background-color: var(--search-overlays-semitransparent); +} + +.tree .tree-node:not(.focused):hover { + background-color: transparent; +} + +.expression-container__close-btn { + position: absolute; + /* hiding button outside of row until hovered or focused */ + top: -100px; +} + +.expression-container:hover .expression-container__close-btn, +.expression-container:focus-within .expression-container__close-btn, +.expression-container__close-btn:focus-within { + top: 0; +} + +.expression-content .object-node { + padding-inline-start: 0px; + cursor: default; +} + +.expressions-list .tree.object-inspector .node.object-node { + max-width: calc(100% - 20px); + min-width: 0; + text-overflow: ellipsis; + overflow: hidden; +} + +.expression-container__close-btn { + max-height: 16px; + padding-inline-start: 4px; +} + +[dir="ltr"] .expression-container__close-btn { + right: 0; +} + +[dir="rtl"] .expression-container__close-btn { + left: 0; +} + +.expression-content { + display: flex; + align-items: center; + flex-grow: 1; + position: relative; +} + +.expression-content .tree { + overflow: hidden; + flex-grow: 1; + line-height: 15px; +} + +.expression-content .tree-node[data-expandable="false"][aria-level="1"] { + padding-inline-start: 0px; +} + +.input-expression:not(:placeholder-shown) { + font-family: var(--monospace-font-family); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js new file mode 100644 index 0000000000..308e6d4de5 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js @@ -0,0 +1,395 @@ +/* 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 { features } from "../../utils/prefs"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +import actions from "../../actions"; +import { + getExpressions, + getExpressionError, + getAutocompleteMatchset, + getThreadContext, +} from "../../selectors"; +import { getExpressionResultGripAndFront } from "../../utils/expressions"; + +import { CloseButton } from "../shared/Button"; + +import "./Expressions.css"; + +const { debounce } = require("devtools/shared/debounce"); +const classnames = require("devtools/client/shared/classnames.js"); + +const { ObjectInspector } = objectInspector; + +class Expressions extends Component { + constructor(props) { + super(props); + + this.state = { + editing: false, + editIndex: -1, + inputValue: "", + focused: false, + }; + } + + static get propTypes() { + return { + addExpression: PropTypes.func.isRequired, + autocomplete: PropTypes.func.isRequired, + autocompleteMatches: PropTypes.array, + clearAutocomplete: PropTypes.func.isRequired, + clearExpressionError: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + deleteExpression: PropTypes.func.isRequired, + expressionError: PropTypes.bool.isRequired, + expressions: PropTypes.array.isRequired, + highlightDomElement: PropTypes.func.isRequired, + onExpressionAdded: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openLink: PropTypes.any.isRequired, + showInput: PropTypes.bool.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + updateExpression: PropTypes.func.isRequired, + }; + } + + componentDidMount() { + const { showInput } = this.props; + + // Ensures that the input is focused when the "+" + // is clicked while the panel is collapsed + if (showInput && this._input) { + this._input.focus(); + } + } + + clear = () => { + this.setState(() => { + this.props.clearExpressionError(); + return { editing: false, editIndex: -1, inputValue: "", focused: false }; + }); + }; + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + if (this.state.editing && !nextProps.expressionError) { + this.clear(); + } + + // Ensures that the add watch expression input + // is no longer visible when the new watch expression is rendered + if (this.props.expressions.length < nextProps.expressions.length) { + this.hideInput(); + } + } + + shouldComponentUpdate(nextProps, nextState) { + const { editing, inputValue, focused } = this.state; + const { expressions, expressionError, showInput, autocompleteMatches } = + this.props; + + return ( + autocompleteMatches !== nextProps.autocompleteMatches || + expressions !== nextProps.expressions || + expressionError !== nextProps.expressionError || + editing !== nextState.editing || + inputValue !== nextState.inputValue || + nextProps.showInput !== showInput || + focused !== nextState.focused + ); + } + + componentDidUpdate(prevProps, prevState) { + const input = this._input; + + if (!input) { + return; + } + + if (!prevState.editing && this.state.editing) { + input.setSelectionRange(0, input.value.length); + input.focus(); + } else if (this.props.showInput && !this.state.focused) { + input.focus(); + } + } + + editExpression(expression, index) { + this.setState({ + inputValue: expression.input, + editing: true, + editIndex: index, + }); + } + + deleteExpression(e, expression) { + e.stopPropagation(); + const { deleteExpression } = this.props; + deleteExpression(expression); + } + + handleChange = e => { + const { target } = e; + if (features.autocompleteExpression) { + this.findAutocompleteMatches(target.value, target.selectionStart); + } + this.setState({ inputValue: target.value }); + }; + + findAutocompleteMatches = debounce((value, selectionStart) => { + const { autocomplete } = this.props; + autocomplete(this.props.cx, value, selectionStart); + }, 250); + + handleKeyDown = e => { + if (e.key === "Escape") { + this.clear(); + } + }; + + hideInput = () => { + this.setState({ focused: false }); + this.props.onExpressionAdded(); + this.props.clearExpressionError(); + }; + + createElement = element => { + return document.createElement(element); + }; + + onFocus = () => { + this.setState({ focused: true }); + }; + + onBlur() { + this.clear(); + this.hideInput(); + } + + handleExistingSubmit = async (e, expression) => { + e.preventDefault(); + e.stopPropagation(); + + this.props.updateExpression( + this.props.cx, + this.state.inputValue, + expression + ); + }; + + handleNewSubmit = async e => { + const { inputValue } = this.state; + e.preventDefault(); + e.stopPropagation(); + + this.props.clearExpressionError(); + await this.props.addExpression(this.props.cx, this.state.inputValue); + this.setState({ + editing: false, + editIndex: -1, + inputValue: this.props.expressionError ? inputValue : "", + }); + + this.props.clearAutocomplete(); + }; + + renderExpression = (expression, index) => { + const { + expressionError, + openLink, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const { editing, editIndex } = this.state; + const { input, updating } = expression; + const isEditingExpr = editing && editIndex === index; + if (isEditingExpr || (isEditingExpr && expressionError)) { + return this.renderExpressionEditInput(expression); + } + + if (updating) { + return null; + } + + const { expressionResultGrip, expressionResultFront } = + getExpressionResultGripAndFront(expression); + + const root = { + name: expression.input, + path: input, + contents: { + value: expressionResultGrip, + front: expressionResultFront, + }, + }; + + return ( + <li className="expression-container" key={input} title={expression.input}> + <div className="expression-content"> + <ObjectInspector + roots={[root]} + autoExpandDepth={0} + disableWrap={true} + openLink={openLink} + createElement={this.createElement} + onDoubleClick={(items, { depth }) => { + if (depth === 0) { + this.editExpression(expression, index); + } + }} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + shouldRenderTooltip={true} + mayUseCustomFormatter={true} + /> + <div className="expression-container__close-btn"> + <CloseButton + handleClick={e => this.deleteExpression(e, expression)} + tooltip={L10N.getStr("expressions.remove.tooltip")} + /> + </div> + </div> + </li> + ); + }; + + renderExpressions() { + const { expressions, showInput } = this.props; + + return ( + <> + <ul className="pane expressions-list"> + {expressions.map(this.renderExpression)} + </ul> + {showInput && this.renderNewExpressionInput()} + </> + ); + } + + renderAutoCompleteMatches() { + if (!features.autocompleteExpression) { + return null; + } + const { autocompleteMatches } = this.props; + if (autocompleteMatches) { + return ( + <datalist id="autocomplete-matches"> + {autocompleteMatches.map((match, index) => { + return <option key={index} value={match} />; + })} + </datalist> + ); + } + return <datalist id="autocomplete-matches" />; + } + + renderNewExpressionInput() { + const { expressionError } = this.props; + const { editing, inputValue, focused } = this.state; + const error = editing === false && expressionError === true; + const placeholder = error + ? L10N.getStr("expressions.errorMsg") + : L10N.getStr("expressions.placeholder"); + + return ( + <form + className={classnames( + "expression-input-container expression-input-form", + { focused, error } + )} + onSubmit={this.handleNewSubmit} + > + <input + className="input-expression" + type="text" + placeholder={placeholder} + onChange={this.handleChange} + onBlur={this.hideInput} + onKeyDown={this.handleKeyDown} + onFocus={this.onFocus} + value={!editing ? inputValue : ""} + ref={c => (this._input = c)} + {...(features.autocompleteExpression && { + list: "autocomplete-matches", + })} + /> + {this.renderAutoCompleteMatches()} + <input type="submit" style={{ display: "none" }} /> + </form> + ); + } + + renderExpressionEditInput(expression) { + const { expressionError } = this.props; + const { inputValue, editing, focused } = this.state; + const error = editing === true && expressionError === true; + + return ( + <form + key={expression.input} + className={classnames( + "expression-input-container expression-input-form", + { focused, error } + )} + onSubmit={e => this.handleExistingSubmit(e, expression)} + > + <input + className={classnames("input-expression", { error })} + type="text" + onChange={this.handleChange} + onBlur={this.clear} + onKeyDown={this.handleKeyDown} + onFocus={this.onFocus} + value={editing ? inputValue : expression.input} + ref={c => (this._input = c)} + {...(features.autocompleteExpression && { + list: "autocomplete-matches", + })} + /> + {this.renderAutoCompleteMatches()} + <input type="submit" style={{ display: "none" }} /> + </form> + ); + } + + render() { + const { expressions } = this.props; + + if (expressions.length === 0) { + return this.renderNewExpressionInput(); + } + + return this.renderExpressions(); + } +} + +const mapStateToProps = state => ({ + cx: getThreadContext(state), + autocompleteMatches: getAutocompleteMatchset(state), + expressions: getExpressions(state), + expressionError: getExpressionError(state), +}); + +export default connect(mapStateToProps, { + autocomplete: actions.autocomplete, + clearAutocomplete: actions.clearAutocomplete, + addExpression: actions.addExpression, + clearExpressionError: actions.clearExpressionError, + updateExpression: actions.updateExpression, + deleteExpression: actions.deleteExpression, + openLink: actions.openLink, + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, +})(Expressions); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js new file mode 100644 index 0000000000..4ea94df95d --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js @@ -0,0 +1,197 @@ +/* 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, memo } from "react"; +import PropTypes from "prop-types"; + +import AccessibleImage from "../../shared/AccessibleImage"; +import { formatDisplayName } from "../../../utils/pause/frames"; +import { getFilename, getFileURL } from "../../../utils/source"; +import FrameMenu from "./FrameMenu"; +import FrameIndent from "./FrameIndent"; +const classnames = require("devtools/client/shared/classnames.js"); + +function FrameTitle({ frame, options = {}, l10n }) { + const displayName = formatDisplayName(frame, options, l10n); + return <span className="title">{displayName}</span>; +} + +FrameTitle.propTypes = { + frame: PropTypes.object.isRequired, + options: PropTypes.object.isRequired, + l10n: PropTypes.object.isRequired, +}; + +const FrameLocation = memo(({ frame, displayFullUrl = false }) => { + if (!frame.source) { + return null; + } + + if (frame.library) { + return ( + <span className="location"> + {frame.library} + <AccessibleImage + className={`annotation-logo ${frame.library.toLowerCase()}`} + /> + </span> + ); + } + + const { location, source } = frame; + const filename = displayFullUrl + ? getFileURL(source, false) + : getFilename(source); + + return ( + <span className="location" title={source.url}> + <span className="filename">{filename}</span>: + <span className="line">{location.line}</span> + </span> + ); +}); + +FrameLocation.displayName = "FrameLocation"; + +FrameLocation.propTypes = { + frame: PropTypes.object.isRequired, + displayFullUrl: PropTypes.bool.isRequired, +}; + +export default class FrameComponent extends Component { + static defaultProps = { + hideLocation: false, + shouldMapDisplayName: true, + disableContextMenu: false, + }; + + static get propTypes() { + return { + copyStackTrace: PropTypes.func.isRequired, + cx: PropTypes.object, + disableContextMenu: PropTypes.bool.isRequired, + displayFullUrl: PropTypes.bool.isRequired, + frame: PropTypes.object.isRequired, + frameworkGroupingOn: PropTypes.bool.isRequired, + getFrameTitle: PropTypes.func, + hideLocation: PropTypes.bool.isRequired, + panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired, + restart: PropTypes.func, + selectFrame: PropTypes.func.isRequired, + selectedFrame: PropTypes.object, + shouldMapDisplayName: PropTypes.bool.isRequired, + toggleBlackBox: PropTypes.func, + toggleFrameworkGrouping: PropTypes.func.isRequired, + }; + } + + get isSelectable() { + return this.props.panel == "webconsole"; + } + + get isDebugger() { + return this.props.panel == "debugger"; + } + + onContextMenu(event) { + const { + frame, + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + frameworkGroupingOn, + cx, + restart, + } = this.props; + FrameMenu( + frame, + frameworkGroupingOn, + { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox, restart }, + event, + cx + ); + } + + onMouseDown(e, frame, selectedFrame) { + if (e.button !== 0) { + return; + } + + this.props.selectFrame(this.props.cx, frame); + } + + onKeyUp(event, frame, selectedFrame) { + if (event.key != "Enter") { + return; + } + + this.props.selectFrame(this.props.cx, frame); + } + + render() { + const { + frame, + selectedFrame, + hideLocation, + shouldMapDisplayName, + displayFullUrl, + getFrameTitle, + disableContextMenu, + } = this.props; + const { l10n } = this.context; + + const className = classnames("frame", { + selected: selectedFrame && selectedFrame.id === frame.id, + }); + + if (!frame.source) { + throw new Error("no frame source"); + } + + const title = getFrameTitle + ? getFrameTitle( + `${getFileURL(frame.source, false)}:${frame.location.line}` + ) + : undefined; + + return ( + <div + role="listitem" + key={frame.id} + className={className} + onMouseDown={e => this.onMouseDown(e, frame, selectedFrame)} + onKeyUp={e => this.onKeyUp(e, frame, selectedFrame)} + onContextMenu={disableContextMenu ? null : e => this.onContextMenu(e)} + tabIndex={0} + title={title} + > + {frame.asyncCause && ( + <span className="location-async-cause"> + {this.isSelectable && <FrameIndent />} + {this.isDebugger ? ( + <span className="async-label">{frame.asyncCause}</span> + ) : ( + l10n.getFormatStr("stacktrace.asyncStack", frame.asyncCause) + )} + {this.isSelectable && <br className="clipboard-only" />} + </span> + )} + {this.isSelectable && <FrameIndent />} + <FrameTitle + frame={frame} + options={{ shouldMapDisplayName }} + l10n={l10n} + /> + {!hideLocation && <span className="clipboard-only"> </span>} + {!hideLocation && ( + <FrameLocation frame={frame} displayFullUrl={displayFullUrl} /> + )} + {this.isSelectable && <br className="clipboard-only" />} + </div> + ); + } +} + +FrameComponent.displayName = "Frame"; +FrameComponent.contextTypes = { l10n: PropTypes.object }; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js new file mode 100644 index 0000000000..55eb5da08a --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js @@ -0,0 +1,13 @@ +/* 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"; + +export default function FrameIndent() { + return ( + <span className="frame-indent clipboard-only"> + + </span> + ); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js new file mode 100644 index 0000000000..a92db936ba --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js @@ -0,0 +1,105 @@ +/* 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 { showMenu } from "../../../context-menu/menu"; +import { copyToTheClipboard } from "../../../utils/clipboard"; + +const blackboxString = "ignoreContextItem.ignore"; +const unblackboxString = "ignoreContextItem.unignore"; + +function formatMenuElement(labelString, click, disabled = false) { + const label = L10N.getStr(labelString); + const accesskey = L10N.getStr(`${labelString}.accesskey`); + const id = `node-menu-${labelString}`; + return { + id, + label, + accesskey, + disabled, + click, + }; +} + +function copySourceElement(url) { + return formatMenuElement("copySourceUri2", () => copyToTheClipboard(url)); +} + +function copyStackTraceElement(copyStackTrace) { + return formatMenuElement("copyStackTrace", () => copyStackTrace()); +} + +function toggleFrameworkGroupingElement( + toggleFrameworkGrouping, + frameworkGroupingOn +) { + const actionType = frameworkGroupingOn + ? "framework.disableGrouping" + : "framework.enableGrouping"; + + return formatMenuElement(actionType, () => toggleFrameworkGrouping()); +} + +function blackBoxSource(cx, source, toggleBlackBox) { + const toggleBlackBoxString = source.isBlackBoxed + ? unblackboxString + : blackboxString; + + return formatMenuElement(toggleBlackBoxString, () => + toggleBlackBox(cx, source) + ); +} + +function restartFrame(cx, frame, restart) { + return formatMenuElement("restartFrame", () => restart(cx, frame)); +} + +function isValidRestartFrame(frame, callbacks) { + // Hides 'Restart Frame' item for call stack groups context menu, + // otherwise can be misleading for the user which frame gets restarted. + if (!callbacks.restart) { + return false; + } + + // Any frame state than 'on-stack' is either dismissed by the server + // or can potentially cause unexpected errors. + // Global frame has frame.callee equal to null and can't be restarted. + return frame.type === "call" && frame.state === "on-stack"; +} + +export default function FrameMenu( + frame, + frameworkGroupingOn, + callbacks, + event, + cx +) { + event.stopPropagation(); + event.preventDefault(); + + const menuOptions = []; + + if (isValidRestartFrame(frame, callbacks)) { + const restartFrameItem = restartFrame(cx, frame, callbacks.restart); + menuOptions.push(restartFrameItem); + } + + const toggleFrameworkElement = toggleFrameworkGroupingElement( + callbacks.toggleFrameworkGrouping, + frameworkGroupingOn + ); + menuOptions.push(toggleFrameworkElement); + + const { source } = frame; + if (source) { + const copySourceUri2 = copySourceElement(source.url); + menuOptions.push(copySourceUri2); + menuOptions.push(blackBoxSource(cx, source, callbacks.toggleBlackBox)); + } + + const copyStackTraceItem = copyStackTraceElement(callbacks.copyStackTrace); + + menuOptions.push(copyStackTraceItem); + + showMenu(event, menuOptions); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css new file mode 100644 index 0000000000..5f57f97e51 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css @@ -0,0 +1,185 @@ +/* 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/>. */ + +.frames [role="list"] { + list-style: none; + margin: 0; + padding: 4px 0; +} + +.frames [role="list"] [role="listitem"] { + padding-bottom: 2px; + overflow: hidden; + display: flex; + justify-content: space-between; + column-gap: 0.5em; + flex-direction: row; + align-items: center; + margin: 0; + max-width: 100%; + flex-wrap: wrap; +} + +.frames [role="list"] [role="listitem"] * { + user-select: none; +} + +.frames .badge { + flex-shrink: 0; + margin-inline-end: 10px; +} + +.frames .location { + font-weight: normal; + margin: 0; + flex-grow: 1; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + /* Trick to get the ellipsis at the start of the string */ + text-overflow: ellipsis; + direction: rtl; +} + +.call-stack-pane:dir(ltr) .frames .location { + padding-right: 10px; + text-align: right; +} + +.call-stack-pane:dir(rtl) .frames .location { + padding-left: 10px; + text-align: left; +} + +.call-stack-pane .location-async-cause { + color: var(--theme-comment); +} + +.theme-light .frames .location { + color: var(--theme-comment); +} + +:root.theme-dark .frames .location { + color: var(--theme-body-color); + opacity: 0.6; +} + +.frames .title { + text-overflow: ellipsis; + overflow: hidden; + padding-inline-start: 10px; +} + +.frames-group .title { + padding-inline-start: 40px; +} + +.frames [role="list"] [role="listitem"]:hover, +.frames [role="list"] [role="listitem"]:focus { + background-color: var(--theme-toolbar-background-alt); +} + +.frames [role="list"] [role="listitem"]:hover .location-async-cause, +.frames [role="list"] [role="listitem"]:focus .location-async-cause, +.frames [role="list"] [role="listitem"]:hover .async-label, +.frames [role="list"] [role="listitem"]:focus .async-label { + background-color: var(--theme-body-background); +} + +.theme-dark .frames [role="list"] [role="listitem"]:focus, +.theme-dark .frames [role="list"] [role="listitem"]:focus .async-label, +.theme-dark .frames [role="list"] [role="listitem"]:focus .async-label { + background-color: var(--theme-tab-toolbar-background); +} + +.frames [role="list"] [role="listitem"].selected, +.frames [role="list"] [role="listitem"].selected .async-label { + background-color: var(--theme-selection-background); + color: white; +} + +.frames [role="list"] [role="listitem"].selected i.annotation-logo svg path { + fill: white; +} + +:root.theme-light .frames [role="list"] [role="listitem"].selected .location, +:root.theme-dark .frames [role="list"] [role="listitem"].selected .location { + color: white; +} + +.frames .show-more-container { + display: flex; + min-height: 24px; + padding: 4px 0; +} + +.frames .show-more { + text-align: center; + padding: 8px 0px; + margin: 7px 10px 7px 7px; + border: 1px solid var(--theme-splitter-color); + background-color: var(--theme-tab-toolbar-background); + width: 100%; + font-size: inherit; + color: inherit; +} + +.frames .show-more:hover { + background-color: var(--theme-toolbar-background-hover); +} + +.frames .img.annotation-logo { + margin-inline-end: 4px; + background-color: currentColor; +} + +/* + * We also show the library icon in locations, which are forced to RTL. + */ +.frames .location .img.annotation-logo { + margin-inline-start: 4px; +} + +/* Some elements are added to the DOM only to be printed into the clipboard + when the user copy some elements. We don't want those elements to mess with + the layout so we put them outside of the screen +*/ +.frames .clipboard-only { + position: absolute; + left: -9999px; +} + +.call-stack-pane [role="listitem"] .location-async-cause { + height: 20px; + line-height: 20px; + color: var(--theme-icon-dimmed-color); + display: block; + z-index: 4; + position: relative; + padding-inline-start: 17px; + width: 100%; + pointer-events: none; +} + +.frames-group .location-async-cause { + padding-inline-start: 47px; +} + +.call-stack-pane [role="listitem"] .location-async-cause::after { + content: " "; + position: absolute; + left: 0; + z-index: -1; + height: 30px; + top: 50%; + width: 100%; + border-top: 1px solid var(--theme-tab-toolbar-background);; +} + +.call-stack-pane .async-label { + z-index: 1; + background-color: var(--theme-sidebar-background); + padding: 0 3px; + display: inline-block; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css new file mode 100644 index 0000000000..14dbea9954 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.frames-group .group, +.frames-group .group .location { + font-weight: 500; + cursor: default; + /* + * direction:rtl is set in Frames.css to overflow the location text from the + * start. Here we need to reset it in order to display the framework icon + * after the framework name. + */ + direction: ltr; +} + +.frames-group.expanded .group, +.frames-group.expanded .group .location { + color: var(--theme-highlight-blue); +} + +.frames-group .frames-list { + border-top: 1px solid var(--theme-splitter-color); + border-bottom: 1px solid var(--theme-splitter-color); +} + +.frames-group.expanded .badge { + color: var(--theme-highlight-blue); +} + +.frames-group .img.arrow { + margin-inline-start: -1px; + margin-inline-end: 4px; +} + +.frames-group .group-description { + padding-inline-start: 6px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js new file mode 100644 index 0000000000..162c89a2a6 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js @@ -0,0 +1,197 @@ +/* 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 { getLibraryFromUrl } from "../../../utils/pause/frames"; + +import FrameMenu from "./FrameMenu"; +import AccessibleImage from "../../shared/AccessibleImage"; +import FrameComponent from "./Frame"; + +import "./Group.css"; + +import Badge from "../../shared/Badge"; +import FrameIndent from "./FrameIndent"; + +const classnames = require("devtools/client/shared/classnames.js"); + +function FrameLocation({ frame, expanded }) { + const library = frame.library || getLibraryFromUrl(frame); + if (!library) { + return null; + } + + const arrowClassName = classnames("arrow", { expanded }); + return ( + <span className="group-description"> + <AccessibleImage className={arrowClassName} /> + <AccessibleImage className={`annotation-logo ${library.toLowerCase()}`} /> + <span className="group-description-name">{library}</span> + </span> + ); +} + +FrameLocation.propTypes = { + expanded: PropTypes.any.isRequired, + frame: PropTypes.object.isRequired, +}; + +FrameLocation.displayName = "FrameLocation"; + +export default class Group extends Component { + constructor(...args) { + super(...args); + this.state = { expanded: false }; + } + + static get propTypes() { + return { + copyStackTrace: PropTypes.func.isRequired, + cx: PropTypes.object, + disableContextMenu: PropTypes.bool.isRequired, + displayFullUrl: PropTypes.bool.isRequired, + frameworkGroupingOn: PropTypes.bool.isRequired, + getFrameTitle: PropTypes.func, + group: PropTypes.array.isRequired, + panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired, + restart: PropTypes.func, + selectFrame: PropTypes.func.isRequired, + selectLocation: PropTypes.func, + selectedFrame: PropTypes.object, + toggleBlackBox: PropTypes.func, + toggleFrameworkGrouping: PropTypes.func.isRequired, + }; + } + + get isSelectable() { + return this.props.panel == "webconsole"; + } + + onContextMenu(event) { + const { + group, + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + frameworkGroupingOn, + cx, + } = this.props; + const frame = group[0]; + FrameMenu( + frame, + frameworkGroupingOn, + { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox }, + event, + cx + ); + } + + toggleFrames = event => { + event.stopPropagation(); + this.setState(prevState => ({ expanded: !prevState.expanded })); + }; + + renderFrames() { + const { + cx, + group, + selectFrame, + selectLocation, + selectedFrame, + toggleFrameworkGrouping, + frameworkGroupingOn, + toggleBlackBox, + copyStackTrace, + displayFullUrl, + getFrameTitle, + disableContextMenu, + panel, + restart, + } = this.props; + + const { expanded } = this.state; + if (!expanded) { + return null; + } + + return ( + <div className="frames-list"> + {group.reduce((acc, frame, i) => { + if (this.isSelectable) { + acc.push(<FrameIndent key={`frame-indent-${i}`} />); + } + return acc.concat( + <FrameComponent + cx={cx} + copyStackTrace={copyStackTrace} + frame={frame} + frameworkGroupingOn={frameworkGroupingOn} + hideLocation={true} + key={frame.id} + selectedFrame={selectedFrame} + selectFrame={selectFrame} + selectLocation={selectLocation} + shouldMapDisplayName={false} + toggleBlackBox={toggleBlackBox} + toggleFrameworkGrouping={toggleFrameworkGrouping} + displayFullUrl={displayFullUrl} + getFrameTitle={getFrameTitle} + disableContextMenu={disableContextMenu} + panel={panel} + restart={restart} + /> + ); + }, [])} + </div> + ); + } + + renderDescription() { + const { l10n } = this.context; + const { group } = this.props; + const { expanded } = this.state; + + const frame = group[0]; + const l10NEntry = expanded + ? "callStack.group.collapseTooltip" + : "callStack.group.expandTooltip"; + const title = l10n.getFormatStr(l10NEntry, frame.library); + + return ( + <div + role="listitem" + key={frame.id} + className="group" + onClick={this.toggleFrames} + tabIndex={0} + title={title} + > + {this.isSelectable && <FrameIndent />} + <FrameLocation frame={frame} expanded={expanded} /> + {this.isSelectable && <span className="clipboard-only"> </span>} + <Badge>{this.props.group.length}</Badge> + {this.isSelectable && <br className="clipboard-only" />} + </div> + ); + } + + render() { + const { expanded } = this.state; + const { disableContextMenu } = this.props; + return ( + <div + className={classnames("frames-group", { expanded })} + onContextMenu={disableContextMenu ? null : e => this.onContextMenu(e)} + > + {this.renderDescription()} + {this.renderFrames()} + </div> + ); + } +} + +Group.displayName = "Group"; +Group.contextTypes = { l10n: PropTypes.object }; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js new file mode 100644 index 0000000000..5c48af8cb3 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js @@ -0,0 +1,231 @@ +/* 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 { connect } from "../../../utils/connect"; +import PropTypes from "prop-types"; + +import FrameComponent from "./Frame"; +import Group from "./Group"; + +import actions from "../../../actions"; +import { collapseFrames, formatCopyName } from "../../../utils/pause/frames"; +import { copyToTheClipboard } from "../../../utils/clipboard"; + +import { + getFrameworkGroupingState, + getSelectedFrame, + getCallStackFrames, + getCurrentThread, + getThreadContext, +} from "../../../selectors"; + +import "./Frames.css"; + +const NUM_FRAMES_SHOWN = 7; + +class Frames extends Component { + constructor(props) { + super(props); + + this.state = { + showAllFrames: !!props.disableFrameTruncate, + }; + } + + static get propTypes() { + return { + cx: PropTypes.object, + disableContextMenu: PropTypes.bool.isRequired, + disableFrameTruncate: PropTypes.bool.isRequired, + displayFullUrl: PropTypes.bool.isRequired, + frames: PropTypes.array.isRequired, + frameworkGroupingOn: PropTypes.bool.isRequired, + getFrameTitle: PropTypes.func, + panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired, + restart: PropTypes.func, + selectFrame: PropTypes.func.isRequired, + selectLocation: PropTypes.func, + selectedFrame: PropTypes.object, + toggleBlackBox: PropTypes.func, + toggleFrameworkGrouping: PropTypes.func, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + const { frames, selectedFrame, frameworkGroupingOn } = this.props; + const { showAllFrames } = this.state; + return ( + frames !== nextProps.frames || + selectedFrame !== nextProps.selectedFrame || + showAllFrames !== nextState.showAllFrames || + frameworkGroupingOn !== nextProps.frameworkGroupingOn + ); + } + + toggleFramesDisplay = () => { + this.setState(prevState => ({ + showAllFrames: !prevState.showAllFrames, + })); + }; + + collapseFrames(frames) { + const { frameworkGroupingOn } = this.props; + if (!frameworkGroupingOn) { + return frames; + } + + return collapseFrames(frames); + } + + truncateFrames(frames) { + const numFramesToShow = this.state.showAllFrames + ? frames.length + : NUM_FRAMES_SHOWN; + + return frames.slice(0, numFramesToShow); + } + + copyStackTrace = () => { + const { frames } = this.props; + const { l10n } = this.context; + const framesToCopy = frames.map(f => formatCopyName(f, l10n)).join("\n"); + copyToTheClipboard(framesToCopy); + }; + + toggleFrameworkGrouping = () => { + const { toggleFrameworkGrouping, frameworkGroupingOn } = this.props; + toggleFrameworkGrouping(!frameworkGroupingOn); + }; + + renderFrames(frames) { + const { + cx, + selectFrame, + selectLocation, + selectedFrame, + toggleBlackBox, + frameworkGroupingOn, + displayFullUrl, + getFrameTitle, + disableContextMenu, + panel, + restart, + } = this.props; + + const framesOrGroups = this.truncateFrames(this.collapseFrames(frames)); + + // We're not using a <ul> because it adds new lines before and after when + // the user copies the trace. Needed for the console which has several + // places where we don't want to have those new lines. + return ( + <div role="list"> + {framesOrGroups.map(frameOrGroup => + frameOrGroup.id ? ( + <FrameComponent + cx={cx} + frame={frameOrGroup} + toggleFrameworkGrouping={this.toggleFrameworkGrouping} + copyStackTrace={this.copyStackTrace} + frameworkGroupingOn={frameworkGroupingOn} + selectFrame={selectFrame} + selectLocation={selectLocation} + selectedFrame={selectedFrame} + toggleBlackBox={toggleBlackBox} + key={String(frameOrGroup.id)} + displayFullUrl={displayFullUrl} + getFrameTitle={getFrameTitle} + disableContextMenu={disableContextMenu} + panel={panel} + restart={restart} + /> + ) : ( + <Group + cx={cx} + group={frameOrGroup} + toggleFrameworkGrouping={this.toggleFrameworkGrouping} + copyStackTrace={this.copyStackTrace} + frameworkGroupingOn={frameworkGroupingOn} + selectFrame={selectFrame} + selectLocation={selectLocation} + selectedFrame={selectedFrame} + toggleBlackBox={toggleBlackBox} + key={frameOrGroup[0].id} + displayFullUrl={displayFullUrl} + getFrameTitle={getFrameTitle} + disableContextMenu={disableContextMenu} + panel={panel} + restart={restart} + /> + ) + )} + </div> + ); + } + + renderToggleButton(frames) { + const { l10n } = this.context; + const buttonMessage = this.state.showAllFrames + ? l10n.getStr("callStack.collapse") + : l10n.getStr("callStack.expand"); + + frames = this.collapseFrames(frames); + if (frames.length <= NUM_FRAMES_SHOWN) { + return null; + } + + return ( + <div className="show-more-container"> + <button className="show-more" onClick={this.toggleFramesDisplay}> + {buttonMessage} + </button> + </div> + ); + } + + render() { + const { frames, disableFrameTruncate } = this.props; + + if (!frames) { + return ( + <div className="pane frames"> + <div className="pane-info empty"> + {L10N.getStr("callStack.notPaused")} + </div> + </div> + ); + } + + return ( + <div className="pane frames"> + {this.renderFrames(frames)} + {disableFrameTruncate ? null : this.renderToggleButton(frames)} + </div> + ); + } +} + +Frames.contextTypes = { l10n: PropTypes.object }; + +const mapStateToProps = state => ({ + cx: getThreadContext(state), + frames: getCallStackFrames(state), + frameworkGroupingOn: getFrameworkGroupingState(state), + selectedFrame: getSelectedFrame(state, getCurrentThread(state)), + disableFrameTruncate: false, + disableContextMenu: false, + displayFullUrl: false, +}); + +export default connect(mapStateToProps, { + selectFrame: actions.selectFrame, + selectLocation: actions.selectLocation, + toggleBlackBox: actions.toggleBlackBox, + toggleFrameworkGrouping: actions.toggleFrameworkGrouping, + restart: actions.restart, +})(Frames); + +// Export the non-connected component in order to use it outside of the debugger +// panel (e.g. console, netmonitor, …). +export { Frames }; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build new file mode 100644 index 0000000000..f775363b14 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build @@ -0,0 +1,14 @@ +# 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( + "Frame.js", + "FrameIndent.js", + "FrameMenu.js", + "Group.js", + "index.js", +) diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js new file mode 100644 index 0000000000..10ec961858 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js @@ -0,0 +1,155 @@ +/* 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 { shallow, mount } from "enzyme"; +import Frame from "../Frame.js"; +import { + makeMockFrame, + makeMockSource, + mockthreadcx, +} from "../../../../utils/test-mockup"; + +import FrameMenu from "../FrameMenu"; +jest.mock("../FrameMenu", () => jest.fn()); + +function frameProperties(frame, selectedFrame, overrides = {}) { + return { + cx: mockthreadcx, + frame, + selectedFrame, + copyStackTrace: jest.fn(), + contextTypes: {}, + selectFrame: jest.fn(), + selectLocation: jest.fn(), + toggleBlackBox: jest.fn(), + displayFullUrl: false, + frameworkGroupingOn: false, + panel: "webconsole", + toggleFrameworkGrouping: null, + restart: jest.fn(), + ...overrides, + }; +} + +function render(frameToSelect = {}, overrides = {}, propsOverrides = {}) { + const source = makeMockSource("foo-view.js"); + const defaultFrame = makeMockFrame("1", source, undefined, 10, "renderFoo"); + + const frame = { ...defaultFrame, ...overrides }; + const selectedFrame = { ...frame, ...frameToSelect }; + + const props = frameProperties(frame, selectedFrame, propsOverrides); + const component = shallow(<Frame {...props} />); + return { component, props }; +} + +describe("Frame", () => { + it("user frame", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("user frame (not selected)", () => { + const { component } = render({ id: "2" }); + expect(component).toMatchSnapshot(); + }); + + it("library frame", () => { + const source = makeMockSource("backbone.js"); + const backboneFrame = { + ...makeMockFrame("3", source, undefined, 12, "updateEvents"), + library: "backbone", + }; + + const { component } = render({ id: "3" }, backboneFrame); + expect(component).toMatchSnapshot(); + }); + + it("filename only", () => { + const source = makeMockSource( + "https://firefox.com/assets/src/js/foo-view.js" + ); + const frame = makeMockFrame("1", source, undefined, 10, "renderFoo"); + + const props = frameProperties(frame, null); + const component = mount(<Frame {...props} />); + expect(component.text()).toBe(" renderFoo foo-view.js:10"); + }); + + it("full URL", () => { + const url = `https://${"a".repeat(100)}.com/assets/src/js/foo-view.js`; + const source = makeMockSource(url); + const frame = makeMockFrame("1", source, undefined, 10, "renderFoo"); + + const props = frameProperties(frame, null, { displayFullUrl: true }); + const component = mount(<Frame {...props} />); + expect(component.text()).toBe(` renderFoo ${url}:10`); + }); + + it("renders asyncCause", () => { + const url = `https://example.com/async.js`; + const source = makeMockSource(url); + const frame = makeMockFrame("1", source, undefined, 10, "timeoutFn"); + frame.asyncCause = "setTimeout handler"; + + const props = frameProperties(frame); + const component = mount(<Frame {...props} />, { context: { l10n: L10N } }); + expect(component.find(".location-async-cause").text()).toBe( + ` (Async: setTimeout handler)` + ); + }); + + it("getFrameTitle", () => { + const url = `https://${"a".repeat(100)}.com/assets/src/js/foo-view.js`; + const source = makeMockSource(url); + const frame = makeMockFrame("1", source, undefined, 10, "renderFoo"); + + const props = frameProperties(frame, null, { + getFrameTitle: x => `Jump to ${x}`, + }); + const component = shallow(<Frame {...props} />); + expect(component.prop("title")).toBe(`Jump to ${url}:10`); + expect(component).toMatchSnapshot(); + }); + + describe("mouse events", () => { + it("does not call FrameMenu when disableContextMenu is true", () => { + const { component } = render(undefined, undefined, { + disableContextMenu: true, + }); + + const mockEvent = "mockEvent"; + component.simulate("contextmenu", mockEvent); + + expect(FrameMenu).toHaveBeenCalledTimes(0); + }); + + it("calls FrameMenu on right click", () => { + const { component, props } = render(); + const { + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + cx, + restart, + } = props; + const mockEvent = "mockEvent"; + component.simulate("contextmenu", mockEvent); + + expect(FrameMenu).toHaveBeenCalledWith( + props.frame, + props.frameworkGroupingOn, + { + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + restart, + }, + mockEvent, + cx + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js new file mode 100644 index 0000000000..dbaa98f5cf --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import FrameMenu from "../FrameMenu"; + +import { showMenu } from "../../../../context-menu/menu"; +import { copyToTheClipboard } from "../../../../utils/clipboard"; +import { + makeMockFrame, + makeMockSource, + mockthreadcx, +} from "../../../../utils/test-mockup"; + +jest.mock("../../../../context-menu/menu", () => ({ showMenu: jest.fn() })); +jest.mock("../../../../utils/clipboard", () => ({ + copyToTheClipboard: jest.fn(), +})); + +function generateMockId(labelString) { + return `node-menu-${labelString}`; +} + +describe("FrameMenu", () => { + let mockEvent; + let mockFrame; + let emptyFrame; + let callbacks; + let frameworkGroupingOn; + let toggleFrameworkGrouping; + + beforeEach(() => { + mockFrame = makeMockFrame(undefined, makeMockSource("isFake")); + mockEvent = { + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }; + callbacks = { + toggleFrameworkGrouping, + toggleBlackbox: jest.fn(), + copyToTheClipboard, + restart: jest.fn(), + }; + emptyFrame = {}; + }); + + afterEach(() => { + showMenu.mockClear(); + }); + + it("sends three element in menuOpts to showMenu if source is present", () => { + const restartFrameId = generateMockId("restartFrame"); + const sourceId = generateMockId("copySourceUri2"); + const stacktraceId = generateMockId("copyStackTrace"); + const frameworkGroupingId = generateMockId("framework.enableGrouping"); + const blackBoxId = generateMockId("ignoreContextItem.ignore"); + + FrameMenu( + mockFrame, + frameworkGroupingOn, + callbacks, + mockEvent, + mockthreadcx + ); + + const receivedArray = showMenu.mock.calls[0][1]; + expect(showMenu).toHaveBeenCalledWith(mockEvent, receivedArray); + const receivedArrayIds = receivedArray.map(item => item.id); + expect(receivedArrayIds).toEqual([ + restartFrameId, + frameworkGroupingId, + sourceId, + blackBoxId, + stacktraceId, + ]); + }); + + it("sends one element in menuOpts without source", () => { + const stacktraceId = generateMockId("copyStackTrace"); + const frameworkGrouping = generateMockId("framework.enableGrouping"); + + FrameMenu( + emptyFrame, + frameworkGroupingOn, + callbacks, + mockEvent, + mockthreadcx + ); + + const receivedArray = showMenu.mock.calls[0][1]; + expect(showMenu).toHaveBeenCalledWith(mockEvent, receivedArray); + const receivedArrayIds = receivedArray.map(item => item.id); + expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]); + }); + + it("uses the disableGrouping text if frameworkGroupingOn is false", () => { + const stacktraceId = generateMockId("copyStackTrace"); + const frameworkGrouping = generateMockId("framework.disableGrouping"); + + FrameMenu(emptyFrame, true, callbacks, mockEvent, mockthreadcx); + + const receivedArray = showMenu.mock.calls[0][1]; + const receivedArrayIds = receivedArray.map(item => item.id); + expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]); + }); + + it("uses the enableGrouping text if frameworkGroupingOn is true", () => { + const stacktraceId = generateMockId("copyStackTrace"); + const frameworkGrouping = generateMockId("framework.enableGrouping"); + + FrameMenu(emptyFrame, false, callbacks, mockEvent, mockthreadcx); + + const receivedArray = showMenu.mock.calls[0][1]; + const receivedArrayIds = receivedArray.map(item => item.id); + expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js new file mode 100644 index 0000000000..da60418b07 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js @@ -0,0 +1,295 @@ +/* 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 { mount, shallow } from "enzyme"; +import Frames from "../index.js"; +// eslint-disable-next-line +import { formatCallStackFrames } from "../../../../selectors/getCallStackFrames"; +import { makeMockFrame, makeMockSource } from "../../../../utils/test-mockup"; + +function render(overrides = {}) { + const defaultProps = { + frames: null, + selectedFrame: null, + frameworkGroupingOn: false, + toggleFrameworkGrouping: jest.fn(), + contextTypes: {}, + selectFrame: jest.fn(), + toggleBlackBox: jest.fn(), + }; + + const props = { ...defaultProps, ...overrides }; + const component = shallow(<Frames.WrappedComponent {...props} />, { + context: { l10n: L10N }, + }); + + return component; +} + +describe("Frames", () => { + describe("Supports different number of frames", () => { + it("empty frames", () => { + const component = render(); + expect(component).toMatchSnapshot(); + expect(component.find(".show-more").exists()).toBeFalsy(); + }); + + it("one frame", () => { + const frames = [{ id: 1 }]; + const selectedFrame = frames[0]; + const component = render({ frames, selectedFrame }); + + expect(component.find(".show-more").exists()).toBeFalsy(); + expect(component).toMatchSnapshot(); + }); + + it("toggling the show more button", () => { + const frames = [ + { id: 1 }, + { id: 2 }, + { id: 3 }, + { id: 4 }, + { id: 5 }, + { id: 6 }, + { id: 7 }, + { id: 8 }, + { id: 9 }, + { id: 10 }, + ]; + + const selectedFrame = frames[0]; + const component = render({ selectedFrame, frames }); + + const getToggleBtn = () => component.find(".show-more"); + const getFrames = () => component.find("Frame"); + + expect(getToggleBtn().text()).toEqual("Expand rows"); + expect(getFrames()).toHaveLength(7); + + getToggleBtn().simulate("click"); + expect(getToggleBtn().text()).toEqual("Collapse rows"); + expect(getFrames()).toHaveLength(10); + expect(component).toMatchSnapshot(); + }); + + it("disable frame truncation", () => { + const framesNumber = 20; + const frames = Array.from({ length: framesNumber }, (_, i) => ({ + id: i + 1, + })); + + const component = render({ + frames, + disableFrameTruncate: true, + }); + + const getToggleBtn = () => component.find(".show-more"); + const getFrames = () => component.find("Frame"); + + expect(getToggleBtn().exists()).toBeFalsy(); + expect(getFrames()).toHaveLength(framesNumber); + + expect(component).toMatchSnapshot(); + }); + + it("shows the full URL", () => { + const frames = [ + { + id: 1, + displayName: "renderFoo", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/mahscripts.js", + }, + }, + ]; + + const component = mount( + <Frames.WrappedComponent + frames={frames} + disableFrameTruncate={true} + displayFullUrl={true} + /> + ); + expect(component.text()).toBe( + "renderFoo http://myfile.com/mahscripts.js:55" + ); + }); + + it("passes the getFrameTitle prop to the Frame component", () => { + const frames = [ + { + id: 1, + displayName: "renderFoo", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/mahscripts.js", + }, + }, + ]; + const getFrameTitle = () => {}; + const component = render({ frames, getFrameTitle }); + + expect(component.find("Frame").prop("getFrameTitle")).toBe(getFrameTitle); + expect(component).toMatchSnapshot(); + }); + + it("passes the getFrameTitle prop to the Group component", () => { + const frames = [ + { + id: 1, + displayName: "renderFoo", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/mahscripts.js", + }, + }, + { + id: 2, + library: "back", + displayName: "a", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/back.js", + }, + }, + { + id: 3, + library: "back", + displayName: "b", + location: { + line: 55, + }, + source: { + url: "http://myfile.com/back.js", + }, + }, + ]; + const getFrameTitle = () => {}; + const component = render({ + frames, + getFrameTitle, + frameworkGroupingOn: true, + }); + + expect(component.find("Group").prop("getFrameTitle")).toBe(getFrameTitle); + }); + }); + + describe("Blackboxed Frames", () => { + it("filters blackboxed frames", () => { + const source1 = makeMockSource("source1", "1"); + const source2 = makeMockSource("source2", "2"); + source2.isBlackBoxed = true; + + const frames = [ + makeMockFrame("1", source1), + makeMockFrame("2", source2), + makeMockFrame("3", source1), + makeMockFrame("8", source2), + ]; + + const blackboxedRanges = { + source2: [], + }; + + const processedFrames = formatCallStackFrames( + frames, + source1, + blackboxedRanges + ); + const selectedFrame = frames[0]; + + const component = render({ + frames: processedFrames, + frameworkGroupingOn: false, + selectedFrame, + }); + + expect(component.find("Frame")).toHaveLength(2); + expect(component).toMatchSnapshot(); + }); + }); + + describe("Library Frames", () => { + it("toggling framework frames", () => { + const frames = [ + { id: 1 }, + { id: 2, library: "back" }, + { id: 3, library: "back" }, + { id: 8 }, + ]; + + const selectedFrame = frames[0]; + const frameworkGroupingOn = false; + const component = render({ frames, frameworkGroupingOn, selectedFrame }); + + expect(component.find("Frame")).toHaveLength(4); + expect(component).toMatchSnapshot(); + + component.setProps({ frameworkGroupingOn: true }); + + expect(component.find("Frame")).toHaveLength(2); + expect(component).toMatchSnapshot(); + }); + + it("groups all the Webpack-related frames", () => { + const frames = [ + { id: "1-appFrame" }, + { + id: "2-webpackBootstrapFrame", + source: { url: "webpack:///webpack/bootstrap 01d88449ca6e9335a66f" }, + }, + { + id: "3-webpackBundleFrame", + source: { url: "https://foo.com/bundle.js" }, + }, + { + id: "4-webpackBootstrapFrame", + source: { url: "webpack:///webpack/bootstrap 01d88449ca6e9335a66f" }, + }, + { + id: "5-webpackBundleFrame", + source: { url: "https://foo.com/bundle.js" }, + }, + ]; + const selectedFrame = frames[0]; + const frameworkGroupingOn = true; + const component = render({ frames, frameworkGroupingOn, selectedFrame }); + + expect(component).toMatchSnapshot(); + }); + + it("selectable framework frames", () => { + const frames = [ + { id: 1 }, + { id: 2, library: "back" }, + { id: 3, library: "back" }, + { id: 8 }, + ]; + + const selectedFrame = frames[0]; + + const component = render({ + frames, + frameworkGroupingOn: false, + selectedFrame, + selectable: true, + }); + expect(component).toMatchSnapshot(); + + component.setProps({ frameworkGroupingOn: true }); + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js new file mode 100644 index 0000000000..8ff1454d1a --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js @@ -0,0 +1,134 @@ +/* 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 { shallow } from "enzyme"; +import Group from "../Group.js"; +import { + makeMockFrame, + makeMockSource, + mockthreadcx, +} from "../../../../utils/test-mockup"; + +import FrameMenu from "../FrameMenu"; +jest.mock("../FrameMenu", () => jest.fn()); + +function render(overrides = {}) { + const frame = { ...makeMockFrame(), displayName: "foo", library: "Back" }; + const defaultProps = { + cx: mockthreadcx, + group: [frame], + selectedFrame: frame, + frameworkGroupingOn: true, + toggleFrameworkGrouping: jest.fn(), + selectFrame: jest.fn(), + selectLocation: jest.fn(), + copyStackTrace: jest.fn(), + toggleBlackBox: jest.fn(), + disableContextMenu: false, + displayFullUrl: false, + panel: "webconsole", + restart: jest.fn(), + }; + + const props = { ...defaultProps, ...overrides }; + const component = shallow(<Group {...props} />, { + context: { l10n: L10N }, + }); + return { component, props }; +} + +describe("Group", () => { + it("displays a group", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("passes the getFrameTitle prop to the Frame components", () => { + const mahscripts = makeMockSource("http://myfile.com/mahscripts.js"); + const back = makeMockSource("http://myfile.com/back.js"); + const group = [ + { + ...makeMockFrame("1", mahscripts, undefined, 55, "renderFoo"), + library: "Back", + }, + { + ...makeMockFrame("2", back, undefined, 55, "a"), + library: "Back", + }, + { + ...makeMockFrame("3", back, undefined, 55, "b"), + library: "Back", + }, + ]; + const getFrameTitle = () => {}; + const { component } = render({ group, getFrameTitle }); + + component.setState({ expanded: true }); + + const frameComponents = component.find("Frame"); + expect(frameComponents).toHaveLength(3); + frameComponents.forEach(node => { + expect(node.prop("getFrameTitle")).toBe(getFrameTitle); + }); + expect(component).toMatchSnapshot(); + }); + + it("renders group with anonymous functions", () => { + const mahscripts = makeMockSource("http://myfile.com/mahscripts.js"); + const back = makeMockSource("http://myfile.com/back.js"); + const group = [ + { + ...makeMockFrame("1", mahscripts, undefined, 55), + library: "Back", + }, + { + ...makeMockFrame("2", back, undefined, 55), + library: "Back", + }, + { + ...makeMockFrame("3", back, undefined, 55), + library: "Back", + }, + ]; + + const { component } = render({ group }); + expect(component).toMatchSnapshot(); + component.setState({ expanded: true }); + expect(component).toMatchSnapshot(); + }); + + describe("mouse events", () => { + it("does not call FrameMenu when disableContextMenu is true", () => { + const { component } = render({ + disableContextMenu: true, + }); + + const mockEvent = "mockEvent"; + component.simulate("contextmenu", mockEvent); + + expect(FrameMenu).toHaveBeenCalledTimes(0); + }); + + it("calls FrameMenu on right click", () => { + const { component, props } = render(); + const { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox, cx } = + props; + const mockEvent = "mockEvent"; + component.simulate("contextmenu", mockEvent); + + expect(FrameMenu).toHaveBeenCalledWith( + props.group[0], + props.frameworkGroupingOn, + { + copyStackTrace, + toggleFrameworkGrouping, + toggleBlackBox, + }, + mockEvent, + cx + ); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap new file mode 100644 index 0000000000..2b1edaeef7 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap @@ -0,0 +1,1196 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Frame getFrameTitle 1`] = ` +<div + className="frame" + key="1" + onContextMenu={[Function]} + onKeyUp={[Function]} + onMouseDown={[Function]} + role="listitem" + tabIndex={0} + title="Jump to https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js:10" +> + <FrameIndent /> + <FrameTitle + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + options={ + Object { + "shouldMapDisplayName": true, + } + } + /> + <span + className="clipboard-only" + > + + </span> + <FrameLocation + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "path": "/assets/src/js/foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <br + className="clipboard-only" + /> +</div> +`; + +exports[`Frame library frame 1`] = ` +<div + className="frame selected" + key="3" + onContextMenu={[Function]} + onKeyUp={[Function]} + onMouseDown={[Function]} + role="listitem" + tabIndex={0} +> + <FrameIndent /> + <FrameTitle + frame={ + Object { + "asyncCause": null, + "displayName": "updateEvents", + "generatedLocation": Object { + "column": undefined, + "line": 12, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "library": "backbone", + "location": Object { + "column": undefined, + "line": 12, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + options={ + Object { + "shouldMapDisplayName": true, + } + } + /> + <span + className="clipboard-only" + > + + </span> + <FrameLocation + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "updateEvents", + "generatedLocation": Object { + "column": undefined, + "line": 12, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "library": "backbone", + "location": Object { + "column": undefined, + "line": 12, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "backbone.js", + "group": "", + "path": "backbone.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "backbone.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <br + className="clipboard-only" + /> +</div> +`; + +exports[`Frame user frame (not selected) 1`] = ` +<div + className="frame" + key="1" + onContextMenu={[Function]} + onKeyUp={[Function]} + onMouseDown={[Function]} + role="listitem" + tabIndex={0} +> + <FrameIndent /> + <FrameTitle + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + options={ + Object { + "shouldMapDisplayName": true, + } + } + /> + <span + className="clipboard-only" + > + + </span> + <FrameLocation + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <br + className="clipboard-only" + /> +</div> +`; + +exports[`Frame user frame 1`] = ` +<div + className="frame selected" + key="1" + onContextMenu={[Function]} + onKeyUp={[Function]} + onMouseDown={[Function]} + role="listitem" + tabIndex={0} +> + <FrameIndent /> + <FrameTitle + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + options={ + Object { + "shouldMapDisplayName": true, + } + } + /> + <span + className="clipboard-only" + > + + </span> + <FrameLocation + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 10, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "foo-view.js", + "group": "", + "path": "foo-view.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "foo-view.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <br + className="clipboard-only" + /> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap new file mode 100644 index 0000000000..9a9c2a379f --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap @@ -0,0 +1,1651 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Frames Blackboxed Frames filters blackboxed frames 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-3", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "sourceActor": Object { + "actor": "1-actor", + "id": "1-actor", + "source": "1", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + }, + "sourceActorId": "1-actor", + "sourceId": "1", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "source1", + "group": "", + "path": "source1", + "search": "", + }, + "extensionName": null, + "id": "1", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "source1", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames groups all the Webpack-related frames 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": "1-appFrame", + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="1-appFrame" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": "1-appFrame", + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Group + copyStackTrace={[Function]} + frameworkGroupingOn={true} + group={ + Array [ + Object { + "id": "2-webpackBootstrapFrame", + "source": Object { + "url": "webpack:///webpack/bootstrap 01d88449ca6e9335a66f", + }, + }, + Object { + "id": "3-webpackBundleFrame", + "source": Object { + "url": "https://foo.com/bundle.js", + }, + }, + Object { + "id": "4-webpackBootstrapFrame", + "source": Object { + "url": "webpack:///webpack/bootstrap 01d88449ca6e9335a66f", + }, + }, + Object { + "id": "5-webpackBundleFrame", + "source": Object { + "url": "https://foo.com/bundle.js", + }, + }, + ] + } + key="2-webpackBootstrapFrame" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": "1-appFrame", + } + } + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames selectable framework frames 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 2, + "library": "back", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 3, + "library": "back", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames selectable framework frames 2`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Group + copyStackTrace={[Function]} + frameworkGroupingOn={true} + group={ + Array [ + Object { + "id": 2, + "library": "back", + }, + Object { + "id": 3, + "library": "back", + }, + ] + } + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames toggling framework frames 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 2, + "library": "back", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 3, + "library": "back", + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Library Frames toggling framework frames 2`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Group + copyStackTrace={[Function]} + frameworkGroupingOn={true} + group={ + Array [ + Object { + "id": 2, + "library": "back", + }, + Object { + "id": 3, + "library": "back", + }, + ] + } + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={true} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Supports different number of frames disable frame truncation 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 2, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="2" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 3, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 4, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="4" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 5, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="5" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 6, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="6" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 7, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="7" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 9, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="9" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 10, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="10" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 11, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="11" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 12, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="12" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 13, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="13" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 14, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="14" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 15, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="15" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 16, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="16" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 17, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="17" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 18, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="18" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 19, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="19" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 20, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="20" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Supports different number of frames empty frames 1`] = ` +<div + className="pane frames" +> + <div + className="pane-info empty" + > + Not paused + </div> +</div> +`; + +exports[`Frames Supports different number of frames one frame 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Supports different number of frames passes the getFrameTitle prop to the Frame component 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "displayName": "renderFoo", + "id": 1, + "location": Object { + "line": 55, + }, + "source": Object { + "url": "http://myfile.com/mahscripts.js", + }, + } + } + frameworkGroupingOn={false} + getFrameTitle={[Function]} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={null} + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> +</div> +`; + +exports[`Frames Supports different number of frames toggling the show more button 1`] = ` +<div + className="pane frames" +> + <div + role="list" + > + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 1, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="1" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 2, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="2" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 3, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="3" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 4, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="4" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 5, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="5" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 6, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="6" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 7, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="7" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 8, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="8" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 9, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="9" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + <Frame + copyStackTrace={[Function]} + disableContextMenu={false} + frame={ + Object { + "id": 10, + } + } + frameworkGroupingOn={false} + hideLocation={false} + key="10" + selectFrame={[MockFunction]} + selectedFrame={ + Object { + "id": 1, + } + } + shouldMapDisplayName={true} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[Function]} + /> + </div> + <div + className="show-more-container" + > + <button + className="show-more" + onClick={[Function]} + > + Collapse rows + </button> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap new file mode 100644 index 0000000000..d6542f7fd2 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap @@ -0,0 +1,2440 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Group displays a group 1`] = ` +<div + className="frames-group" + onContextMenu={[Function]} +> + <div + className="group" + key="frame" + onClick={[Function]} + role="listitem" + tabIndex={0} + title="Show Back frames" + > + <FrameIndent /> + <FrameLocation + expanded={false} + frame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <span + className="clipboard-only" + > + + </span> + <Badge> + 1 + </Badge> + <br + className="clipboard-only" + /> + </div> +</div> +`; + +exports[`Group passes the getFrameTitle prop to the Frame components 1`] = ` +<div + className="frames-group expanded" + onContextMenu={[Function]} +> + <div + className="group" + key="1" + onClick={[Function]} + role="listitem" + tabIndex={0} + title="Collapse Back frames" + > + <FrameIndent /> + <FrameLocation + expanded={true} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <span + className="clipboard-only" + > + + </span> + <Badge> + 3 + </Badge> + <br + className="clipboard-only" + /> + </div> + <div + className="frames-list" + > + <FrameIndent + key="frame-indent-0" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "renderFoo", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + getFrameTitle={[Function]} + hideLocation={true} + key="1" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + <FrameIndent + key="frame-indent-1" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "a", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "2", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + getFrameTitle={[Function]} + hideLocation={true} + key="2" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + <FrameIndent + key="frame-indent-2" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "b", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + getFrameTitle={[Function]} + hideLocation={true} + key="3" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + </div> +</div> +`; + +exports[`Group renders group with anonymous functions 1`] = ` +<div + className="frames-group" + onContextMenu={[Function]} +> + <div + className="group" + key="1" + onClick={[Function]} + role="listitem" + tabIndex={0} + title="Show Back frames" + > + <FrameIndent /> + <FrameLocation + expanded={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <span + className="clipboard-only" + > + + </span> + <Badge> + 3 + </Badge> + <br + className="clipboard-only" + /> + </div> +</div> +`; + +exports[`Group renders group with anonymous functions 2`] = ` +<div + className="frames-group expanded" + onContextMenu={[Function]} +> + <div + className="group" + key="1" + onClick={[Function]} + role="listitem" + tabIndex={0} + title="Collapse Back frames" + > + <FrameIndent /> + <FrameLocation + expanded={true} + frame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + /> + <span + className="clipboard-only" + > + + </span> + <Badge> + 3 + </Badge> + <br + className="clipboard-only" + /> + </div> + <div + className="frames-list" + > + <FrameIndent + key="frame-indent-0" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-1", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "1", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "mahscripts.js", + "group": "myfile.com", + "path": "/mahscripts.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/mahscripts.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + hideLocation={true} + key="1" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + <FrameIndent + key="frame-indent-1" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-2", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "2", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + hideLocation={true} + key="2" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + <FrameIndent + key="frame-indent-2" + /> + <Frame + copyStackTrace={[MockFunction]} + cx={ + Object { + "isPaused": false, + "navigateCounter": 0, + "pauseCounter": 0, + "thread": "FakeThread", + } + } + disableContextMenu={false} + displayFullUrl={false} + frame={ + Object { + "asyncCause": null, + "displayName": "display-3", + "generatedLocation": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "3", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 55, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "js", + "filename": "back.js", + "group": "myfile.com", + "path": "/back.js", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "http://myfile.com/back.js", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + frameworkGroupingOn={true} + hideLocation={true} + key="3" + panel="webconsole" + restart={[MockFunction]} + selectFrame={[MockFunction]} + selectLocation={[MockFunction]} + selectedFrame={ + Object { + "asyncCause": null, + "displayName": "foo", + "generatedLocation": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "id": "frame", + "index": 0, + "library": "Back", + "location": Object { + "column": undefined, + "line": 4, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "sourceActor": Object { + "actor": "source-actor", + "id": "source-actor", + "source": "source", + "sourceObject": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + }, + "sourceActorId": "source-actor", + "sourceId": "source", + "sourceUrl": "", + }, + "scope": Object { + "actor": "scope-actor", + "bindings": Object { + "arguments": Array [], + "variables": Object {}, + }, + "function": null, + "object": null, + "parent": null, + "scopeKind": "", + "type": "block", + }, + "source": Object { + "displayURL": Object { + "fileExtension": "", + "filename": "url", + "group": "", + "path": "url", + "search": "", + }, + "extensionName": null, + "id": "source", + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "thread": "FakeThread", + "url": "url", + }, + "state": "on-stack", + "this": Object {}, + "thread": "FakeThread", + "type": "call", + } + } + shouldMapDisplayName={false} + toggleBlackBox={[MockFunction]} + toggleFrameworkGrouping={[MockFunction]} + /> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css new file mode 100644 index 0000000000..6f47c45d19 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css @@ -0,0 +1,104 @@ +/* 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/>. */ + +.secondary-panes .map-scopes-header { + padding-inline-end: 3px; +} + +.secondary-panes .header-buttons .img.shortcuts { + width: 14px; + height: 14px; + /* Better vertical centering of the icon */ + margin-top: -2px; +} + +.scopes-content .node.object-node { + padding-inline-start: 7px; +} + +.scopes-content .pane.scopes-list { + font-family: var(--monospace-font-family); +} + +.scopes-content .toggle-map-scopes a.mdn { + padding-inline-start: 3px; +} + +.scopes-content .toggle-map-scopes .img.shortcuts { + background: var(--theme-comment); +} + +.object-node.default-property { + opacity: 0.6; +} + +.object-node { + padding-inline-start: 20px; +} + +html[dir="rtl"] .object-node { + padding-right: 4px; +} + +.object-label { + color: var(--theme-highlight-blue); +} + +.objectBox-object, +.objectBox-text, +.objectBox-table, +.objectLink-textNode, +.objectLink-event, +.objectLink-eventLog, +.objectLink-regexp, +.objectLink-object, +.objectLink-Date, +.theme-dark .objectBox-object, +.theme-light .objectBox-object { + white-space: nowrap; +} + +.scopes-pane ._content { + overflow: auto; +} + +.scopes-list { + padding: 4px 0px; +} + +.scopes-list .function-signature { + display: inline-block; +} + +.scopes-list .scope-type-toggle { + text-align: center; + padding-top: 10px; + padding-bottom: 10px; +} + +.scopes-list .scope-type-toggle button { + /* Override color so that the link doesn't turn purple */ + color: var(--theme-body-color); + font-size: inherit; + text-decoration: underline; + cursor: pointer; +} + +.scopes-list .scope-type-toggle button:hover { + background: transparent; +} + +.scopes-list .tree.object-inspector .node.object-node { + display: flex; + align-items: center; +} + +.scopes-list .tree.object-inspector .tree-node button.arrow, +.scopes-list button.invoke-getter { + margin-top: 2px; +} + +.scopes-list .tree { + line-height: 15px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js new file mode 100644 index 0000000000..2b6b5f94c9 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js @@ -0,0 +1,311 @@ +/* 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, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { showMenu } from "../../context-menu/menu"; +import { connect } from "../../utils/connect"; +import actions from "../../actions"; + +import { + getSelectedSource, + getSelectedFrame, + getGeneratedFrameScope, + getOriginalFrameScope, + getPauseReason, + isMapScopesEnabled, + getThreadContext, + getLastExpandedScopes, + getIsCurrentThreadPaused, +} from "../../selectors"; +import { getScopes } from "../../utils/pause/scopes"; +import { getScopeItemPath } from "../../utils/pause/scopes/utils"; +import { clientCommands } from "../../client/firefox"; + +import { objectInspector } from "devtools/client/shared/components/reps/index"; + +import "./Scopes.css"; + +const { ObjectInspector } = objectInspector; + +class Scopes extends PureComponent { + constructor(props) { + const { why, selectedFrame, originalFrameScopes, generatedFrameScopes } = + props; + + super(props); + + this.state = { + originalScopes: getScopes(why, selectedFrame, originalFrameScopes), + generatedScopes: getScopes(why, selectedFrame, generatedFrameScopes), + showOriginal: true, + }; + } + + static get propTypes() { + return { + addWatchpoint: PropTypes.func.isRequired, + cx: PropTypes.object.isRequired, + expandedScopes: PropTypes.array.isRequired, + generatedFrameScopes: PropTypes.object, + highlightDomElement: PropTypes.func.isRequired, + isLoading: PropTypes.bool.isRequired, + isPaused: PropTypes.bool.isRequired, + mapScopesEnabled: PropTypes.bool.isRequired, + openElementInInspector: PropTypes.func.isRequired, + openLink: PropTypes.func.isRequired, + originalFrameScopes: PropTypes.object, + removeWatchpoint: PropTypes.func.isRequired, + selectedFrame: PropTypes.object.isRequired, + setExpandedScope: PropTypes.func.isRequired, + toggleMapScopes: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + why: PropTypes.object.isRequired, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { + selectedFrame, + originalFrameScopes, + generatedFrameScopes, + isPaused, + } = this.props; + const isPausedChanged = isPaused !== nextProps.isPaused; + const selectedFrameChanged = selectedFrame !== nextProps.selectedFrame; + const originalFrameScopesChanged = + originalFrameScopes !== nextProps.originalFrameScopes; + const generatedFrameScopesChanged = + generatedFrameScopes !== nextProps.generatedFrameScopes; + + if ( + isPausedChanged || + selectedFrameChanged || + originalFrameScopesChanged || + generatedFrameScopesChanged + ) { + this.setState({ + originalScopes: getScopes( + nextProps.why, + nextProps.selectedFrame, + nextProps.originalFrameScopes + ), + generatedScopes: getScopes( + nextProps.why, + nextProps.selectedFrame, + nextProps.generatedFrameScopes + ), + }); + } + } + + onToggleMapScopes = () => { + this.props.toggleMapScopes(); + }; + + onContextMenu = (event, item) => { + const { addWatchpoint, removeWatchpoint } = this.props; + + if (!item.parent || !item.contents.configurable) { + return; + } + + if (!item.contents || item.contents.watchpoint) { + const removeWatchpointLabel = L10N.getStr("watchpoints.removeWatchpoint"); + + const removeWatchpointItem = { + id: "node-menu-remove-watchpoint", + label: removeWatchpointLabel, + disabled: false, + click: () => removeWatchpoint(item), + }; + + const menuItems = [removeWatchpointItem]; + showMenu(event, menuItems); + return; + } + + const addSetWatchpointLabel = L10N.getStr("watchpoints.setWatchpoint"); + const addGetWatchpointLabel = L10N.getStr("watchpoints.getWatchpoint"); + const addGetOrSetWatchpointLabel = L10N.getStr( + "watchpoints.getOrSetWatchpoint" + ); + const watchpointsSubmenuLabel = L10N.getStr("watchpoints.submenu"); + + const addSetWatchpointItem = { + id: "node-menu-add-set-watchpoint", + label: addSetWatchpointLabel, + disabled: false, + click: () => addWatchpoint(item, "set"), + }; + + const addGetWatchpointItem = { + id: "node-menu-add-get-watchpoint", + label: addGetWatchpointLabel, + disabled: false, + click: () => addWatchpoint(item, "get"), + }; + + const addGetOrSetWatchpointItem = { + id: "node-menu-add-get-watchpoint", + label: addGetOrSetWatchpointLabel, + disabled: false, + click: () => addWatchpoint(item, "getorset"), + }; + + const watchpointsSubmenuItem = { + id: "node-menu-watchpoints", + label: watchpointsSubmenuLabel, + disabled: false, + click: () => addWatchpoint(item, "set"), + submenu: [ + addSetWatchpointItem, + addGetWatchpointItem, + addGetOrSetWatchpointItem, + ], + }; + + const menuItems = [watchpointsSubmenuItem]; + showMenu(event, menuItems); + }; + + renderWatchpointButton = item => { + const { removeWatchpoint } = this.props; + + if ( + !item || + !item.contents || + !item.contents.watchpoint || + typeof L10N === "undefined" + ) { + return null; + } + + const { watchpoint } = item.contents; + return ( + <button + className={`remove-watchpoint-${watchpoint}`} + title={L10N.getStr("watchpoints.removeWatchpointTooltip")} + onClick={e => { + e.stopPropagation(); + removeWatchpoint(item); + }} + /> + ); + }; + + renderScopesList() { + const { + cx, + isLoading, + openLink, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + mapScopesEnabled, + selectedFrame, + setExpandedScope, + expandedScopes, + } = this.props; + const { originalScopes, generatedScopes, showOriginal } = this.state; + + const scopes = + (showOriginal && mapScopesEnabled && originalScopes) || generatedScopes; + + function initiallyExpanded(item) { + return expandedScopes.some(path => path == getScopeItemPath(item)); + } + + if (scopes && !!scopes.length && !isLoading) { + return ( + <div className="pane scopes-list"> + <ObjectInspector + roots={scopes} + autoExpandAll={false} + autoExpandDepth={1} + client={clientCommands} + createElement={tagName => document.createElement(tagName)} + disableWrap={true} + dimTopLevelWindow={true} + frame={selectedFrame} + mayUseCustomFormatter={true} + openLink={openLink} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + onContextMenu={this.onContextMenu} + setExpanded={(path, expand) => setExpandedScope(cx, path, expand)} + initiallyExpanded={initiallyExpanded} + renderItemActions={this.renderWatchpointButton} + shouldRenderTooltip={true} + /> + </div> + ); + } + + let stateText = L10N.getStr("scopes.notPaused"); + if (this.props.isPaused) { + if (isLoading) { + stateText = L10N.getStr("loadingText"); + } else { + stateText = L10N.getStr("scopes.notAvailable"); + } + } + + return ( + <div className="pane scopes-list"> + <div className="pane-info">{stateText}</div> + </div> + ); + } + + render() { + return <div className="scopes-content">{this.renderScopesList()}</div>; + } +} + +const mapStateToProps = state => { + const cx = getThreadContext(state); + const selectedFrame = getSelectedFrame(state, cx.thread); + const selectedSource = getSelectedSource(state); + + const { scope: originalFrameScopes, pending: originalPending } = + getOriginalFrameScope( + state, + cx.thread, + selectedSource?.id, + selectedFrame?.id + ) || { scope: null, pending: false }; + + const { scope: generatedFrameScopes, pending: generatedPending } = + getGeneratedFrameScope(state, cx.thread, selectedFrame?.id) || { + scope: null, + pending: false, + }; + + return { + cx, + selectedFrame, + mapScopesEnabled: isMapScopesEnabled(state), + isLoading: generatedPending || originalPending, + why: getPauseReason(state, cx.thread), + originalFrameScopes, + generatedFrameScopes, + expandedScopes: getLastExpandedScopes(state, cx.thread), + isPaused: getIsCurrentThreadPaused(state), + }; +}; + +export default connect(mapStateToProps, { + openLink: actions.openLink, + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, + toggleMapScopes: actions.toggleMapScopes, + setExpandedScope: actions.setExpandedScope, + addWatchpoint: actions.addWatchpoint, + removeWatchpoint: actions.removeWatchpoint, +})(Scopes); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css b/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css new file mode 100644 index 0000000000..dec84252f8 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.secondary-panes { + overflow-x: hidden; + overflow-y: auto; + display: flex; + flex-direction: column; + flex: 1; + white-space: nowrap; + background-color: var(--theme-sidebar-background); + --breakpoint-expression-right-clear-space: 36px; +} + +.secondary-panes .controlled > div { + max-width: 100%; +} + +/* + We apply overflow to the container with the commandbar. + This allows the commandbar to remain fixed when scrolling + until the content completely ends. Not just the height of + the wrapper. + Ref: https://github.com/firefox-devtools/debugger/issues/3426 +*/ + +.secondary-panes-wrapper { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; +} + +.secondary-panes .accordion { + flex: 1 0 auto; + margin-bottom: 0; +} + +.secondary-panes-wrapper .accordion li:last-child ._content { + border-bottom: 0; +} + +.pane { + color: var(--theme-body-color); +} + +.pane .pane-info { + font-style: italic; + text-align: center; + padding: 0.5em; + user-select: none; + cursor: default; +} + +.secondary-panes .breakpoints-buttons { + display: flex; +} + +.dropdown { + width: 20em; + overflow: auto; +} + +.secondary-panes input[type="checkbox"] { + margin: 0; + margin-inline-end: 4px; + vertical-align: middle; +} + +.secondary-panes-wrapper .command-bar.bottom { + background-color: var(--theme-body-background); +} + +/** + * Skip Pausing style + * Add a gray background and lower content opacity + */ +.skip-pausing .xhr-breakpoints-pane ._content, +.skip-pausing .breakpoints-pane ._content, +.skip-pausing .event-listeners-pane ._content, +.skip-pausing .dom-mutations-pane ._content { + background-color: var(--skip-pausing-background-color); + opacity: var(--skip-pausing-opacity); + color: var(--skip-pausing-color); +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Thread.js b/devtools/client/debugger/src/components/SecondaryPanes/Thread.js new file mode 100644 index 0000000000..c9db8a25ef --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Thread.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import actions from "../../actions"; +import { getCurrentThread, getIsPaused, getContext } from "../../selectors"; +import AccessibleImage from "../shared/AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +export class Thread extends Component { + static get propTypes() { + return { + currentThread: PropTypes.string.isRequired, + cx: PropTypes.object.isRequired, + isPaused: PropTypes.bool.isRequired, + selectThread: PropTypes.func.isRequired, + thread: PropTypes.object.isRequired, + }; + } + + onSelectThread = () => { + const { thread } = this.props; + this.props.selectThread(this.props.cx, thread.actor); + }; + + render() { + const { currentThread, isPaused, thread } = this.props; + + const isWorker = thread.targetType.includes("worker"); + let label = thread.name; + if (thread.serviceWorkerStatus) { + label += ` (${thread.serviceWorkerStatus})`; + } + + return ( + <div + className={classnames("thread", { + selected: thread.actor == currentThread, + })} + key={thread.actor} + onClick={this.onSelectThread} + > + <div className="icon"> + <AccessibleImage className={isWorker ? "worker" : "window"} /> + </div> + <div className="label">{label}</div> + {isPaused ? ( + <div className="pause-badge"> + <AccessibleImage className="pause" /> + </div> + ) : null} + </div> + ); + } +} + +const mapStateToProps = (state, props) => ({ + cx: getContext(state), + currentThread: getCurrentThread(state), + isPaused: getIsPaused(state, props.thread.actor), +}); + +export default connect(mapStateToProps, { + selectThread: actions.selectThread, +})(Thread); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Threads.css b/devtools/client/debugger/src/components/SecondaryPanes/Threads.css new file mode 100644 index 0000000000..49e150dd44 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Threads.css @@ -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/>. */ + +.threads-list { + padding: 4px 0; +} + +.threads-list * { + user-select: none; +} + +.threads-list > .thread { + font-size: inherit; + color: var(--theme-text-color-strong); + padding: 2px 6px; + padding-inline-start: 20px; + line-height: 16px; + position: relative; + cursor: pointer; + display: flex; + align-items: center; +} + +.threads-list > .thread:hover { + background-color: var(--search-overlays-semitransparent); +} + +.threads-list > .thread.selected { + background-color: var(--tab-line-selected-color); +} + +.threads-list .icon { + flex: none; + margin-inline-end: 4px; +} + +.threads-list .img { + display: block; +} + +.threads-list .label { + display: inline-block; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.threads-list .pause-badge { + flex: none; + margin-inline-start: 4px; +} + +.threads-list > .thread.selected { + background: var(--theme-selection-background); + color: var(--theme-selection-color); +} + +.threads-list > .thread.selected .img { + background-color: currentColor; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Threads.js b/devtools/client/debugger/src/components/SecondaryPanes/Threads.js new file mode 100644 index 0000000000..4dbf0ff081 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/Threads.js @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; + +import { getAllThreads } from "../../selectors"; +import Thread from "./Thread"; + +import "./Threads.css"; + +export class Threads extends Component { + static get propTypes() { + return { + threads: PropTypes.array.isRequired, + }; + } + + render() { + const { threads } = this.props; + + return ( + <div className="pane threads-list"> + {threads.map(thread => ( + <Thread thread={thread} key={thread.actor} /> + ))} + </div> + ); + } +} + +const mapStateToProps = state => ({ + threads: getAllThreads(state), +}); + +export default connect(mapStateToProps)(Threads); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css new file mode 100644 index 0000000000..cbe2ebf4c9 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.why-paused { + display: flex; + flex-direction: column; + justify-content: center; + border-bottom: 1px solid var(--theme-splitter-color); + background-color: hsl(54, 100%, 92%); + color: var(--theme-body-color); + font-size: 12px; + cursor: default; + min-height: 44px; + padding: 6px; + white-space: normal; + font-weight: bold; +} + +.why-paused > div { + display: flex; + flex-direction: row; + align-items: center; +} + +.why-paused .info.icon { + align-self: center; + padding-right: 4px; + margin-inline-start: 14px; + margin-inline-end: 3px; +} + +.why-paused .pause.reason { + display: flex; + flex-direction: column; + padding-right: 4px; +} + +.theme-dark .secondary-panes .why-paused { + background-color: hsl(42, 37%, 19%); + color: hsl(43, 94%, 81%); +} + +.why-paused .message { + font-style: italic; + font-weight: 100; +} + +.why-paused .mutationNode { + font-weight: normal; +} + +.why-paused .message.warning { + color: var(--theme-graphs-full-red); + font-family: var(--monospace-font-family); + font-size: 10px; + font-style: normal; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js new file mode 100644 index 0000000000..5123649f37 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js @@ -0,0 +1,183 @@ +/* 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/>. */ + +const { + LocalizationProvider, + Localized, +} = require("devtools/client/shared/vendor/fluent-react"); + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import { connect } from "../../utils/connect"; +import AccessibleImage from "../shared/AccessibleImage"; +import actions from "../../actions"; + +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { Rep }, + MODE, +} = Reps; + +import { getPauseReason } from "../../utils/pause"; +import { + getCurrentThread, + getPaneCollapse, + getPauseReason as getWhy, +} from "../../selectors"; + +import "./WhyPaused.css"; + +class WhyPaused extends PureComponent { + constructor(props) { + super(props); + this.state = { hideWhyPaused: "" }; + } + + static get propTypes() { + return { + delay: PropTypes.number.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + highlightDomElement: PropTypes.func.isRequired, + openElementInInspector: PropTypes.func.isRequired, + unHighlightDomElement: PropTypes.func.isRequired, + why: PropTypes.object, + }; + } + + componentDidUpdate() { + const { delay } = this.props; + + if (delay) { + setTimeout(() => { + this.setState({ hideWhyPaused: "" }); + }, delay); + } else { + this.setState({ hideWhyPaused: "pane why-paused" }); + } + } + + renderExceptionSummary(exception) { + if (typeof exception === "string") { + return exception; + } + + const { preview } = exception; + if (!preview || !preview.name || !preview.message) { + return null; + } + + return `${preview.name}: ${preview.message}`; + } + + renderMessage(why) { + const { type, exception, message } = why; + + if (type == "exception" && exception) { + // Our types for 'Why' are too general because 'type' can be 'string'. + // $FlowFixMe - We should have a proper discriminating union of reasons. + const summary = this.renderExceptionSummary(exception); + return <div className="message warning">{summary}</div>; + } + + if (type === "mutationBreakpoint" && why.nodeGrip) { + const { nodeGrip, ancestorGrip, action } = why; + const { + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const targetRep = Rep({ + object: nodeGrip, + mode: MODE.TINY, + onDOMNodeClick: () => openElementInInspector(nodeGrip), + onInspectIconClick: () => openElementInInspector(nodeGrip), + onDOMNodeMouseOver: () => highlightDomElement(nodeGrip), + onDOMNodeMouseOut: () => unHighlightDomElement(), + }); + + const ancestorRep = ancestorGrip + ? Rep({ + object: ancestorGrip, + mode: MODE.TINY, + onDOMNodeClick: () => openElementInInspector(ancestorGrip), + onInspectIconClick: () => openElementInInspector(ancestorGrip), + onDOMNodeMouseOver: () => highlightDomElement(ancestorGrip), + onDOMNodeMouseOut: () => unHighlightDomElement(), + }) + : null; + + return ( + <div> + <div className="message">{why.message}</div> + <div className="mutationNode"> + {ancestorRep} + {ancestorGrip ? ( + <span className="why-paused-ancestor"> + <Localized + id={ + action === "remove" + ? "whypaused-mutation-breakpoint-removed" + : "whypaused-mutation-breakpoint-added" + } + ></Localized> + {targetRep} + </span> + ) : ( + targetRep + )} + </div> + </div> + ); + } + + if (typeof message == "string") { + return <div className="message">{message}</div>; + } + + return null; + } + + render() { + const { endPanelCollapsed, why } = this.props; + const { fluentBundles } = this.context; + const reason = getPauseReason(why); + + if (!why || !reason || endPanelCollapsed) { + return <div className={this.state.hideWhyPaused} />; + } + return ( + // We're rendering the LocalizationProvider component from here and not in an upper + // component because it does set a new context, overriding the context that we set + // in the first place in <App>, which breaks some components. + // This should be fixed in Bug 1743155. + <LocalizationProvider bundles={fluentBundles || []}> + <div className="pane why-paused"> + <div> + <div className="info icon"> + <AccessibleImage className="info" /> + </div> + <div className="pause reason"> + <Localized id={reason}></Localized> + {this.renderMessage(why)} + </div> + </div> + </div> + </LocalizationProvider> + ); + } +} + +WhyPaused.contextTypes = { fluentBundles: PropTypes.array }; + +const mapStateToProps = state => ({ + endPanelCollapsed: getPaneCollapse(state, "end"), + why: getWhy(state, getCurrentThread(state)), +}); + +export default connect(mapStateToProps, { + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, +})(WhyPaused); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css new file mode 100644 index 0000000000..5f0352a93c --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.xhr-breakpoints-pane ._content { + overflow-x: auto; +} + +.xhr-input-container { + display: flex; + border: 1px solid transparent; +} + +.xhr-input-container.focused { + border: 1px solid var(--theme-highlight-blue); +} + +:root.theme-dark .xhr-input-container.focused { + border: 1px solid var(--blue-50); +} + +.xhr-input-container.error { + border: 1px solid red; +} + +.xhr-input-form { + display: inline-flex; + width: 100%; + padding-inline-start: 20px; + padding-inline-end: 12px; + /* Stop select height from increasing as input height increases */ + align-items: center; +} + +.xhr-checkbox { + margin-inline-start: 0; + margin-inline-end: 4px; +} + +.xhr-input-url { + border: 1px; + flex-grow: 1; + background-color: var(--theme-sidebar-background); + font-size: inherit; + height: 24px; + color: var(--theme-body-color); +} + +.xhr-input-url::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.xhr-input-url:focus { + cursor: text; + outline: none; +} + +.expressions-list .xhr-input-container { + height: var(--expression-item-height); +} + +.expressions-list .xhr-input-url { + /* Prevent vertical bounce when editing an existing XHR Breakpoint */ + height: 100%; +} + +.xhr-container { + border-left: 4px solid transparent; + width: 100%; + color: var(--theme-body-color); + padding-inline-start: 16px; + padding-inline-end: 12px; + display: flex; + align-items: center; + position: relative; + height: var(--expression-item-height); +} + +:root.theme-light .xhr-container:hover { + background-color: var(--search-overlays-semitransparent); +} + +:root.theme-dark .xhr-container:hover { + background-color: var(--search-overlays-semitransparent); +} + +.xhr-label-method { + line-height: 14px; + display: inline-block; + margin-inline-end: 2px; +} + +.xhr-input-method { + display: none; + /* Vertically center select in form */ + margin-top: 2px; +} + +.expressions-list .xhr-input-method { + margin-top: 0px; +} + +.xhr-input-container.focused .xhr-input-method { + display: block; +} + +.xhr-label-url { + max-width: calc(100% - var(--breakpoint-expression-right-clear-space)); + color: var(--theme-comment); + display: inline-block; + cursor: text; + flex-grow: 1; + text-overflow: ellipsis; + overflow: hidden; + padding: 0px 2px 0px 2px; + line-height: 14px; +} + +.xhr-container label { + flex-grow: 1; + display: flex; + align-items: center; + overflow-x: hidden; +} + +.xhr-container__close-btn { + display: flex; + padding-top: 2px; + padding-bottom: 2px; +} diff --git a/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js new file mode 100644 index 0000000000..721b132a3b --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js @@ -0,0 +1,361 @@ +/* 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 { CloseButton } from "../shared/Button"; + +import "./XHRBreakpoints.css"; +import { getXHRBreakpoints, shouldPauseOnAnyXHR } from "../../selectors"; +import ExceptionOption from "./Breakpoints/ExceptionOption"; + +const classnames = require("devtools/client/shared/classnames.js"); + +// At present, the "Pause on any URL" checkbox creates an xhrBreakpoint +// of "ANY" with no path, so we can remove that before creating the list +function getExplicitXHRBreakpoints(xhrBreakpoints) { + return xhrBreakpoints.filter(bp => bp.path !== ""); +} + +const xhrMethods = [ + "ANY", + "GET", + "POST", + "PUT", + "HEAD", + "DELETE", + "PATCH", + "OPTIONS", +]; + +class XHRBreakpoints extends Component { + constructor(props) { + super(props); + + this.state = { + editing: false, + inputValue: "", + inputMethod: "ANY", + focused: false, + editIndex: -1, + clickedOnFormElement: false, + }; + } + + static get propTypes() { + return { + disableXHRBreakpoint: PropTypes.func.isRequired, + enableXHRBreakpoint: PropTypes.func.isRequired, + onXHRAdded: PropTypes.func.isRequired, + removeXHRBreakpoint: PropTypes.func.isRequired, + setXHRBreakpoint: PropTypes.func.isRequired, + shouldPauseOnAny: PropTypes.bool.isRequired, + showInput: PropTypes.bool.isRequired, + togglePauseOnAny: PropTypes.func.isRequired, + updateXHRBreakpoint: PropTypes.func.isRequired, + xhrBreakpoints: PropTypes.array.isRequired, + }; + } + + componentDidMount() { + const { showInput } = this.props; + + // Ensures that the input is focused when the "+" + // is clicked while the panel is collapsed + if (this._input && showInput) { + this._input.focus(); + } + } + + componentDidUpdate(prevProps, prevState) { + const input = this._input; + + if (!input) { + return; + } + + if (!prevState.editing && this.state.editing) { + input.setSelectionRange(0, input.value.length); + input.focus(); + } else if (this.props.showInput && !this.state.focused) { + input.focus(); + } + } + + handleNewSubmit = e => { + e.preventDefault(); + e.stopPropagation(); + + const setXHRBreakpoint = function () { + this.props.setXHRBreakpoint( + this.state.inputValue, + this.state.inputMethod + ); + this.hideInput(); + }; + + // force update inputMethod in state for mochitest purposes + // before setting XHR breakpoint + this.setState( + { inputMethod: e.target.children[1].value }, + setXHRBreakpoint + ); + }; + + handleExistingSubmit = e => { + e.preventDefault(); + e.stopPropagation(); + + const { editIndex, inputValue, inputMethod } = this.state; + const { xhrBreakpoints } = this.props; + const { path, method } = xhrBreakpoints[editIndex]; + + if (path !== inputValue || method != inputMethod) { + this.props.updateXHRBreakpoint(editIndex, inputValue, inputMethod); + } + + this.hideInput(); + }; + + handleChange = e => { + this.setState({ inputValue: e.target.value }); + }; + + handleMethodChange = e => { + this.setState({ + focused: true, + editing: true, + inputMethod: e.target.value, + }); + }; + + hideInput = () => { + if (this.state.clickedOnFormElement) { + this.setState({ + focused: true, + clickedOnFormElement: false, + }); + } else { + this.setState({ + focused: false, + editing: false, + editIndex: -1, + inputValue: "", + inputMethod: "ANY", + }); + this.props.onXHRAdded(); + } + }; + + onFocus = () => { + this.setState({ focused: true, editing: true }); + }; + + onMouseDown = e => { + this.setState({ editing: false, clickedOnFormElement: true }); + }; + + handleTab = e => { + if (e.key !== "Tab") { + return; + } + + if (e.currentTarget.nodeName === "INPUT") { + this.setState({ + clickedOnFormElement: true, + editing: false, + }); + } else if (e.currentTarget.nodeName === "SELECT" && !e.shiftKey) { + // The user has tabbed off the select and we should + // cancel the edit + this.hideInput(); + } + }; + + editExpression = index => { + const { xhrBreakpoints } = this.props; + const { path, method } = xhrBreakpoints[index]; + this.setState({ + inputValue: path, + inputMethod: method, + editing: true, + editIndex: index, + }); + }; + + renderXHRInput(onSubmit) { + const { focused, inputValue } = this.state; + const placeholder = L10N.getStr("xhrBreakpoints.placeholder"); + + return ( + <form + key="xhr-input-container" + className={classnames("xhr-input-container xhr-input-form", { + focused, + })} + onSubmit={onSubmit} + > + <input + className="xhr-input-url" + type="text" + placeholder={placeholder} + onChange={this.handleChange} + onBlur={this.hideInput} + onFocus={this.onFocus} + value={inputValue} + onKeyDown={this.handleTab} + ref={c => (this._input = c)} + /> + {this.renderMethodSelectElement()} + <input type="submit" style={{ display: "none" }} /> + </form> + ); + } + + handleCheckbox = index => { + const { xhrBreakpoints, enableXHRBreakpoint, disableXHRBreakpoint } = + this.props; + const breakpoint = xhrBreakpoints[index]; + if (breakpoint.disabled) { + enableXHRBreakpoint(index); + } else { + disableXHRBreakpoint(index); + } + }; + + renderBreakpoint = breakpoint => { + const { path, disabled, method } = breakpoint; + const { editIndex } = this.state; + const { removeXHRBreakpoint, xhrBreakpoints } = this.props; + + // The "pause on any" checkbox + if (!path) { + return null; + } + + // Finds the xhrbreakpoint so as to not make assumptions about position + const index = xhrBreakpoints.findIndex( + bp => bp.path === path && bp.method === method + ); + + if (index === editIndex) { + return this.renderXHRInput(this.handleExistingSubmit); + } + + return ( + <li + className="xhr-container" + key={`${path}-${method}`} + title={path} + onDoubleClick={(items, options) => this.editExpression(index)} + > + <label> + <input + type="checkbox" + className="xhr-checkbox" + checked={!disabled} + onChange={() => this.handleCheckbox(index)} + onClick={ev => ev.stopPropagation()} + /> + <div className="xhr-label-method">{method}</div> + <div className="xhr-label-url">{path}</div> + <div className="xhr-container__close-btn"> + <CloseButton handleClick={e => removeXHRBreakpoint(index)} /> + </div> + </label> + </li> + ); + }; + + renderBreakpoints = explicitXhrBreakpoints => { + const { showInput } = this.props; + + return ( + <> + <ul className="pane expressions-list"> + {explicitXhrBreakpoints.map(this.renderBreakpoint)} + </ul> + {showInput && this.renderXHRInput(this.handleNewSubmit)} + </> + ); + }; + + renderCheckbox = explicitXhrBreakpoints => { + const { shouldPauseOnAny, togglePauseOnAny } = this.props; + + return ( + <div + className={classnames("breakpoints-exceptions-options", { + empty: explicitXhrBreakpoints.length === 0, + })} + > + <ExceptionOption + className="breakpoints-exceptions" + label={L10N.getStr("pauseOnAnyXHR")} + isChecked={shouldPauseOnAny} + onChange={() => togglePauseOnAny()} + /> + </div> + ); + }; + + renderMethodOption = method => { + return ( + <option + key={method} + value={method} + // e.stopPropagation() required here since otherwise Firefox triggers 2x + // onMouseDown events on <select> upon clicking on an <option> + onMouseDown={e => e.stopPropagation()} + > + {method} + </option> + ); + }; + + renderMethodSelectElement = () => { + return ( + <select + value={this.state.inputMethod} + className="xhr-input-method" + onChange={this.handleMethodChange} + onMouseDown={this.onMouseDown} + onKeyDown={this.handleTab} + > + {xhrMethods.map(this.renderMethodOption)} + </select> + ); + }; + + render() { + const { xhrBreakpoints } = this.props; + const explicitXhrBreakpoints = getExplicitXHRBreakpoints(xhrBreakpoints); + + return ( + <> + {this.renderCheckbox(explicitXhrBreakpoints)} + {explicitXhrBreakpoints.length === 0 + ? this.renderXHRInput(this.handleNewSubmit) + : this.renderBreakpoints(explicitXhrBreakpoints)} + </> + ); + } +} + +const mapStateToProps = state => ({ + xhrBreakpoints: getXHRBreakpoints(state), + shouldPauseOnAny: shouldPauseOnAnyXHR(state), +}); + +export default connect(mapStateToProps, { + setXHRBreakpoint: actions.setXHRBreakpoint, + removeXHRBreakpoint: actions.removeXHRBreakpoint, + enableXHRBreakpoint: actions.enableXHRBreakpoint, + disableXHRBreakpoint: actions.disableXHRBreakpoint, + updateXHRBreakpoint: actions.updateXHRBreakpoint, + togglePauseOnAny: actions.togglePauseOnAny, +})(XHRBreakpoints); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/index.js b/devtools/client/debugger/src/components/SecondaryPanes/index.js new file mode 100644 index 0000000000..9b1e2dca60 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/index.js @@ -0,0 +1,537 @@ +/* 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/>. */ + +const SplitBox = require("devtools/client/shared/components/splitter/SplitBox"); + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { isGeneratedId } from "devtools/client/shared/source-map-loader/index"; +import { connect } from "../../utils/connect"; + +import actions from "../../actions"; +import { + getTopFrame, + getExpressions, + getPauseCommand, + isMapScopesEnabled, + getSelectedFrame, + getShouldPauseOnExceptions, + getShouldPauseOnCaughtExceptions, + getThreads, + getCurrentThread, + getThreadContext, + getPauseReason, + getShouldBreakpointsPaneOpenOnPause, + getSkipPausing, + shouldLogEventBreakpoints, +} from "../../selectors"; + +import AccessibleImage from "../shared/AccessibleImage"; +import { prefs } from "../../utils/prefs"; + +import Breakpoints from "./Breakpoints"; +import Expressions from "./Expressions"; +import Frames from "./Frames"; +import Threads from "./Threads"; +import Accordion from "../shared/Accordion"; +import CommandBar from "./CommandBar"; +import XHRBreakpoints from "./XHRBreakpoints"; +import EventListeners from "./EventListeners"; +import DOMMutationBreakpoints from "./DOMMutationBreakpoints"; +import WhyPaused from "./WhyPaused"; + +import Scopes from "./Scopes"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./SecondaryPanes.css"; + +function debugBtn(onClick, type, className, tooltip) { + return ( + <button + onClick={onClick} + className={`${type} ${className}`} + key={type} + title={tooltip} + > + <AccessibleImage className={type} title={tooltip} aria-label={tooltip} /> + </button> + ); +} + +const mdnLink = + "https://firefox-source-docs.mozilla.org/devtools-user/debugger/using_the_debugger_map_scopes_feature/"; + +class SecondaryPanes extends Component { + constructor(props) { + super(props); + + this.state = { + showExpressionsInput: false, + showXHRInput: false, + }; + } + + static get propTypes() { + return { + cx: PropTypes.object.isRequired, + evaluateExpressions: PropTypes.func.isRequired, + expressions: PropTypes.array.isRequired, + hasFrames: PropTypes.bool.isRequired, + horizontal: PropTypes.bool.isRequired, + logEventBreakpoints: PropTypes.bool.isRequired, + mapScopesEnabled: PropTypes.bool.isRequired, + pauseOnExceptions: PropTypes.func.isRequired, + pauseReason: PropTypes.string.isRequired, + shouldBreakpointsPaneOpenOnPause: PropTypes.bool.isRequired, + thread: PropTypes.string.isRequired, + renderWhyPauseDelay: PropTypes.number.isRequired, + selectedFrame: PropTypes.object, + shouldPauseOnCaughtExceptions: PropTypes.bool.isRequired, + shouldPauseOnExceptions: PropTypes.bool.isRequired, + skipPausing: PropTypes.bool.isRequired, + source: PropTypes.object, + toggleEventLogging: PropTypes.func.isRequired, + resetBreakpointsPaneState: PropTypes.func.isRequired, + toggleMapScopes: PropTypes.func.isRequired, + threads: PropTypes.array.isRequired, + removeAllBreakpoints: PropTypes.func.isRequired, + removeAllXHRBreakpoints: PropTypes.func.isRequired, + }; + } + + onExpressionAdded = () => { + this.setState({ showExpressionsInput: false }); + }; + + onXHRAdded = () => { + this.setState({ showXHRInput: false }); + }; + + watchExpressionHeaderButtons() { + const { expressions } = this.props; + const buttons = []; + + if (expressions.length) { + buttons.push( + debugBtn( + evt => { + evt.stopPropagation(); + this.props.evaluateExpressions(this.props.cx); + }, + "refresh", + "active", + L10N.getStr("watchExpressions.refreshButton") + ) + ); + } + buttons.push( + debugBtn( + evt => { + if (prefs.expressionsVisible) { + evt.stopPropagation(); + } + this.setState({ showExpressionsInput: true }); + }, + "plus", + "active", + L10N.getStr("expressions.placeholder") + ) + ); + return buttons; + } + + xhrBreakpointsHeaderButtons() { + return [ + debugBtn( + evt => { + if (prefs.xhrBreakpointsVisible) { + evt.stopPropagation(); + } + this.setState({ showXHRInput: true }); + }, + "plus", + "active", + L10N.getStr("xhrBreakpoints.label") + ), + + debugBtn( + evt => { + evt.stopPropagation(); + this.props.removeAllXHRBreakpoints(); + }, + "removeAll", + "active", + L10N.getStr("xhrBreakpoints.removeAll.tooltip") + ), + ]; + } + + breakpointsHeaderButtons() { + return [ + debugBtn( + evt => { + evt.stopPropagation(); + this.props.removeAllBreakpoints(this.props.cx); + }, + "removeAll", + "active", + L10N.getStr("breakpointMenuItem.deleteAll") + ), + ]; + } + + getScopeItem() { + return { + header: L10N.getStr("scopes.header"), + className: "scopes-pane", + component: <Scopes />, + opened: prefs.scopesVisible, + buttons: this.getScopesButtons(), + onToggle: opened => { + prefs.scopesVisible = opened; + }, + }; + } + + getScopesButtons() { + const { selectedFrame, mapScopesEnabled, source } = this.props; + + if ( + !selectedFrame || + isGeneratedId(selectedFrame.location.sourceId) || + source?.isPrettyPrinted + ) { + return null; + } + + return [ + <div key="scopes-buttons"> + <label + className="map-scopes-header" + title={L10N.getStr("scopes.mapping.label")} + onClick={e => e.stopPropagation()} + > + <input + type="checkbox" + checked={mapScopesEnabled ? "checked" : ""} + onChange={e => this.props.toggleMapScopes()} + /> + {L10N.getStr("scopes.map.label")} + </label> + <a + className="mdn" + target="_blank" + href={mdnLink} + onClick={e => e.stopPropagation()} + title={L10N.getStr("scopes.helpTooltip.label")} + > + <AccessibleImage className="shortcuts" /> + </a> + </div>, + ]; + } + + getEventButtons() { + const { logEventBreakpoints } = this.props; + return [ + <div key="events-buttons"> + <label + className="events-header" + title={L10N.getStr("eventlisteners.log.label")} + onClick={e => e.stopPropagation()} + > + <input + type="checkbox" + checked={logEventBreakpoints ? "checked" : ""} + onChange={e => this.props.toggleEventLogging()} + onKeyDown={e => e.stopPropagation()} + /> + {L10N.getStr("eventlisteners.log")} + </label> + </div>, + ]; + } + + getWatchItem() { + return { + header: L10N.getStr("watchExpressions.header"), + className: "watch-expressions-pane", + buttons: this.watchExpressionHeaderButtons(), + component: ( + <Expressions + showInput={this.state.showExpressionsInput} + onExpressionAdded={this.onExpressionAdded} + /> + ), + opened: prefs.expressionsVisible, + onToggle: opened => { + prefs.expressionsVisible = opened; + }, + }; + } + + getXHRItem() { + const { pauseReason } = this.props; + + return { + header: L10N.getStr("xhrBreakpoints.header"), + className: "xhr-breakpoints-pane", + buttons: this.xhrBreakpointsHeaderButtons(), + component: ( + <XHRBreakpoints + showInput={this.state.showXHRInput} + onXHRAdded={this.onXHRAdded} + /> + ), + opened: prefs.xhrBreakpointsVisible || pauseReason === "XHR", + onToggle: opened => { + prefs.xhrBreakpointsVisible = opened; + }, + }; + } + + getCallStackItem() { + return { + header: L10N.getStr("callStack.header"), + className: "call-stack-pane", + component: <Frames panel="debugger" />, + opened: prefs.callStackVisible, + onToggle: opened => { + prefs.callStackVisible = opened; + }, + }; + } + + getThreadsItem() { + return { + header: L10N.getStr("threadsHeader"), + className: "threads-pane", + component: <Threads />, + opened: prefs.threadsVisible, + onToggle: opened => { + prefs.threadsVisible = opened; + }, + }; + } + + getBreakpointsItem() { + const { + shouldPauseOnExceptions, + shouldPauseOnCaughtExceptions, + pauseOnExceptions, + pauseReason, + shouldBreakpointsPaneOpenOnPause, + thread, + } = this.props; + + return { + header: L10N.getStr("breakpoints.header"), + className: "breakpoints-pane", + buttons: this.breakpointsHeaderButtons(), + component: ( + <Breakpoints + shouldPauseOnExceptions={shouldPauseOnExceptions} + shouldPauseOnCaughtExceptions={shouldPauseOnCaughtExceptions} + pauseOnExceptions={pauseOnExceptions} + /> + ), + opened: + prefs.breakpointsVisible || + (pauseReason === "breakpoint" && shouldBreakpointsPaneOpenOnPause), + onToggle: opened => { + prefs.breakpointsVisible = opened; + // one-shot flag used to force open the Breakpoints Pane only + // when hitting a breakpoint, but not when selecting frames etc... + if (shouldBreakpointsPaneOpenOnPause) { + this.props.resetBreakpointsPaneState(thread); + } + }, + }; + } + + getEventListenersItem() { + const { pauseReason } = this.props; + + return { + header: L10N.getStr("eventListenersHeader1"), + className: "event-listeners-pane", + buttons: this.getEventButtons(), + component: <EventListeners />, + opened: prefs.eventListenersVisible || pauseReason === "eventBreakpoint", + onToggle: opened => { + prefs.eventListenersVisible = opened; + }, + }; + } + + getDOMMutationsItem() { + const { pauseReason } = this.props; + + return { + header: L10N.getStr("domMutationHeader"), + className: "dom-mutations-pane", + buttons: [], + component: <DOMMutationBreakpoints />, + opened: + prefs.domMutationBreakpointsVisible || + pauseReason === "mutationBreakpoint", + onToggle: opened => { + prefs.domMutationBreakpointsVisible = opened; + }, + }; + } + + getStartItems() { + const items = []; + const { horizontal, hasFrames } = this.props; + + if (horizontal) { + if (this.props.threads.length) { + items.push(this.getThreadsItem()); + } + + items.push(this.getWatchItem()); + } + + items.push(this.getBreakpointsItem()); + + if (hasFrames) { + items.push(this.getCallStackItem()); + if (horizontal) { + items.push(this.getScopeItem()); + } + } + + items.push(this.getXHRItem()); + + items.push(this.getEventListenersItem()); + + items.push(this.getDOMMutationsItem()); + + return items; + } + + getEndItems() { + if (this.props.horizontal) { + return []; + } + + const items = []; + if (this.props.threads.length) { + items.push(this.getThreadsItem()); + } + + items.push(this.getWatchItem()); + + if (this.props.hasFrames) { + items.push(this.getScopeItem()); + } + + return items; + } + + getItems() { + return [...this.getStartItems(), ...this.getEndItems()]; + } + + renderHorizontalLayout() { + const { renderWhyPauseDelay } = this.props; + + return ( + <div> + <WhyPaused delay={renderWhyPauseDelay} /> + <Accordion items={this.getItems()} /> + </div> + ); + } + + renderVerticalLayout() { + return ( + <SplitBox + initialSize="300px" + minSize={10} + maxSize="50%" + splitterSize={1} + startPanel={ + <div style={{ width: "inherit" }}> + <WhyPaused delay={this.props.renderWhyPauseDelay} /> + <Accordion items={this.getStartItems()} /> + </div> + } + endPanel={<Accordion items={this.getEndItems()} />} + /> + ); + } + + render() { + const { skipPausing } = this.props; + return ( + <div className="secondary-panes-wrapper"> + <CommandBar horizontal={this.props.horizontal} /> + <div + className={classnames( + "secondary-panes", + skipPausing && "skip-pausing" + )} + > + {this.props.horizontal + ? this.renderHorizontalLayout() + : this.renderVerticalLayout()} + </div> + </div> + ); + } +} + +// Checks if user is in debugging mode and adds a delay preventing +// excessive vertical 'jumpiness' +function getRenderWhyPauseDelay(state, thread) { + const inPauseCommand = !!getPauseCommand(state, thread); + + if (!inPauseCommand) { + return 100; + } + + return 0; +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + const selectedFrame = getSelectedFrame(state, thread); + const pauseReason = getPauseReason(state, thread); + const shouldBreakpointsPaneOpenOnPause = getShouldBreakpointsPaneOpenOnPause( + state, + thread + ); + + return { + cx: getThreadContext(state), + expressions: getExpressions(state), + hasFrames: !!getTopFrame(state, thread), + renderWhyPauseDelay: getRenderWhyPauseDelay(state, thread), + selectedFrame, + mapScopesEnabled: isMapScopesEnabled(state), + shouldPauseOnExceptions: getShouldPauseOnExceptions(state), + shouldPauseOnCaughtExceptions: getShouldPauseOnCaughtExceptions(state), + threads: getThreads(state), + skipPausing: getSkipPausing(state), + logEventBreakpoints: shouldLogEventBreakpoints(state), + source: selectedFrame && selectedFrame.location.source, + pauseReason: pauseReason?.type ?? "", + shouldBreakpointsPaneOpenOnPause, + thread, + }; +}; + +export default connect(mapStateToProps, { + evaluateExpressions: actions.evaluateExpressions, + pauseOnExceptions: actions.pauseOnExceptions, + toggleMapScopes: actions.toggleMapScopes, + breakOnNext: actions.breakOnNext, + toggleEventLogging: actions.toggleEventLogging, + removeAllBreakpoints: actions.removeAllBreakpoints, + removeAllXHRBreakpoints: actions.removeAllXHRBreakpoints, + resetBreakpointsPaneState: actions.resetBreakpointsPaneState, +})(SecondaryPanes); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/moz.build new file mode 100644 index 0000000000..33cfa2e316 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/moz.build @@ -0,0 +1,22 @@ +# 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 += [ + "Breakpoints", + "Frames", +] + +CompiledModules( + "CommandBar.js", + "DOMMutationBreakpoints.js", + "EventListeners.js", + "Expressions.js", + "index.js", + "Scopes.js", + "Thread.js", + "Threads.js", + "WhyPaused.js", + "XHRBreakpoints.js", +) diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js new file mode 100644 index 0000000000..69dd75a187 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js @@ -0,0 +1,77 @@ +/* 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 { shallow } from "enzyme"; +import CommandBar from "../CommandBar"; +import { mockthreadcx } from "../../../utils/test-mockup"; + +describe("CommandBar", () => { + it("f8 key command calls props.breakOnNext when not in paused state", () => { + const props = { + cx: mockthreadcx, + breakOnNext: jest.fn(), + resume: jest.fn(), + isPaused: false, + }; + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + // The "on" spy will see all the keyboard listeners being registered by + // the shortcuts.on function + const context = { shortcuts: { on: jest.fn() } }; + + shallow(<CommandBar.WrappedComponent {...props} />, { context }); + + // get the keyboard event listeners recorded from the "on" spy. + // this will be an array where each item is itself a two item array + // containing the key code and the corresponding handler for that key code + const keyEventHandlers = context.shortcuts.on.mock.calls; + + // simulate pressing the F8 key by calling the F8 handlers + keyEventHandlers + .filter(i => i[0] === "F8") + .forEach(([_, handler]) => { + handler(mockEvent); + }); + + expect(props.breakOnNext).toHaveBeenCalled(); + expect(props.resume).not.toHaveBeenCalled(); + }); + + it("f8 key command calls props.resume when in paused state", () => { + const props = { + cx: { ...mockthreadcx, isPaused: true }, + breakOnNext: jest.fn(), + resume: jest.fn(), + isPaused: true, + }; + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + // The "on" spy will see all the keyboard listeners being registered by + // the shortcuts.on function + const context = { shortcuts: { on: jest.fn() } }; + + shallow(<CommandBar.WrappedComponent {...props} />, { context }); + + // get the keyboard event listeners recorded from the "on" spy. + // this will be an array where each item is itself a two item array + // containing the key code and the corresponding handler for that key code + const keyEventHandlers = context.shortcuts.on.mock.calls; + + // simulate pressing the F8 key by calling the F8 handlers + keyEventHandlers + .filter(i => i[0] === "F8") + .forEach(([_, handler]) => { + handler(mockEvent); + }); + expect(props.resume).toHaveBeenCalled(); + expect(props.breakOnNext).not.toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js new file mode 100644 index 0000000000..f82b2093c9 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js @@ -0,0 +1,134 @@ +/* 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 { shallow } from "enzyme"; +import EventListeners from "../EventListeners"; + +function getCategories() { + return [ + { + name: "Category 1", + events: [ + { name: "Subcategory 1", id: "category1.subcategory1" }, + { name: "Subcategory 2", id: "category1.subcategory2" }, + ], + }, + { + name: "Category 2", + events: [ + { name: "Subcategory 3", id: "category2.subcategory1" }, + { name: "Subcategory 4", id: "category2.subcategory2" }, + ], + }, + ]; +} + +function generateDefaults(overrides = {}) { + const defaults = { + activeEventListeners: [], + expandedCategories: [], + categories: [], + }; + + return { ...defaults, ...overrides }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<EventListeners.WrappedComponent {...props} />); + return { component, props }; +} + +describe("EventListeners", () => { + it("should render", async () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("should render categories appropriately", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + }; + const { component } = render(props); + expect(component).toMatchSnapshot(); + }); + + it("should render expanded categories appropriately", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + expandedCategories: ["Category 2"], + }; + const { component } = render(props); + expect(component).toMatchSnapshot(); + }); + + it("should render checked subcategories appropriately", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + activeEventListeners: ["category1.subcategory2"], + expandedCategories: ["Category 1"], + }; + const { component } = render(props); + expect(component).toMatchSnapshot(); + }); + + it("should filter the event listeners based on the event name", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + }; + const { component } = render(props); + component.find(".event-search-input").simulate("focus"); + + const searchInput = component.find(".event-search-input"); + // Simulate a search query of "Subcategory 3" to display just one event which + // will be the Subcategory 3 event + searchInput.simulate("change", { + currentTarget: { value: "Subcategory 3" }, + }); + + const displayedEvents = component.find(".event-listener-event"); + expect(displayedEvents).toHaveLength(1); + }); + + it("should filter the event listeners based on the category name", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + }; + const { component } = render(props); + component.find(".event-search-input").simulate("focus"); + + const searchInput = component.find(".event-search-input"); + // Simulate a search query of "Category 1" to display two events which will be + // the Subcategory 1 event and the Subcategory 2 event + searchInput.simulate("change", { currentTarget: { value: "Category 1" } }); + + const displayedEvents = component.find(".event-listener-event"); + expect(displayedEvents).toHaveLength(2); + }); + + it("should be case insensitive when filtering events and categories", async () => { + const props = { + ...generateDefaults(), + categories: getCategories(), + }; + const { component } = render(props); + component.find(".event-search-input").simulate("focus"); + + const searchInput = component.find(".event-search-input"); + // Simulate a search query of "Subcategory 3" to display just one event which + // will be the Subcategory 3 event + searchInput.simulate("change", { + currentTarget: { value: "sUbCaTeGoRy 3" }, + }); + + const displayedEvents = component.find(".event-listener-event"); + expect(displayedEvents).toHaveLength(1); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js new file mode 100644 index 0000000000..ad14190276 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js @@ -0,0 +1,75 @@ +/* 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 { shallow } from "enzyme"; +import Expressions from "../Expressions"; + +function generateDefaults(overrides) { + return { + evaluateExpressions: async () => {}, + expressions: [ + { + input: "expression1", + value: { + result: { + value: "foo", + class: "", + }, + }, + }, + { + input: "expression2", + value: { + result: { + value: "bar", + class: "", + }, + }, + }, + ], + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<Expressions.WrappedComponent {...props} />); + return { component, props }; +} + +describe("Expressions", () => { + it("should render", async () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("should always have unique keys", async () => { + const overrides = { + expressions: [ + { + input: "expression1", + value: { + result: { + value: undefined, + class: "", + }, + }, + }, + { + input: "expression2", + value: { + result: { + value: undefined, + class: "", + }, + }, + }, + ], + }; + + const { component } = render(overrides); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js new file mode 100644 index 0000000000..e269e89ac5 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js @@ -0,0 +1,345 @@ +/* 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 { mount } from "enzyme"; +import XHRBreakpoints from "../XHRBreakpoints"; + +const xhrMethods = [ + "ANY", + "GET", + "POST", + "PUT", + "HEAD", + "DELETE", + "PATCH", + "OPTIONS", +]; + +// default state includes xhrBreakpoints[0] which is the checkbox that +// enables breaking on any url during an XMLHTTPRequest +function generateDefaultState(propsOverride) { + return { + xhrBreakpoints: [ + { + path: "", + method: "ANY", + disabled: false, + loading: false, + text: 'URL contains ""', + }, + ], + enableXHRBreakpoint: () => {}, + disableXHRBreakpoint: () => {}, + updateXHRBreakpoint: () => {}, + removeXHRBreakpoint: () => {}, + setXHRBreakpoint: () => {}, + togglePauseOnAny: () => {}, + showInput: false, + shouldPauseOnAny: false, + onXHRAdded: () => {}, + ...propsOverride, + }; +} + +function renderXHRBreakpointsComponent(propsOverride) { + const props = generateDefaultState(propsOverride); + const xhrBreakpointsComponent = mount( + <XHRBreakpoints.WrappedComponent {...props} /> + ); + return xhrBreakpointsComponent; +} + +describe("XHR Breakpoints", function () { + it("should render with 0 expressions passed from props", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + expect(xhrBreakpointsComponent).toMatchSnapshot(); + }); + + it("should render with 8 expressions passed from props", function () { + const allXHRBreakpointMethods = { + xhrBreakpoints: [ + { + path: "", + method: "ANY", + disabled: false, + loading: false, + text: 'URL contains ""', + }, + { + path: "this is any", + method: "ANY", + disabled: false, + loading: false, + text: 'URL contains "this is any"', + }, + { + path: "this is get", + method: "GET", + disabled: false, + loading: false, + text: 'URL contains "this is get"', + }, + { + path: "this is post", + method: "POST", + disabled: false, + loading: false, + text: 'URL contains "this is post"', + }, + { + path: "this is put", + method: "PUT", + disabled: false, + loading: false, + text: 'URL contains "this is put"', + }, + { + path: "this is head", + method: "HEAD", + disabled: false, + loading: false, + text: 'URL contains "this is head"', + }, + { + path: "this is delete", + method: "DELETE", + disabled: false, + loading: false, + text: 'URL contains "this is delete"', + }, + { + path: "this is patch", + method: "PATCH", + disabled: false, + loading: false, + text: 'URL contains "this is patch"', + }, + { + path: "this is options", + method: "OPTIONS", + disabled: false, + loading: false, + text: 'URL contains "this is options"', + }, + ], + }; + + const xhrBreakpointsComponent = renderXHRBreakpointsComponent( + allXHRBreakpointMethods + ); + expect(xhrBreakpointsComponent).toMatchSnapshot(); + }); + + it("should display xhr-input-method on click", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + + const xhrInputContainer = xhrBreakpointsComponent.find( + ".xhr-input-container" + ); + expect(xhrInputContainer.hasClass("focused")).toBeTruthy(); + }); + + it("should have focused and editing default to false", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + expect(xhrBreakpointsComponent.state("focused")).toBe(false); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + }); + + it("should have state {..focused: true, editing: true} on focus", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + expect(xhrBreakpointsComponent.state("focused")).toBe(true); + expect(xhrBreakpointsComponent.state("editing")).toBe(true); + }); + + // shifting focus from .xhr-input to any other element apart from + // .xhr-input-method should unrender .xhr-input-method + it("shifting focus should unrender XHR methods", function () { + const propsOverride = { + onXHRAdded: jest.fn, + togglePauseOnAny: jest.fn, + }; + const xhrBreakpointsComponent = + renderXHRBreakpointsComponent(propsOverride); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + let xhrInputContainer = xhrBreakpointsComponent.find( + ".xhr-input-container" + ); + expect(xhrInputContainer.hasClass("focused")).toBeTruthy(); + + xhrBreakpointsComponent + .find(".breakpoints-exceptions-options") + .simulate("mousedown"); + expect(xhrBreakpointsComponent.state("focused")).toBe(true); + expect(xhrBreakpointsComponent.state("editing")).toBe(true); + expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false); + + xhrBreakpointsComponent.find(".xhr-input-url").simulate("blur"); + expect(xhrBreakpointsComponent.state("focused")).toBe(false); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false); + + xhrBreakpointsComponent + .find(".breakpoints-exceptions-options") + .simulate("click"); + + xhrInputContainer = xhrBreakpointsComponent.find(".xhr-input-container"); + expect(xhrInputContainer.hasClass("focused")).not.toBeTruthy(); + }); + + // shifting focus from .xhr-input to .xhr-input-method + // should not unrender .xhr-input-method + it("shifting focus to XHR methods should not unrender", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + + xhrBreakpointsComponent.find(".xhr-input-method").simulate("mousedown"); + expect(xhrBreakpointsComponent.state("focused")).toBe(true); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(true); + + xhrBreakpointsComponent.find(".xhr-input-url").simulate("blur"); + expect(xhrBreakpointsComponent.state("focused")).toBe(true); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false); + + xhrBreakpointsComponent.find(".xhr-input-method").simulate("click"); + const xhrInputContainer = xhrBreakpointsComponent.find( + ".xhr-input-container" + ); + expect(xhrInputContainer.hasClass("focused")).toBeTruthy(); + }); + + it("should have all 8 methods available as options", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + + const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method"); + expect(xhrInputMethod.children()).toHaveLength(8); + + const actualXHRMethods = []; + const expectedXHRMethods = xhrMethods; + + // fill the actualXHRMethods array with actual methods displayed in DOM + for (let i = 0; i < xhrInputMethod.children().length; i++) { + actualXHRMethods.push(xhrInputMethod.childAt(i).key()); + } + + // check each expected XHR Method to see if they match the actual methods + expectedXHRMethods.forEach((expectedMethod, i) => { + function compareMethods(actualMethod) { + return expectedMethod === actualMethod; + } + expect(actualXHRMethods.find(compareMethods)).toBeTruthy(); + }); + }); + + it("should return focus to input box after selecting a method", function () { + const xhrBreakpointsComponent = renderXHRBreakpointsComponent(); + + // focus starts off at .xhr-input + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + + // click on method options and select GET + const methodEvent = { target: { value: "GET" } }; + xhrBreakpointsComponent.find(".xhr-input-method").simulate("mousedown"); + expect(xhrBreakpointsComponent.state("inputMethod")).toBe("ANY"); + expect(xhrBreakpointsComponent.state("editing")).toBe(false); + xhrBreakpointsComponent + .find(".xhr-input-method") + .simulate("change", methodEvent); + + // if state.editing changes from false to true, infer that + // this._input.focus() is called, which shifts focus back to input box + expect(xhrBreakpointsComponent.state("inputMethod")).toBe("GET"); + expect(xhrBreakpointsComponent.state("editing")).toBe(true); + }); + + it("should submit the URL and method when adding a breakpoint", function () { + const setXHRBreakpointCallback = jest.fn(); + const propsOverride = { + setXHRBreakpoint: setXHRBreakpointCallback, + onXHRAdded: jest.fn(), + }; + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const availableXHRMethods = xhrMethods; + expect(!!availableXHRMethods.length).toBeTruthy(); + + // check each of the available methods to see whether + // adding them as a method to a new breakpoint works as expected + availableXHRMethods.forEach(function (method) { + const xhrBreakpointsComponent = + renderXHRBreakpointsComponent(propsOverride); + xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus"); + const urlValue = `${method.toLowerCase()}URLValue`; + + // simulate DOM event adding urlValue to .xhr-input + const xhrInput = xhrBreakpointsComponent.find(".xhr-input-url"); + xhrInput.simulate("change", { target: { value: urlValue } }); + + // simulate DOM event adding the input method to .xhr-input-method + const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method"); + xhrInputMethod.simulate("change", { target: { value: method } }); + + xhrBreakpointsComponent.find("form").simulate("submit", mockEvent); + expect(setXHRBreakpointCallback).toHaveBeenCalledWith(urlValue, method); + }); + }); + + it("should submit the URL and method when editing a breakpoint", function () { + const setXHRBreakpointCallback = jest.fn(); + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const propsOverride = { + updateXHRBreakpoint: setXHRBreakpointCallback, + onXHRAdded: jest.fn(), + xhrBreakpoints: [ + { + path: "", + method: "ANY", + disabled: false, + loading: false, + text: 'URL contains ""', + }, + { + path: "this is GET", + method: "GET", + disabled: false, + loading: false, + text: 'URL contains "this is get"', + }, + ], + }; + const xhrBreakpointsComponent = + renderXHRBreakpointsComponent(propsOverride); + + // load xhrBreakpoints pane with one existing xhrBreakpoint + const existingXHRbreakpoint = + xhrBreakpointsComponent.find(".xhr-container"); + expect(existingXHRbreakpoint).toHaveLength(1); + + // double click on existing breakpoint + existingXHRbreakpoint.simulate("doubleclick"); + const xhrInput = xhrBreakpointsComponent.find(".xhr-input-url"); + xhrInput.simulate("focus"); + + // change inputs and submit form + const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method"); + xhrInput.simulate("change", { target: { value: "POSTURLValue" } }); + xhrInputMethod.simulate("change", { target: { value: "POST" } }); + xhrBreakpointsComponent.find("form").simulate("submit", mockEvent); + expect(setXHRBreakpointCallback).toHaveBeenCalledWith( + 1, + "POSTURLValue", + "POST" + ); + }); +}); diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap new file mode 100644 index 0000000000..cc2ddf09f6 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap @@ -0,0 +1,408 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EventListeners should render 1`] = ` +<div + className="event-listeners" +> + <div + className="event-search-container" + > + <form + className="event-search-form" + onSubmit={[Function]} + > + <input + className="event-search-input" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter by event type" + value="" + /> + </form> + </div> + <div + className="event-listeners-content" + > + <ul + className="event-listeners-list" + /> + </div> +</div> +`; + +exports[`EventListeners should render categories appropriately 1`] = ` +<div + className="event-listeners" +> + <div + className="event-search-container" + > + <form + className="event-search-form" + onSubmit={[Function]} + > + <input + className="event-search-input" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter by event type" + value="" + /> + </form> + </div> + <div + className="event-listeners-content" + > + <ul + className="event-listeners-list" + > + <li + className="event-listener-group" + key="0" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 1" + /> + <span + className="event-listener-category" + > + Category 1 + </span> + </label> + </div> + </li> + <li + className="event-listener-group" + key="1" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 2" + /> + <span + className="event-listener-category" + > + Category 2 + </span> + </label> + </div> + </li> + </ul> + </div> +</div> +`; + +exports[`EventListeners should render checked subcategories appropriately 1`] = ` +<div + className="event-listeners" +> + <div + className="event-search-container" + > + <form + className="event-search-form" + onSubmit={[Function]} + > + <input + className="event-search-input" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter by event type" + value="" + /> + </form> + </div> + <div + className="event-listeners-content" + > + <ul + className="event-listeners-list" + > + <li + className="event-listener-group" + key="0" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow expanded" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 1" + /> + <span + className="event-listener-category" + > + Category 1 + </span> + </label> + </div> + <ul> + <li + className="event-listener-event" + key="category1.subcategory1" + > + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="category1.subcategory1" + /> + <span + className="event-listener-name" + > + Subcategory 1 + </span> + </label> + </li> + <li + className="event-listener-event" + key="category1.subcategory2" + > + <label + className="event-listener-label" + > + <input + checked={true} + onChange={[Function]} + type="checkbox" + value="category1.subcategory2" + /> + <span + className="event-listener-name" + > + Subcategory 2 + </span> + </label> + </li> + </ul> + </li> + <li + className="event-listener-group" + key="1" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 2" + /> + <span + className="event-listener-category" + > + Category 2 + </span> + </label> + </div> + </li> + </ul> + </div> +</div> +`; + +exports[`EventListeners should render expanded categories appropriately 1`] = ` +<div + className="event-listeners" +> + <div + className="event-search-container" + > + <form + className="event-search-form" + onSubmit={[Function]} + > + <input + className="event-search-input" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter by event type" + value="" + /> + </form> + </div> + <div + className="event-listeners-content" + > + <ul + className="event-listeners-list" + > + <li + className="event-listener-group" + key="0" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 1" + /> + <span + className="event-listener-category" + > + Category 1 + </span> + </label> + </div> + </li> + <li + className="event-listener-group" + key="1" + > + <div + className="event-listener-header" + > + <button + className="event-listener-expand" + onClick={[Function]} + > + <AccessibleImage + className="arrow expanded" + /> + </button> + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="Category 2" + /> + <span + className="event-listener-category" + > + Category 2 + </span> + </label> + </div> + <ul> + <li + className="event-listener-event" + key="category2.subcategory1" + > + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="category2.subcategory1" + /> + <span + className="event-listener-name" + > + Subcategory 3 + </span> + </label> + </li> + <li + className="event-listener-event" + key="category2.subcategory2" + > + <label + className="event-listener-label" + > + <input + checked={false} + onChange={[Function]} + type="checkbox" + value="category2.subcategory2" + /> + <span + className="event-listener-name" + > + Subcategory 4 + </span> + </label> + </li> + </ul> + </li> + </ul> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap new file mode 100644 index 0000000000..4869b15a73 --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap @@ -0,0 +1,199 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Expressions should always have unique keys 1`] = ` +<Fragment> + <ul + className="pane expressions-list" + > + <li + className="expression-container" + key="expression1" + title="expression1" + > + <div + className="expression-content" + > + <Component + autoExpandDepth={0} + createElement={[Function]} + disableWrap={true} + mayUseCustomFormatter={true} + onDOMNodeClick={[Function]} + onDOMNodeMouseOut={[Function]} + onDOMNodeMouseOver={[Function]} + onDoubleClick={[Function]} + onInspectIconClick={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "front": null, + "value": Object { + "class": "", + "value": undefined, + }, + }, + "name": "expression1", + "path": "expression1", + }, + ] + } + shouldRenderTooltip={true} + /> + <div + className="expression-container__close-btn" + > + <CloseButton + handleClick={[Function]} + tooltip="Remove watch expression" + /> + </div> + </div> + </li> + <li + className="expression-container" + key="expression2" + title="expression2" + > + <div + className="expression-content" + > + <Component + autoExpandDepth={0} + createElement={[Function]} + disableWrap={true} + mayUseCustomFormatter={true} + onDOMNodeClick={[Function]} + onDOMNodeMouseOut={[Function]} + onDOMNodeMouseOver={[Function]} + onDoubleClick={[Function]} + onInspectIconClick={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "front": null, + "value": Object { + "class": "", + "value": undefined, + }, + }, + "name": "expression2", + "path": "expression2", + }, + ] + } + shouldRenderTooltip={true} + /> + <div + className="expression-container__close-btn" + > + <CloseButton + handleClick={[Function]} + tooltip="Remove watch expression" + /> + </div> + </div> + </li> + </ul> +</Fragment> +`; + +exports[`Expressions should render 1`] = ` +<Fragment> + <ul + className="pane expressions-list" + > + <li + className="expression-container" + key="expression1" + title="expression1" + > + <div + className="expression-content" + > + <Component + autoExpandDepth={0} + createElement={[Function]} + disableWrap={true} + mayUseCustomFormatter={true} + onDOMNodeClick={[Function]} + onDOMNodeMouseOut={[Function]} + onDOMNodeMouseOver={[Function]} + onDoubleClick={[Function]} + onInspectIconClick={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "front": null, + "value": Object { + "class": "", + "value": "foo", + }, + }, + "name": "expression1", + "path": "expression1", + }, + ] + } + shouldRenderTooltip={true} + /> + <div + className="expression-container__close-btn" + > + <CloseButton + handleClick={[Function]} + tooltip="Remove watch expression" + /> + </div> + </div> + </li> + <li + className="expression-container" + key="expression2" + title="expression2" + > + <div + className="expression-content" + > + <Component + autoExpandDepth={0} + createElement={[Function]} + disableWrap={true} + mayUseCustomFormatter={true} + onDOMNodeClick={[Function]} + onDOMNodeMouseOut={[Function]} + onDOMNodeMouseOver={[Function]} + onDoubleClick={[Function]} + onInspectIconClick={[Function]} + roots={ + Array [ + Object { + "contents": Object { + "front": null, + "value": Object { + "class": "", + "value": "bar", + }, + }, + "name": "expression2", + "path": "expression2", + }, + ] + } + shouldRenderTooltip={true} + /> + <div + className="expression-container__close-btn" + > + <CloseButton + handleClick={[Function]} + tooltip="Remove watch expression" + /> + </div> + </div> + </li> + </ul> +</Fragment> +`; diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap new file mode 100644 index 0000000000..5611f6ceef --- /dev/null +++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap @@ -0,0 +1,621 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`XHR Breakpoints should render with 0 expressions passed from props 1`] = ` +<XHRBreakpoints + disableXHRBreakpoint={[Function]} + enableXHRBreakpoint={[Function]} + onXHRAdded={[Function]} + removeXHRBreakpoint={[Function]} + setXHRBreakpoint={[Function]} + shouldPauseOnAny={false} + showInput={false} + togglePauseOnAny={[Function]} + updateXHRBreakpoint={[Function]} + xhrBreakpoints={ + Array [ + Object { + "disabled": false, + "loading": false, + "method": "ANY", + "path": "", + "text": "URL contains \\"\\"", + }, + ] + } +> + <div + className="breakpoints-exceptions-options empty" + > + <ExceptionOption + className="breakpoints-exceptions" + isChecked={false} + label="Pause on any URL" + onChange={[Function]} + > + <div + className="breakpoints-exceptions" + onClick={[Function]} + > + <input + checked="" + onChange={[Function]} + type="checkbox" + /> + <div + className="breakpoint-exceptions-label" + > + Pause on any URL + </div> + </div> + </ExceptionOption> + </div> + <form + className="xhr-input-container xhr-input-form" + key="xhr-input-container" + onSubmit={[Function]} + > + <input + className="xhr-input-url" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Break when URL contains" + type="text" + value="" + /> + <select + className="xhr-input-method" + onChange={[Function]} + onKeyDown={[Function]} + onMouseDown={[Function]} + value="ANY" + > + <option + key="ANY" + onMouseDown={[Function]} + value="ANY" + > + ANY + </option> + <option + key="GET" + onMouseDown={[Function]} + value="GET" + > + GET + </option> + <option + key="POST" + onMouseDown={[Function]} + value="POST" + > + POST + </option> + <option + key="PUT" + onMouseDown={[Function]} + value="PUT" + > + PUT + </option> + <option + key="HEAD" + onMouseDown={[Function]} + value="HEAD" + > + HEAD + </option> + <option + key="DELETE" + onMouseDown={[Function]} + value="DELETE" + > + DELETE + </option> + <option + key="PATCH" + onMouseDown={[Function]} + value="PATCH" + > + PATCH + </option> + <option + key="OPTIONS" + onMouseDown={[Function]} + value="OPTIONS" + > + OPTIONS + </option> + </select> + <input + style={ + Object { + "display": "none", + } + } + type="submit" + /> + </form> +</XHRBreakpoints> +`; + +exports[`XHR Breakpoints should render with 8 expressions passed from props 1`] = ` +<XHRBreakpoints + disableXHRBreakpoint={[Function]} + enableXHRBreakpoint={[Function]} + onXHRAdded={[Function]} + removeXHRBreakpoint={[Function]} + setXHRBreakpoint={[Function]} + shouldPauseOnAny={false} + showInput={false} + togglePauseOnAny={[Function]} + updateXHRBreakpoint={[Function]} + xhrBreakpoints={ + Array [ + Object { + "disabled": false, + "loading": false, + "method": "ANY", + "path": "", + "text": "URL contains \\"\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "ANY", + "path": "this is any", + "text": "URL contains \\"this is any\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "GET", + "path": "this is get", + "text": "URL contains \\"this is get\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "POST", + "path": "this is post", + "text": "URL contains \\"this is post\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "PUT", + "path": "this is put", + "text": "URL contains \\"this is put\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "HEAD", + "path": "this is head", + "text": "URL contains \\"this is head\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "DELETE", + "path": "this is delete", + "text": "URL contains \\"this is delete\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "PATCH", + "path": "this is patch", + "text": "URL contains \\"this is patch\\"", + }, + Object { + "disabled": false, + "loading": false, + "method": "OPTIONS", + "path": "this is options", + "text": "URL contains \\"this is options\\"", + }, + ] + } +> + <div + className="breakpoints-exceptions-options" + > + <ExceptionOption + className="breakpoints-exceptions" + isChecked={false} + label="Pause on any URL" + onChange={[Function]} + > + <div + className="breakpoints-exceptions" + onClick={[Function]} + > + <input + checked="" + onChange={[Function]} + type="checkbox" + /> + <div + className="breakpoint-exceptions-label" + > + Pause on any URL + </div> + </div> + </ExceptionOption> + </div> + <ul + className="pane expressions-list" + > + <li + className="xhr-container" + key="this is any-ANY" + onDoubleClick={[Function]} + title="this is any" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + ANY + </div> + <div + className="xhr-label-url" + > + this is any + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is get-GET" + onDoubleClick={[Function]} + title="this is get" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + GET + </div> + <div + className="xhr-label-url" + > + this is get + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is post-POST" + onDoubleClick={[Function]} + title="this is post" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + POST + </div> + <div + className="xhr-label-url" + > + this is post + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is put-PUT" + onDoubleClick={[Function]} + title="this is put" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + PUT + </div> + <div + className="xhr-label-url" + > + this is put + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is head-HEAD" + onDoubleClick={[Function]} + title="this is head" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + HEAD + </div> + <div + className="xhr-label-url" + > + this is head + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is delete-DELETE" + onDoubleClick={[Function]} + title="this is delete" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + DELETE + </div> + <div + className="xhr-label-url" + > + this is delete + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is patch-PATCH" + onDoubleClick={[Function]} + title="this is patch" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + PATCH + </div> + <div + className="xhr-label-url" + > + this is patch + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + <li + className="xhr-container" + key="this is options-OPTIONS" + onDoubleClick={[Function]} + title="this is options" + > + <label> + <input + checked={true} + className="xhr-checkbox" + onChange={[Function]} + onClick={[Function]} + type="checkbox" + /> + <div + className="xhr-label-method" + > + OPTIONS + </div> + <div + className="xhr-label-url" + > + this is options + </div> + <div + className="xhr-container__close-btn" + > + <CloseButton + handleClick={[Function]} + > + <button + className="close-btn" + onClick={[Function]} + > + <AccessibleImage + className="close" + > + <span + className="img close" + /> + </AccessibleImage> + </button> + </CloseButton> + </div> + </label> + </li> + </ul> +</XHRBreakpoints> +`; diff --git a/devtools/client/debugger/src/components/ShortcutsModal.css b/devtools/client/debugger/src/components/ShortcutsModal.css new file mode 100644 index 0000000000..84024f9677 --- /dev/null +++ b/devtools/client/debugger/src/components/ShortcutsModal.css @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.shortcuts-content { + padding: 15px; + column-width: 250px; + cursor: default; + user-select: none; +} + +.shortcuts-content h2 { + margin-top: 2px; + margin-bottom: 2px; + color: var(--theme-text-color-strong); +} + +.shortcuts-section { + display: inline-block; + margin: 5px; + margin-bottom: 15px; + width: 250px; +} + +.shortcuts-list { + list-style: none; + margin: 0px; + padding: 0px; + overflow: auto; + width: calc(100% - 1px); /* 1px fixes the hidden right border */ +} + +.shortcuts-list li { + font-size: 12px; + color: var(--theme-body-color); + padding-top: 5px; + display: flex; + justify-content: space-between; + border: 1px solid transparent; + white-space: pre; +} + +@media (max-width: 640px) { + .shortcuts-section { + width: 100%; + } +} diff --git a/devtools/client/debugger/src/components/ShortcutsModal.js b/devtools/client/debugger/src/components/ShortcutsModal.js new file mode 100644 index 0000000000..fd9696e93f --- /dev/null +++ b/devtools/client/debugger/src/components/ShortcutsModal.js @@ -0,0 +1,135 @@ +/* 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 Modal from "./shared/Modal"; +import { formatKeyShortcut } from "../utils/text"; +const classnames = require("devtools/client/shared/classnames.js"); + +import "./ShortcutsModal.css"; + +const isMacOS = Services.appinfo.OS === "Darwin"; + +export class ShortcutsModal extends Component { + static get propTypes() { + return { + enabled: PropTypes.bool.isRequired, + handleClose: PropTypes.func.isRequired, + }; + } + + renderPrettyCombos(combo) { + return combo + .split(" ") + .map(c => ( + <span key={c} className="keystroke"> + {c} + </span> + )) + .reduce((prev, curr) => [prev, " + ", curr]); + } + + renderShorcutItem(title, combo) { + return ( + <li> + <span>{title}</span> + <span>{this.renderPrettyCombos(combo)}</span> + </li> + ); + } + + renderEditorShortcuts() { + return ( + <ul className="shortcuts-list"> + {this.renderShorcutItem( + L10N.getStr("shortcuts.toggleBreakpoint"), + formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.toggleCondPanel.breakpoint"), + formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.toggleCondPanel.logPoint"), + formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")) + )} + </ul> + ); + } + + renderSteppingShortcuts() { + return ( + <ul className="shortcuts-list"> + {this.renderShorcutItem(L10N.getStr("shortcuts.pauseOrResume"), "F8")} + {this.renderShorcutItem(L10N.getStr("shortcuts.stepOver"), "F10")} + {this.renderShorcutItem(L10N.getStr("shortcuts.stepIn"), "F11")} + {this.renderShorcutItem( + L10N.getStr("shortcuts.stepOut"), + formatKeyShortcut(L10N.getStr("stepOut.key")) + )} + </ul> + ); + } + + renderSearchShortcuts() { + return ( + <ul className="shortcuts-list"> + {this.renderShorcutItem( + L10N.getStr("shortcuts.fileSearch2"), + formatKeyShortcut(L10N.getStr("sources.search.key2")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.projectSearch2"), + formatKeyShortcut(L10N.getStr("projectTextSearch.key")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.functionSearch2"), + formatKeyShortcut(L10N.getStr("functionSearch.key")) + )} + {this.renderShorcutItem( + L10N.getStr("shortcuts.gotoLine"), + formatKeyShortcut(L10N.getStr("gotoLineModal.key3")) + )} + </ul> + ); + } + + renderShortcutsContent() { + return ( + <div className={classnames("shortcuts-content", isMacOS ? "mac" : "")}> + <div className="shortcuts-section"> + <h2>{L10N.getStr("shortcuts.header.editor")}</h2> + {this.renderEditorShortcuts()} + </div> + <div className="shortcuts-section"> + <h2>{L10N.getStr("shortcuts.header.stepping")}</h2> + {this.renderSteppingShortcuts()} + </div> + <div className="shortcuts-section"> + <h2>{L10N.getStr("shortcuts.header.search")}</h2> + {this.renderSearchShortcuts()} + </div> + </div> + ); + } + + render() { + const { enabled } = this.props; + + if (!enabled) { + return null; + } + + return ( + <Modal + in={enabled} + additionalClass="shortcuts-modal" + handleClose={this.props.handleClose} + > + {this.renderShortcutsContent()} + </Modal> + ); + } +} diff --git a/devtools/client/debugger/src/components/WelcomeBox.css b/devtools/client/debugger/src/components/WelcomeBox.css new file mode 100644 index 0000000000..a0932625ae --- /dev/null +++ b/devtools/client/debugger/src/components/WelcomeBox.css @@ -0,0 +1,83 @@ +/* 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/>. */ + +.welcomebox { + position: absolute; + top: var(--editor-header-height); + left: 0; + bottom: var(--editor-footer-height); + width: calc(100% - 1px); + padding: 10vh 0; + background-color: var(--theme-toolbar-background); + overflow: hidden; + font-weight: 300; + z-index: 10; + user-select: none; +} + +.theme-dark .welcomebox { + background-color: var(--theme-body-background); +} + +.alignlabel { + display: flex; + white-space: nowrap; + font-size: 1.25em; +} + +.shortcutKey, +.shortcutLabel { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.welcomebox__searchSources:hover, +.welcomebox__searchProject:hover, +.welcomebox__allShortcuts:hover { + color: var(--theme-body-color); +} + +.shortcutKey { + direction: ltr; + text-align: right; + padding-right: 10px; + font-family: var(--monospace-font-family); + font-size: 14px; + line-height: 18px; + color: var(--theme-body-color); +} + +.shortcutKey:dir(rtl) { + text-align: left; +} + +:root[platform="mac"] .welcomebox .shortcutKey { + font-family: system-ui, -apple-system, sans-serif; + font-weight: 500; +} + +.shortcutLabel { + text-align: start; + padding-left: 10px; + font-size: 14px; + line-height: 18px; +} + +.shortcutFunction { + margin: 0 auto; + color: var(--theme-comment); + display: table; +} + +.shortcutFunction p { + display: table-row; +} + +.shortcutFunction .shortcutKey, +.shortcutFunction .shortcutLabel { + padding: 10px 5px; + display: table-cell; +} diff --git a/devtools/client/debugger/src/components/WelcomeBox.js b/devtools/client/debugger/src/components/WelcomeBox.js new file mode 100644 index 0000000000..18567d31f7 --- /dev/null +++ b/devtools/client/debugger/src/components/WelcomeBox.js @@ -0,0 +1,94 @@ +/* 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 { primaryPaneTabs } from "../constants"; + +import actions from "../actions"; +import { getPaneCollapse } from "../selectors"; +import { formatKeyShortcut } from "../utils/text"; + +import "./WelcomeBox.css"; + +export class WelcomeBox extends Component { + static get propTypes() { + return { + openQuickOpen: PropTypes.func.isRequired, + setActiveSearch: PropTypes.func.isRequired, + toggleShortcutsModal: PropTypes.func.isRequired, + setPrimaryPaneTab: PropTypes.func.isRequired, + }; + } + + render() { + const searchSourcesShortcut = formatKeyShortcut( + L10N.getStr("sources.search.key2") + ); + + const searchProjectShortcut = formatKeyShortcut( + L10N.getStr("projectTextSearch.key") + ); + + const allShortcutsShortcut = formatKeyShortcut( + L10N.getStr("allShortcut.key") + ); + + const allShortcutsLabel = L10N.getStr("welcome.allShortcuts"); + const searchSourcesLabel = L10N.getStr("welcome.search2").substring(2); + const searchProjectLabel = L10N.getStr("welcome.findInFiles2").substring(2); + + return ( + <div className="welcomebox"> + <div className="alignlabel"> + <div className="shortcutFunction"> + <p + className="welcomebox__searchSources" + role="button" + tabIndex="0" + onClick={() => this.props.openQuickOpen()} + > + <span className="shortcutKey">{searchSourcesShortcut}</span> + <span className="shortcutLabel">{searchSourcesLabel}</span> + </p> + <p + className="welcomebox__searchProject" + role="button" + tabIndex="0" + onClick={() => { + this.props.setActiveSearch(primaryPaneTabs.PROJECT_SEARCH); + this.props.setPrimaryPaneTab(primaryPaneTabs.PROJECT_SEARCH); + }} + > + <span className="shortcutKey">{searchProjectShortcut}</span> + <span className="shortcutLabel">{searchProjectLabel}</span> + </p> + <p + className="welcomebox__allShortcuts" + role="button" + tabIndex="0" + onClick={() => this.props.toggleShortcutsModal()} + > + <span className="shortcutKey">{allShortcutsShortcut}</span> + <span className="shortcutLabel">{allShortcutsLabel}</span> + </p> + </div> + </div> + </div> + ); + } +} + +const mapStateToProps = state => ({ + endPanelCollapsed: getPaneCollapse(state, "end"), +}); + +export default connect(mapStateToProps, { + togglePaneCollapse: actions.togglePaneCollapse, + setActiveSearch: actions.setActiveSearch, + openQuickOpen: actions.openQuickOpen, + setPrimaryPaneTab: actions.setPrimaryPaneTab, +})(WelcomeBox); diff --git a/devtools/client/debugger/src/components/moz.build b/devtools/client/debugger/src/components/moz.build new file mode 100644 index 0000000000..41ced8d474 --- /dev/null +++ b/devtools/client/debugger/src/components/moz.build @@ -0,0 +1,19 @@ +# 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 += [ + "Editor", + "PrimaryPanes", + "SecondaryPanes", + "shared", +] + +CompiledModules( + "A11yIntention.js", + "App.js", + "QuickOpenModal.js", + "ShortcutsModal.js", + "WelcomeBox.js", +) diff --git a/devtools/client/debugger/src/components/shared/AccessibleImage.css b/devtools/client/debugger/src/components/shared/AccessibleImage.css new file mode 100644 index 0000000000..06b8149325 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/AccessibleImage.css @@ -0,0 +1,194 @@ +/* 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/>. */ + +.img { + display: inline-block; + width: 16px; + height: 16px; + vertical-align: middle; + /* use background-color for the icon color, and mask-image for its shape */ + background-color: var(--theme-icon-color); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + /* multicolor icons use background-image */ + background-position: center; + background-repeat: no-repeat; + background-size: contain; + /* do not let images shrink when used as flex children */ + flex-shrink: 0; +} + +/* Expand arrow icon */ +.img.arrow { + width: 10px; + height: 10px; + mask-image: url(chrome://devtools/content/debugger/images/arrow.svg); + /* we may override the width/height in specific contexts to make the + clickable area bigger, but we should always keep the mask size 10x10 */ + mask-size: 10px 10px; + background-color: var(--theme-icon-dimmed-color); + transform: rotate(-90deg); + transition: transform 180ms var(--animation-curve); +} + +.img.arrow:dir(rtl) { + transform: rotate(90deg); +} + +.img.arrow.expanded { + /* icon should always point to the bottom (default) when expanded, + regardless of the text direction */ + transform: none !important; +} + +.img.arrow-down { + mask-image: url(chrome://devtools/content/debugger/images/arrow-down.svg); +} + +.img.arrow-up { + mask-image: url(chrome://devtools/content/debugger/images/arrow-up.svg); +} + +.img.blackBox { + mask-image: url(chrome://devtools/content/debugger/images/blackBox.svg); +} + +.img.breadcrumb { + mask-image: url(chrome://devtools/content/debugger/images/breadcrumbs-divider.svg); +} + +.img.close { + mask-image: url(chrome://devtools/skin/images/close.svg); +} + +.img.disable-pausing { + mask-image: url(chrome://devtools/content/debugger/images/disable-pausing.svg); +} + +.img.enable-pausing { + mask-image: url(chrome://devtools/content/debugger/images/enable-pausing.svg); + background-color: var(--theme-icon-checked-color); +} + +.img.globe { + mask-image: url(chrome://devtools/content/debugger/images/globe.svg); +} + +.img.globe-small { + mask-image: url(chrome://devtools/content/debugger/images/globe-small.svg); + mask-size: 12px 12px; +} + +.img.window { + mask-image: url(chrome://devtools/content/debugger/images/window.svg); +} + +.img.file { + mask-image: url(chrome://devtools/content/debugger/images/file-small.svg); + mask-size: 12px 12px; +} + +.img.folder { + mask-image: url(chrome://devtools/content/debugger/images/folder.svg); +} + +.img.home { + mask-image: url(chrome://devtools/content/debugger/images/home.svg); +} + +.img.info { + mask-image: url(chrome://devtools/skin/images/info.svg); +} + +.img.loader { + background-image: url(chrome://devtools/content/debugger/images/loader.svg); + -moz-context-properties: fill; + fill: var(--theme-icon-color); + background-color: unset; +} + +.img.more-tabs { + mask-image: url(chrome://devtools/content/debugger/images/command-chevron.svg); +} + +html[dir="rtl"] .img.more-tabs { + transform: scaleX(-1); +} + +.img.next { + mask-image: url(chrome://devtools/content/debugger/images/next.svg); +} + +.img.next-circle { + mask-image: url(chrome://devtools/content/debugger/images/next-circle.svg); +} + +.img.pane-collapse { + mask-image: url(chrome://devtools/content/debugger/images/pane-collapse.svg); +} + +.img.pane-expand { + mask-image: url(chrome://devtools/content/debugger/images/pane-expand.svg); +} + +.img.pause { + mask-image: url(chrome://devtools/content/debugger/images/pause.svg); +} + +.img.plus { + mask-image: url(chrome://devtools/skin/images/add.svg); +} + +.img.prettyPrint { + background-image: url(chrome://devtools/content/debugger/images/prettyPrint.svg); + background-size: 14px 14px; + background-color: transparent !important; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.img.removeAll { + mask-image: url(chrome://devtools/skin/images/clear.svg) +} + +.img.refresh { + mask-image: url(chrome://devtools/skin/images/reload.svg); +} + +.img.resume { + mask-image: url(chrome://devtools/content/shared/images/resume.svg); +} + +.img.search { + mask-image: url(chrome://devtools/content/debugger/images/search.svg); +} + +.img.shortcuts { + mask-image: url(chrome://devtools/content/debugger/images/help.svg); +} + +.img.spin { + animation: spin 0.5s linear infinite; +} + +.img.stepIn { + mask-image: url(chrome://devtools/content/debugger/images/stepIn.svg); +} + +.img.stepOut { + mask-image: url(chrome://devtools/content/debugger/images/stepOut.svg); +} + +.img.stepOver { + mask-image: url(chrome://devtools/content/shared/images/stepOver.svg); +} + +.img.tab { + mask-image: url(chrome://devtools/content/debugger/images/tab.svg); +} + +.img.worker { + mask-image: url(chrome://devtools/content/debugger/images/worker.svg); +} diff --git a/devtools/client/debugger/src/components/shared/AccessibleImage.js b/devtools/client/debugger/src/components/shared/AccessibleImage.js new file mode 100644 index 0000000000..1ac3510c36 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/AccessibleImage.js @@ -0,0 +1,24 @@ +/* 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 PropTypes from "prop-types"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./AccessibleImage.css"; + +const AccessibleImage = props => { + props = { + ...props, + className: classnames("img", props.className), + }; + return <span {...props} />; +}; + +AccessibleImage.propTypes = { + className: PropTypes.string.isRequired, +}; + +export default AccessibleImage; diff --git a/devtools/client/debugger/src/components/shared/Accordion.css b/devtools/client/debugger/src/components/shared/Accordion.css new file mode 100644 index 0000000000..e87fa41a6f --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Accordion.css @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.accordion { + background-color: var(--theme-sidebar-background); + width: 100%; + list-style-type: none; + padding: 0px; + margin-top: 0px; +} + +.accordion ._header { + background-color: var(--theme-accordion-header-background); + border-bottom: 1px solid var(--theme-splitter-color); + display: flex; + font-size: 12px; + line-height: calc(16 / 12); + padding: 4px 6px; + width: 100%; + align-items: center; + margin: 0px; + font-weight: normal; + cursor: default; + user-select: none; +} + +.accordion ._header:hover { + background-color: var(--theme-accordion-header-hover); +} + +.accordion ._header .arrow { + margin-inline-end: 4px; +} + +.accordion ._header .header-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--theme-toolbar-color); +} + +.accordion ._header .header-buttons { + display: flex; + margin-inline-start: auto; +} + +.accordion ._header .header-buttons button { + color: var(--theme-body-color); + border: none; + background: none; + padding: 0; + margin: 0 2px; + width: 16px; + height: 16px; +} + +.accordion ._header .header-buttons button::-moz-focus-inner { + border: none; +} + +.accordion ._header .header-buttons button .img { + display: block; +} + +.accordion ._content { + border-bottom: 1px solid var(--theme-splitter-color); + font-size: var(--theme-body-font-size); +} + +.accordion div:last-child ._content { + border-bottom: none; +} diff --git a/devtools/client/debugger/src/components/shared/Accordion.js b/devtools/client/debugger/src/components/shared/Accordion.js new file mode 100644 index 0000000000..fba307abaf --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Accordion.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { cloneElement, Component } from "react"; +import PropTypes from "prop-types"; +import AccessibleImage from "./AccessibleImage"; + +import "./Accordion.css"; + +class Accordion extends Component { + static get propTypes() { + return { + items: PropTypes.array.isRequired, + }; + } + + handleHeaderClick(i) { + const item = this.props.items[i]; + const opened = !item.opened; + item.opened = opened; + + if (item.onToggle) { + item.onToggle(opened); + } + + // We force an update because otherwise the accordion + // would not re-render + this.forceUpdate(); + } + + onHandleHeaderKeyDown(e, i) { + if (e && (e.key === " " || e.key === "Enter")) { + this.handleHeaderClick(i); + } + } + + renderContainer = (item, i) => { + const { opened } = item; + + return ( + <li className={item.className} key={i}> + <h2 + className="_header" + tabIndex="0" + onKeyDown={e => this.onHandleHeaderKeyDown(e, i)} + onClick={() => this.handleHeaderClick(i)} + > + <AccessibleImage className={`arrow ${opened ? "expanded" : ""}`} /> + <span className="header-label">{item.header}</span> + {item.buttons ? ( + <div className="header-buttons" tabIndex="-1"> + {item.buttons} + </div> + ) : null} + </h2> + {opened && ( + <div className="_content"> + {cloneElement(item.component, item.componentProps || {})} + </div> + )} + </li> + ); + }; + render() { + return ( + <ul className="accordion"> + {this.props.items.map(this.renderContainer)} + </ul> + ); + } +} + +export default Accordion; diff --git a/devtools/client/debugger/src/components/shared/Badge.css b/devtools/client/debugger/src/components/shared/Badge.css new file mode 100644 index 0000000000..f52d32edf4 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Badge.css @@ -0,0 +1,16 @@ +/* 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/>. */ + +.badge { + --size: 17px; + --radius: calc(var(--size) / 2); + height: var(--size); + min-width: var(--size); + line-height: var(--size); + background: var(--theme-toolbar-background-hover); + color: var(--theme-body-color); + border-radius: var(--radius); + padding: 0 4px; + font-size: 0.9em; +} diff --git a/devtools/client/debugger/src/components/shared/Badge.js b/devtools/client/debugger/src/components/shared/Badge.js new file mode 100644 index 0000000000..58519e0246 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Badge.js @@ -0,0 +1,17 @@ +/* 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 PropTypes from "prop-types"; +import "./Badge.css"; + +const Badge = ({ children }) => ( + <span className="badge text-white text-center">{children}</span> +); + +Badge.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default Badge; diff --git a/devtools/client/debugger/src/components/shared/BracketArrow.css b/devtools/client/debugger/src/components/shared/BracketArrow.css new file mode 100644 index 0000000000..afca888371 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/BracketArrow.css @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.bracket-arrow { + position: absolute; + pointer-events: none; +} + +.bracket-arrow::before, +.bracket-arrow::after { + content: ""; + height: 0; + width: 0; + position: absolute; + border: 7px solid transparent; +} + +.bracket-arrow.up::before { + border-bottom-color: var(--theme-splitter-color); + top: -1px; +} + +.theme-dark .bracket-arrow.up::before { + border-bottom-color: var(--theme-body-color); +} + +.bracket-arrow.up::after { + border-bottom-color: var(--theme-body-background); + top: 0px; +} + +.bracket-arrow.down::before { + border-bottom-color: transparent; + border-top-color: var(--theme-splitter-color); + top: 0px; +} + +.theme-dark .bracket-arrow.down::before { + border-top-color: var(--theme-body-color); +} + +.bracket-arrow.down::after { + border-bottom-color: transparent; + border-top-color: var(--theme-body-background); + top: -1px; +} + +.bracket-arrow.left::before { + border-left-color: transparent; + border-right-color: var(--theme-splitter-color); + top: 0px; +} + +.theme-dark .bracket-arrow.left::before { + border-right-color: var(--theme-body-color); +} + +.bracket-arrow.left::after { + border-left-color: transparent; + border-right-color: var(--theme-body-background); + top: 0px; + left: 1px; +} diff --git a/devtools/client/debugger/src/components/shared/BracketArrow.js b/devtools/client/debugger/src/components/shared/BracketArrow.js new file mode 100644 index 0000000000..2e0c3fbf0e --- /dev/null +++ b/devtools/client/debugger/src/components/shared/BracketArrow.js @@ -0,0 +1,28 @@ +/* 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 PropTypes from "prop-types"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./BracketArrow.css"; + +const BracketArrow = ({ orientation, left, top, bottom }) => { + return ( + <div + className={classnames("bracket-arrow", orientation || "up")} + style={{ left, top, bottom }} + /> + ); +}; + +BracketArrow.propTypes = { + bottom: PropTypes.number, + left: PropTypes.number, + orientation: PropTypes.string.isRequired, + top: PropTypes.number, +}; + +export default BracketArrow; diff --git a/devtools/client/debugger/src/components/shared/Button/CloseButton.js b/devtools/client/debugger/src/components/shared/Button/CloseButton.js new file mode 100644 index 0000000000..2450b4aae2 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/CloseButton.js @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import PropTypes from "prop-types"; + +import AccessibleImage from "../AccessibleImage"; + +import "./styles/CloseButton.css"; + +function CloseButton({ handleClick, buttonClass, tooltip }) { + return ( + <button + className={buttonClass ? `close-btn ${buttonClass}` : "close-btn"} + onClick={handleClick} + title={tooltip} + > + <AccessibleImage className="close" /> + </button> + ); +} + +CloseButton.propTypes = { + buttonClass: PropTypes.string, + handleClick: PropTypes.func.isRequired, + tooltip: PropTypes.string, +}; + +export default CloseButton; diff --git a/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js b/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js new file mode 100644 index 0000000000..f1579b6f7a --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js @@ -0,0 +1,56 @@ +/* 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 PropTypes from "prop-types"; + +import AccessibleImage from "../AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./styles/CommandBarButton.css"; + +export function debugBtn( + onClick, + type, + className, + tooltip, + disabled = false, + ariaPressed = false +) { + return ( + <CommandBarButton + className={classnames(type, className)} + disabled={disabled} + key={type} + onClick={onClick} + pressed={ariaPressed} + title={tooltip} + > + <AccessibleImage className={type} /> + </CommandBarButton> + ); +} + +const CommandBarButton = props => { + const { children, className, pressed = false, ...rest } = props; + + return ( + <button + aria-pressed={pressed} + className={classnames("command-bar-button", className)} + {...rest} + > + {children} + </button> + ); +}; + +CommandBarButton.propTypes = { + children: PropTypes.node.isRequired, + className: PropTypes.string.isRequired, + pressed: PropTypes.bool, +}; + +export default CommandBarButton; diff --git a/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js b/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js new file mode 100644 index 0000000000..ba2f20e882 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js @@ -0,0 +1,61 @@ +/* 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, { PureComponent } from "react"; +import PropTypes from "prop-types"; +import AccessibleImage from "../AccessibleImage"; +import { CommandBarButton } from "./"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./styles/PaneToggleButton.css"; + +class PaneToggleButton extends PureComponent { + static defaultProps = { + horizontal: false, + position: "start", + }; + + static get propTypes() { + return { + collapsed: PropTypes.bool.isRequired, + handleClick: PropTypes.func.isRequired, + horizontal: PropTypes.bool.isRequired, + position: PropTypes.oneOf(["start", "end"]).isRequired, + }; + } + + label(position, collapsed) { + switch (position) { + case "start": + return L10N.getStr(collapsed ? "expandSources" : "collapseSources"); + case "end": + return L10N.getStr( + collapsed ? "expandBreakpoints" : "collapseBreakpoints" + ); + } + return null; + } + + render() { + const { position, collapsed, horizontal, handleClick } = this.props; + + return ( + <CommandBarButton + className={classnames("toggle-button", position, { + collapsed, + vertical: !horizontal, + })} + onClick={() => handleClick(position, !collapsed)} + title={this.label(position, collapsed)} + > + <AccessibleImage + className={collapsed ? "pane-expand" : "pane-collapse"} + /> + </CommandBarButton> + ); + } +} + +export default PaneToggleButton; diff --git a/devtools/client/debugger/src/components/shared/Button/index.js b/devtools/client/debugger/src/components/shared/Button/index.js new file mode 100644 index 0000000000..df7976ba90 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/index.js @@ -0,0 +1,9 @@ +/* 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 CloseButton from "./CloseButton"; +import CommandBarButton, { debugBtn } from "./CommandBarButton"; +import PaneToggleButton from "./PaneToggleButton"; + +export { CloseButton, CommandBarButton, debugBtn, PaneToggleButton }; diff --git a/devtools/client/debugger/src/components/shared/Button/moz.build b/devtools/client/debugger/src/components/shared/Button/moz.build new file mode 100644 index 0000000000..c6e652d5dc --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/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 += [ + "styles", +] + +CompiledModules( + "CloseButton.js", + "CommandBarButton.js", + "index.js", + "PaneToggleButton.js", +) diff --git a/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css b/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css new file mode 100644 index 0000000000..b0093ff4de --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css @@ -0,0 +1,36 @@ +/* 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/>. */ + +.close-btn { + width: 16px; + height: 16px; + border: 1px solid transparent; + border-radius: 2px; + padding: 1px; + color: var(--theme-icon-color); +} + +.close-btn:hover, +.close-btn:focus { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} + +.close-btn .img { + display: block; + width: 12px; + height: 12px; + /* inherit the button's text color for the icon's color */ + background-color: currentColor; +} + +.close-btn.big { + width: 20px; + height: 20px; +} + +.close-btn.big .img { + width: 16px; + height: 16px; +} diff --git a/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css b/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css new file mode 100644 index 0000000000..5b03bca8ec --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css @@ -0,0 +1,61 @@ +/* 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/>. */ + +.command-bar-button { + appearance: none; + background: transparent; + border: none; + display: inline-block; + text-align: center; + position: relative; + padding: 0px 5px; + fill: currentColor; + min-width: 30px; +} + +.command-bar-button:disabled { + opacity: 0.6; + cursor: default; +} + +.command-bar-button:not(.disabled):hover, +.devtools-button.debugger-settings-menu-button:empty:enabled:not([aria-expanded="true"]):hover { + background: var(--theme-toolbar-background-hover); +} + +.theme-dark .command-bar-button:not(.disabled):hover, +.devtools-button.debugger-settings-menu-button:empty:enabled:not([aria-expanded="true"]):hover { + background: var(--theme-toolbar-hover); +} + +:root.theme-dark .command-bar-button { + color: var(--theme-body-color); +} + +.command-bar-button > * { + width: 16px; + height: 16px; + display: inline-block; + vertical-align: middle; +} + +/** + * Settings icon and menu + */ +.devtools-button.debugger-settings-menu-button { + border-radius: 0; + margin: 0; + padding: 0; +} + +.devtools-button.debugger-settings-menu-button::before { + background-image: url("chrome://devtools/skin/images/settings.svg"); +} + +.devtools-button.debugger-trace-menu-button::before { + background-image: url(chrome://devtools/content/debugger/images/trace.svg); +} +.devtools-button.debugger-trace-menu-button.active::before { + fill: var(--theme-icon-checked-color); +} diff --git a/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css b/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css new file mode 100644 index 0000000000..d8a2495408 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css @@ -0,0 +1,29 @@ +/* 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/>. */ + +.toggle-button { + padding: 4px 6px; +} + +.toggle-button .img { + vertical-align: middle; +} + +.toggle-button.end { + margin-inline-end: 0px; + margin-inline-start: auto; +} + +.toggle-button.start { + margin-inline-start: 0px; +} + +html[dir="rtl"] .toggle-button.start .img, +html[dir="ltr"] .toggle-button.end:not(.vertical) .img { + transform: scaleX(-1); +} + +.toggle-button.end.vertical .img { + transform: rotate(-90deg); +} diff --git a/devtools/client/debugger/src/components/shared/Button/styles/moz.build b/devtools/client/debugger/src/components/shared/Button/styles/moz.build new file mode 100644 index 0000000000..7d80140dbe --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/styles/moz.build @@ -0,0 +1,8 @@ +# 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() diff --git a/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js new file mode 100644 index 0000000000..cb426ddada --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js @@ -0,0 +1,24 @@ +/* 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 { shallow } from "enzyme"; +import { CloseButton } from "../"; + +describe("CloseButton", () => { + it("renders with tooltip", () => { + const tooltip = "testTooltip"; + const wrapper = shallow( + <CloseButton tooltip={tooltip} handleClick={() => {}} /> + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("handles click event", () => { + const handleClickSpy = jest.fn(); + const wrapper = shallow(<CloseButton handleClick={handleClickSpy} />); + wrapper.simulate("click"); + expect(handleClickSpy).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js new file mode 100644 index 0000000000..1da7dc9fed --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js @@ -0,0 +1,36 @@ +/* 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 { shallow } from "enzyme"; +import { CommandBarButton, debugBtn } from "../"; + +describe("CommandBarButton", () => { + it("renders", () => { + const wrapper = shallow(<CommandBarButton children={[]} className={""} />); + expect(wrapper).toMatchSnapshot(); + }); + + it("renders children", () => { + const children = [1, 2, 3, 4]; + const wrapper = shallow( + <CommandBarButton children={children} className={""} /> + ); + expect(wrapper.find("button").children()).toHaveLength(4); + }); +}); + +describe("debugBtn", () => { + it("renders", () => { + const wrapper = shallow(debugBtn()); + expect(wrapper).toMatchSnapshot(); + }); + + it("handles onClick", () => { + const onClickSpy = jest.fn(); + const wrapper = shallow(debugBtn(onClickSpy)); + wrapper.simulate("click"); + expect(onClickSpy).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js new file mode 100644 index 0000000000..59fbe11fc6 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js @@ -0,0 +1,51 @@ +/* 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 { shallow } from "enzyme"; +import { PaneToggleButton } from "../"; + +describe("PaneToggleButton", () => { + const handleClickSpy = jest.fn(); + const wrapper = shallow( + <PaneToggleButton + handleClick={handleClickSpy} + collapsed={false} + position="start" + /> + ); + + it("renders default", () => { + expect(wrapper.hasClass("vertical")).toBe(true); + expect(wrapper).toMatchSnapshot(); + }); + + it("toggles horizontal class", () => { + wrapper.setProps({ horizontal: true }); + expect(wrapper.hasClass("vertical")).toBe(false); + }); + + it("toggles collapsed class", () => { + wrapper.setProps({ collapsed: true }); + expect(wrapper.hasClass("collapsed")).toBe(true); + }); + + it("toggles start position", () => { + wrapper.setProps({ position: "start" }); + expect(wrapper.hasClass("start")).toBe(true); + }); + + it("toggles end position ", () => { + wrapper.setProps({ position: "end" }); + expect(wrapper.hasClass("end")).toBe(true); + }); + + it("handleClick is called", () => { + const position = "end"; + const collapsed = false; + wrapper.setProps({ position, collapsed }); + wrapper.simulate("click"); + expect(handleClickSpy).toHaveBeenCalledWith(position, true); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap new file mode 100644 index 0000000000..d0a0cb9967 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CloseButton renders with tooltip 1`] = ` +<button + className="close-btn" + onClick={[Function]} + title="testTooltip" +> + <AccessibleImage + className="close" + /> +</button> +`; diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap new file mode 100644 index 0000000000..cebcb5892c --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CommandBarButton renders 1`] = ` +<button + aria-pressed={false} + className="command-bar-button" +/> +`; + +exports[`debugBtn renders 1`] = ` +<button + aria-pressed={false} + className="command-bar-button" + disabled={false} +> + <AccessibleImage /> +</button> +`; diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap new file mode 100644 index 0000000000..86067066a6 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PaneToggleButton renders default 1`] = ` +<CommandBarButton + className="toggle-button start vertical" + onClick={[Function]} + title="Collapse Sources and Outline panes" +> + <AccessibleImage + className="pane-collapse" + /> +</CommandBarButton> +`; diff --git a/devtools/client/debugger/src/components/shared/Dropdown.css b/devtools/client/debugger/src/components/shared/Dropdown.css new file mode 100644 index 0000000000..bae5656c8f --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Dropdown.css @@ -0,0 +1,96 @@ +/* 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/>. */ + +.dropdown { + background: var(--theme-body-background); + border: 1px solid var(--theme-splitter-color); + border-radius: 4px; + box-shadow: 0 4px 4px 0 var(--search-overlays-semitransparent); + max-height: 300px; + position: absolute; + top: 24px; + width: 150px; + z-index: 1000; + overflow: auto; +} + +[dir="ltr"] .dropdown { + right: 2px; +} + +[dir="rtl"] .dropdown { + left: 2px; +} + +.dropdown-block { + position: relative; + align-self: center; + height: 100%; +} + +/* cover the reserved space at the end of .source-tabs */ +.source-tabs + .dropdown-block { + margin-inline-start: -28px; +} + +.dropdown-button { + color: var(--theme-comment); + background: none; + border: none; + padding: 4px 6px; + font-weight: 100; + font-size: 14px; + height: 100%; + width: 28px; +} + +.dropdown-button .img { + display: block; +} + +.dropdown ul { + margin: 0; + padding: 4px 0; + list-style: none; +} + +.dropdown li { + display: flex; + align-items: center; + padding: 6px 8px; + font-size: 12px; + line-height: calc(16 / 12); + transition: all 0.25s ease; +} + +.dropdown li:hover { + background-color: var(--search-overlays-semitransparent); +} + +.dropdown-icon { + margin-inline-end: 4px; + mask-size: 13px 13px; +} + +.dropdown-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-icon.prettyPrint, +.dropdown-icon.blackBox { + background-color: var(--theme-highlight-blue); +} + +.dropdown-mask { + position: fixed; + width: 100%; + height: 100%; + background: transparent; + z-index: 999; + left: 0; + top: 0; +} diff --git a/devtools/client/debugger/src/components/shared/Dropdown.js b/devtools/client/debugger/src/components/shared/Dropdown.js new file mode 100644 index 0000000000..7051cec9c5 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Dropdown.js @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import "./Dropdown.css"; + +export class Dropdown extends Component { + constructor(props) { + super(props); + this.state = { + dropdownShown: false, + }; + } + + static get propTypes() { + return { + icon: PropTypes.node.isRequired, + panel: PropTypes.node.isRequired, + }; + } + + toggleDropdown = e => { + this.setState(prevState => ({ + dropdownShown: !prevState.dropdownShown, + })); + }; + + renderPanel() { + return ( + <div + className="dropdown" + onClick={this.toggleDropdown} + style={{ display: this.state.dropdownShown ? "block" : "none" }} + > + {this.props.panel} + </div> + ); + } + + renderButton() { + return ( + <button className="dropdown-button" onClick={this.toggleDropdown}> + {this.props.icon} + </button> + ); + } + + renderMask() { + return ( + <div + className="dropdown-mask" + onClick={this.toggleDropdown} + style={{ display: this.state.dropdownShown ? "block" : "none" }} + /> + ); + } + + render() { + return ( + <div className="dropdown-block"> + {this.renderPanel()} + {this.renderButton()} + {this.renderMask()} + </div> + ); + } +} + +export default Dropdown; diff --git a/devtools/client/debugger/src/components/shared/Modal.css b/devtools/client/debugger/src/components/shared/Modal.css new file mode 100644 index 0000000000..072390b001 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Modal.css @@ -0,0 +1,51 @@ +/* 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/>. */ + +.modal-wrapper { + position: fixed; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 100%; + top: 0; + left: 0; + transition: z-index 200ms; + z-index: 100; +} + +.modal { + display: flex; + width: 80%; + max-height: 80vh; + overflow-y: auto; + background-color: var(--theme-toolbar-background); + transition: transform 150ms cubic-bezier(0.07, 0.95, 0, 1); + box-shadow: 1px 1px 6px 1px var(--popup-shadow-color); +} + +.modal.entering, +.modal.exited { + transform: translateY(-101%); +} + +.modal.entered, +.modal.exiting { + transform: translateY(5px); + flex-direction: column; +} + +/* This rule is active when the screen is not narrow */ +@media (min-width: 580px) { + .modal { + width: 50%; + } +} + +@media (min-height: 340px) { + .modal.entered, + .modal.exiting { + transform: translateY(30px); + } +} diff --git a/devtools/client/debugger/src/components/shared/Modal.js b/devtools/client/debugger/src/components/shared/Modal.js new file mode 100644 index 0000000000..dec65e627b --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Modal.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import PropTypes from "prop-types"; +import React from "react"; +import Transition from "react-transition-group/Transition"; +const classnames = require("devtools/client/shared/classnames.js"); +import "./Modal.css"; + +export const transitionTimeout = 50; + +export class Modal extends React.Component { + static get propTypes() { + return { + additionalClass: PropTypes.string, + children: PropTypes.node.isRequired, + handleClose: PropTypes.func.isRequired, + status: PropTypes.string.isRequired, + }; + } + + onClick = e => { + e.stopPropagation(); + }; + + render() { + const { additionalClass, children, handleClose, status } = this.props; + + return ( + <div className="modal-wrapper" onClick={handleClose}> + <div + className={classnames("modal", additionalClass, status)} + onClick={this.onClick} + > + {children} + </div> + </div> + ); + } +} + +Modal.contextTypes = { + shortcuts: PropTypes.object, +}; + +export default function Slide({ + in: inProp, + children, + additionalClass, + handleClose, +}) { + return ( + <Transition in={inProp} timeout={transitionTimeout} appear> + {status => ( + <Modal + status={status} + additionalClass={additionalClass} + handleClose={handleClose} + > + {children} + </Modal> + )} + </Transition> + ); +} + +Slide.propTypes = { + additionalClass: PropTypes.string, + children: PropTypes.node.isRequired, + handleClose: PropTypes.func.isRequired, + in: PropTypes.bool.isRequired, +}; diff --git a/devtools/client/debugger/src/components/shared/Popover.css b/devtools/client/debugger/src/components/shared/Popover.css new file mode 100644 index 0000000000..5da8ea4b63 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Popover.css @@ -0,0 +1,32 @@ +/* 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/>. */ + +.popover { + position: fixed; + z-index: 100; + --gap-size: 10px; + --left-offset: -55px; +} + +.popover.orientation-right { + display: flex; + flex-direction: row; +} + +.popover.orientation-right .gap { + width: var(--gap-size); +} + +.popover:not(.orientation-right) .gap { + height: var(--gap-size); + margin-left: var(--left-offset); +} + +.popover:not(.orientation-right) .preview-popup { + margin-left: var(--left-offset); +} + +.popover .add-to-expression-bar { + margin-left: var(--left-offset); +} diff --git a/devtools/client/debugger/src/components/shared/Popover.js b/devtools/client/debugger/src/components/shared/Popover.js new file mode 100644 index 0000000000..fde7d40a21 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/Popover.js @@ -0,0 +1,299 @@ +/* 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 BracketArrow from "./BracketArrow"; +import SmartGap from "./SmartGap"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./Popover.css"; + +class Popover extends Component { + state = { + coords: { + left: 0, + top: 0, + orientation: "down", + targetMid: { x: 0, y: 0 }, + }, + }; + firstRender = true; + + static defaultProps = { + type: "popover", + }; + + static get propTypes() { + return { + children: PropTypes.node.isRequired, + editorRef: PropTypes.object.isRequired, + mouseout: PropTypes.func.isRequired, + target: PropTypes.object.isRequired, + targetPosition: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + }; + } + + componentDidMount() { + const { type } = this.props; + this.gapHeight = this.$gap.getBoundingClientRect().height; + const coords = + type == "popover" ? this.getPopoverCoords() : this.getTooltipCoords(); + + if (coords) { + this.setState({ coords }); + } + + this.firstRender = false; + this.startTimer(); + } + + componentWillUnmount() { + if (this.timerId) { + clearTimeout(this.timerId); + } + } + + startTimer() { + this.timerId = setTimeout(this.onTimeout, 0); + } + + onTimeout = () => { + const isHoveredOnGap = this.$gap && this.$gap.matches(":hover"); + const isHoveredOnPopover = this.$popover && this.$popover.matches(":hover"); + const isHoveredOnTooltip = this.$tooltip && this.$tooltip.matches(":hover"); + const isHoveredOnTarget = this.props.target.matches(":hover"); + + if (isHoveredOnGap) { + if (!this.wasOnGap) { + this.wasOnGap = true; + this.timerId = setTimeout(this.onTimeout, 200); + return; + } + this.props.mouseout(); + return; + } + + // Don't clear the current preview if mouse is hovered on + // the current preview's token (target) or the popup element + if (isHoveredOnPopover || isHoveredOnTooltip || isHoveredOnTarget) { + this.wasOnGap = false; + this.timerId = setTimeout(this.onTimeout, 0); + return; + } + + this.props.mouseout(); + }; + + calculateLeft(target, editor, popover, orientation) { + const estimatedLeft = target.left; + const estimatedRight = estimatedLeft + popover.width; + const isOverflowingRight = estimatedRight > editor.right; + if (orientation === "right") { + return target.left + target.width; + } + if (isOverflowingRight) { + const adjustedLeft = editor.right - popover.width - 8; + return adjustedLeft; + } + return estimatedLeft; + } + + calculateTopForRightOrientation = (target, editor, popover) => { + if (popover.height <= editor.height) { + const rightOrientationTop = target.top - popover.height / 2; + if (rightOrientationTop < editor.top) { + return editor.top - target.height; + } + const rightOrientationBottom = rightOrientationTop + popover.height; + if (rightOrientationBottom > editor.bottom) { + return editor.bottom + target.height - popover.height + this.gapHeight; + } + return rightOrientationTop; + } + return editor.top - target.height; + }; + + calculateOrientation(target, editor, popover) { + const estimatedBottom = target.bottom + popover.height; + if (editor.bottom > estimatedBottom) { + return "down"; + } + const upOrientationTop = target.top - popover.height; + if (upOrientationTop > editor.top) { + return "up"; + } + + return "right"; + } + + calculateTop = (target, editor, popover, orientation) => { + if (orientation === "down") { + return target.bottom; + } + if (orientation === "up") { + return target.top - popover.height; + } + + return this.calculateTopForRightOrientation(target, editor, popover); + }; + + getPopoverCoords() { + if (!this.$popover || !this.props.editorRef) { + return null; + } + + const popover = this.$popover; + const editor = this.props.editorRef; + const popoverRect = popover.getBoundingClientRect(); + const editorRect = editor.getBoundingClientRect(); + const targetRect = this.props.targetPosition; + const orientation = this.calculateOrientation( + targetRect, + editorRect, + popoverRect + ); + const top = this.calculateTop( + targetRect, + editorRect, + popoverRect, + orientation + ); + const popoverLeft = this.calculateLeft( + targetRect, + editorRect, + popoverRect, + orientation + ); + let targetMid; + if (orientation === "right") { + targetMid = { + x: -14, + y: targetRect.top - top - 2, + }; + } else { + targetMid = { + x: targetRect.left - popoverLeft + targetRect.width / 2 - 8, + y: 0, + }; + } + + return { + left: popoverLeft, + top, + orientation, + targetMid, + }; + } + + getTooltipCoords() { + if (!this.$tooltip || !this.props.editorRef) { + return null; + } + const tooltip = this.$tooltip; + const editor = this.props.editorRef; + const tooltipRect = tooltip.getBoundingClientRect(); + const editorRect = editor.getBoundingClientRect(); + const targetRect = this.props.targetPosition; + const left = this.calculateLeft(targetRect, editorRect, tooltipRect); + const enoughRoomForTooltipAbove = + targetRect.top - editorRect.top > tooltipRect.height; + const top = enoughRoomForTooltipAbove + ? targetRect.top - tooltipRect.height + : targetRect.bottom; + + return { + left, + top, + orientation: enoughRoomForTooltipAbove ? "up" : "down", + targetMid: { x: 0, y: 0 }, + }; + } + + getChildren() { + const { children } = this.props; + const { coords } = this.state; + const gap = this.getGap(); + + return coords.orientation === "up" ? [children, gap] : [gap, children]; + } + + getGap() { + if (this.firstRender) { + return <div className="gap" key="gap" ref={a => (this.$gap = a)} />; + } + + return ( + <div className="gap" key="gap" ref={a => (this.$gap = a)}> + <SmartGap + token={this.props.target} + preview={this.$tooltip || this.$popover} + type={this.props.type} + gapHeight={this.gapHeight} + coords={this.state.coords} + offset={this.$gap.getBoundingClientRect().left} + /> + </div> + ); + } + + getPopoverArrow(orientation, left, top) { + let arrowProps = {}; + + if (orientation === "up") { + arrowProps = { orientation: "down", bottom: 10, left }; + } else if (orientation === "down") { + arrowProps = { orientation: "up", top: -2, left }; + } else { + arrowProps = { orientation: "left", top, left: -4 }; + } + + return <BracketArrow {...arrowProps} />; + } + + renderPopover() { + const { top, left, orientation, targetMid } = this.state.coords; + const arrow = this.getPopoverArrow(orientation, targetMid.x, targetMid.y); + + return ( + <div + className={classnames("popover", `orientation-${orientation}`, { + up: orientation === "up", + })} + style={{ top, left }} + ref={c => (this.$popover = c)} + > + {arrow} + {this.getChildren()} + </div> + ); + } + + renderTooltip() { + const { top, left, orientation } = this.state.coords; + return ( + <div + className={`tooltip orientation-${orientation}`} + style={{ top, left }} + ref={c => (this.$tooltip = c)} + > + {this.getChildren()} + </div> + ); + } + + render() { + const { type } = this.props; + + if (type === "tooltip") { + return this.renderTooltip(); + } + + return this.renderPopover(); + } +} + +export default Popover; diff --git a/devtools/client/debugger/src/components/shared/PreviewFunction.css b/devtools/client/debugger/src/components/shared/PreviewFunction.css new file mode 100644 index 0000000000..bff9ce25a2 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/PreviewFunction.css @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.function-signature { + align-self: center; +} + +.function-signature .function-name { + color: var(--theme-highlight-blue); +} + +.function-signature .param { + color: var(--theme-highlight-red); +} + +.function-signature .paren { + color: var(--object-color); +} + +.function-signature .comma { + color: var(--object-color); +} diff --git a/devtools/client/debugger/src/components/shared/PreviewFunction.js b/devtools/client/debugger/src/components/shared/PreviewFunction.js new file mode 100644 index 0000000000..760a45db5d --- /dev/null +++ b/devtools/client/debugger/src/components/shared/PreviewFunction.js @@ -0,0 +1,82 @@ +/* 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 { formatDisplayName } from "../../utils/pause/frames"; + +import "./PreviewFunction.css"; + +const IGNORED_SOURCE_URLS = ["debugger eval code"]; + +export default class PreviewFunction extends Component { + static get propTypes() { + return { + func: PropTypes.object.isRequired, + }; + } + + renderFunctionName(func) { + const { l10n } = this.context; + const name = formatDisplayName(func, undefined, l10n); + return <span className="function-name">{name}</span>; + } + + renderParams(func) { + const { parameterNames = [] } = func; + + return parameterNames + .filter(Boolean) + .map((param, i, arr) => { + const elements = [ + <span className="param" key={param}> + {param} + </span>, + ]; + // if this isn't the last param, add a comma + if (i !== arr.length - 1) { + elements.push( + <span className="delimiter" key={i}> + {", "} + </span> + ); + } + return elements; + }) + .flat(); + } + + jumpToDefinitionButton(func) { + const { location } = func; + + if (!location?.url || IGNORED_SOURCE_URLS.includes(location.url)) { + return null; + } + + const lastIndex = location.url.lastIndexOf("/"); + return ( + <button + className="jump-definition" + draggable="false" + title={`${location.url.slice(lastIndex + 1)}:${location.line}`} + /> + ); + } + + render() { + const { func } = this.props; + return ( + <span className="function-signature"> + {this.renderFunctionName(func)} + <span className="paren">(</span> + {this.renderParams(func)} + <span className="paren">)</span> + {this.jumpToDefinitionButton(func)} + </span> + ); + } +} + +PreviewFunction.contextTypes = { l10n: PropTypes.object }; diff --git a/devtools/client/debugger/src/components/shared/ResultList.css b/devtools/client/debugger/src/components/shared/ResultList.css new file mode 100644 index 0000000000..037c3497d3 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/ResultList.css @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.result-list { + list-style: none; + margin: 0px; + padding: 0px; + overflow: auto; + width: 100%; + background: var(--theme-body-background); +} + +.result-list * { + user-select: none; +} + +.result-list li { + color: var(--theme-body-color); + padding: 4px 8px; + display: flex; +} + +.result-list.big li { + flex-direction: row; + align-items: center; + padding: 6px 8px; + font-size: 12px; + line-height: 16px; +} + +.result-list.small li { + justify-content: space-between; +} + +.result-list li:hover { + background: var(--theme-tab-toolbar-background); +} + +.theme-dark .result-list li:hover { + background: var(--grey-70); +} + +.result-list li.selected { + background: var(--theme-accordion-header-background); +} + +.result-list.small li.selected { + background-color: var(--theme-selection-background); + color: white; +} + +.result-list li .result-item-icon { + background-color: var(--theme-icon-dimmed-color); +} + +.result-list li .icon { + align-self: center; + margin-inline-end: 14px; + margin-inline-start: 4px; +} + +.result-list .result-item-icon { + display: block; +} + +.result-list .selected .result-item-icon { + background-color: var(--theme-selection-color); +} + +.result-list li .title { + word-break: break-all; + text-overflow: ellipsis; + white-space: nowrap; + + /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/ + color: var(--grey-90); +} + +.theme-dark .result-list li .title { + /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/ + color: var(--grey-30); +} + +.result-list li.selected .title { + color: white; +} + +.result-list.big li.selected { + background-color: var(--theme-selection-background); + color: white; +} + +.result-list.big li.selected .subtitle { + color: white; +} + +.result-list.big li.selected .subtitle .highlight { + color: white; + font-weight: bold; +} + +.result-list.big li .subtitle { + word-break: break-all; + /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/ + color: var(--grey-40); + margin-left: 15px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.theme-dark .result-list.big li.selected .subtitle { + color: white; +} + +.theme-dark .result-list.big li .subtitle { + color: var(--theme-text-color-inactive); +} + +.search-bar .result-list li.selected .subtitle { + color: white; +} + +.search-bar .result-list { + border-bottom: 1px solid var(--theme-splitter-color); +} + +.theme-dark .result-list { + background-color: var(--theme-body-background); +} diff --git a/devtools/client/debugger/src/components/shared/ResultList.js b/devtools/client/debugger/src/components/shared/ResultList.js new file mode 100644 index 0000000000..bb915b8f24 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/ResultList.js @@ -0,0 +1,82 @@ +/* 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 AccessibleImage from "./AccessibleImage"; + +const classnames = require("devtools/client/shared/classnames.js"); + +import "./ResultList.css"; + +export default class ResultList extends Component { + static defaultProps = { + size: "small", + role: "listbox", + }; + + static get propTypes() { + return { + items: PropTypes.array.isRequired, + role: PropTypes.oneOf(["listbox"]), + selectItem: PropTypes.func.isRequired, + selected: PropTypes.number.isRequired, + size: PropTypes.oneOf(["big", "small"]), + }; + } + + renderListItem = (item, index) => { + if (item.value === "/" && item.title === "") { + item.title = "(index)"; + } + + const { selectItem, selected } = this.props; + const props = { + onClick: event => selectItem(event, item, index), + key: `${item.id}${item.value}${index}`, + ref: String(index), + title: item.value, + "aria-labelledby": `${item.id}-title`, + "aria-describedby": `${item.id}-subtitle`, + role: "option", + className: classnames("result-item", { + selected: index === selected, + }), + }; + + return ( + <li {...props}> + {item.icon && ( + <div className="icon"> + <AccessibleImage className={item.icon} /> + </div> + )} + <div id={`${item.id}-title`} className="title"> + {item.title} + </div> + {item.subtitle != item.title ? ( + <div id={`${item.id}-subtitle`} className="subtitle"> + {item.subtitle} + </div> + ) : null} + </li> + ); + }; + + render() { + const { size, items, role } = this.props; + + return ( + <ul + className={classnames("result-list", size)} + id="result-list" + role={role} + aria-live="polite" + > + {items.map(this.renderListItem)} + </ul> + ); + } +} diff --git a/devtools/client/debugger/src/components/shared/SearchInput.css b/devtools/client/debugger/src/components/shared/SearchInput.css new file mode 100644 index 0000000000..33d217321a --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SearchInput.css @@ -0,0 +1,225 @@ +/* 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-outline { + border: 1px solid var(--theme-toolbar-background); + border-bottom: 1px solid var(--theme-splitter-color); + transition: border-color 200ms ease-in-out; + display: flex; + flex-direction: column; +} + +.search-field { + position: relative; + display: flex; + align-items: center; + flex-shrink: 0; + min-height: 24px; + width: 100%; + background-color: var(--theme-toolbar-background); +} + +.search-field .img.search { + --icon-mask-size: 12px; + --icon-inset-inline-start: 6px; + position: absolute; + z-index: 1; + top: calc(50% - 8px); + mask-size: var(--icon-mask-size); + background-color: var(--theme-icon-dimmed-color); + pointer-events: none; +} + +.search-field.big .img.search { + --icon-mask-size: 16px; + --icon-inset-inline-start: 12px; +} + +[dir="ltr"] .search-field .img.search { + left: var(--icon-inset-inline-start); +} + +[dir="rtl"] .search-field .img.search { + right: var(--icon-inset-inline-start); +} + +.search-field .img.loader { + width: 24px; + height: 24px; + margin-inline-end: 4px; +} + +.search-field input { + align-self: stretch; + flex-grow: 1; + height: 24px; + width: 40px; + border: none; + outline: none; + padding: 4px; + padding-inline-start: 28px; + line-height: 16px; + font-family: inherit; + font-size: inherit; + color: var(--theme-body-color); + background-color: transparent; +} + +.exclude-patterns-field { + position: relative; + display: flex; + align-items: flex-start; + flex-direction: column; + flex-shrink: 0; + min-height: 24px; + width: 100%; + background-color: var(--theme-toolbar-background); + border-top: 1px solid var(--theme-splitter-color); + margin-top: 1px; +} + +.exclude-patterns-field input:focus { + outline: 1px solid var(--blue-50); +} + +.exclude-patterns-field label { + padding-inline-start: 8px; + padding-top: 5px; + padding-bottom: 3px; + align-self: stretch; + background-color: var(--theme-body-background); + font-size: 12px; +} + +.exclude-patterns-field input { + align-self: stretch; + height: 24px; + border: none; + padding-top: 14px; + padding-bottom: 14px; + padding-inline-start: 10px; + line-height: 16px; + font-family: inherit; + font-size: inherit; + color: var(--theme-body-color); + background-color: transparent; + border-top: 1px solid var(--theme-splitter-color); + min-height: 24px; +} + +.exclude-patterns-field input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.search-field.big input { + height: 40px; + padding-top: 10px; + padding-bottom: 10px; + padding-inline-start: 40px; + font-size: 14px; + line-height: 20px; +} + +.search-field:focus-within { + outline: 1px solid var(--blue-50); +} + +.search-field input::placeholder { + color: var(--theme-text-color-alt); + opacity: 1; +} + +.search-field-summary { + align-self: center; + padding: 2px 4px; + white-space: nowrap; + text-align: center; + user-select: none; + color: var(--theme-text-color-alt); + /* Avoid layout jumps when we increment the result count quickly. With tabular + numbers, layout will only jump between 9 and 10, 99 and 100, etc. */ + font-variant-numeric: tabular-nums; +} + +.search-field.big .search-field-summary { + margin-inline-end: 4px; +} + +.search-field .search-nav-buttons { + display: flex; + user-select: none; +} + +.search-field .search-nav-buttons .nav-btn { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + padding: 4px; + background: transparent; +} + +.search-field .search-nav-buttons .nav-btn:hover { + background-color: var(--theme-toolbar-background-hover); +} + +.search-field .close-btn { + margin-inline-end: 4px; +} + +.search-field.big .close-btn { + margin-inline-end: 8px; +} + +.search-field .close-btn::-moz-focus-inner { + border: none; +} + +.search-buttons-bar .pipe-divider { + flex: none; + align-self: stretch; + width: 1px; + vertical-align: middle; + margin: 4px; + background-color: var(--theme-splitter-color); +} + +.search-buttons-bar * { + user-select: none; +} + +.search-buttons-bar { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + align-items: center; + background-color: var(--theme-toolbar-background); + padding: 0; +} + +.search-buttons-bar .search-type-toggles { + display: flex; + align-items: center; + max-width: 68%; +} + +.search-buttons-bar .search-type-name { + margin: 0 4px; + border: none; + background: transparent; + color: var(--theme-comment); +} + +.search-buttons-bar .search-type-toggles .search-type-btn.active { + color: var(--theme-selection-background); +} + +.theme-dark .search-buttons-bar .search-type-toggles .search-type-btn.active { + color: white; +} + +.search-buttons-bar .close-btn { + margin-inline-end: 3px; +} diff --git a/devtools/client/debugger/src/components/shared/SearchInput.js b/devtools/client/debugger/src/components/shared/SearchInput.js new file mode 100644 index 0000000000..c07d7c86c7 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SearchInput.js @@ -0,0 +1,339 @@ +/* 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 { CloseButton } from "./Button"; + +import AccessibleImage from "./AccessibleImage"; +import actions from "../../actions"; +import "./SearchInput.css"; +import { getSearchOptions } from "../../selectors"; + +const classnames = require("devtools/client/shared/classnames.js"); +const SearchModifiers = require("devtools/client/shared/components/SearchModifiers"); + +const arrowBtn = (onClick, type, className, tooltip) => { + const props = { + className, + key: type, + onClick, + title: tooltip, + type, + }; + + return ( + <button {...props}> + <AccessibleImage className={type} /> + </button> + ); +}; + +export class SearchInput extends Component { + static defaultProps = { + expanded: false, + hasPrefix: false, + selectedItemId: "", + size: "", + showClose: true, + }; + + constructor(props) { + super(props); + this.state = { + history: [], + excludePatterns: props.searchOptions.excludePatterns, + }; + } + + static get propTypes() { + return { + count: PropTypes.number.isRequired, + expanded: PropTypes.bool.isRequired, + handleClose: PropTypes.func, + handleNext: PropTypes.func, + handlePrev: PropTypes.func, + hasPrefix: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + onHistoryScroll: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyUp: PropTypes.func, + placeholder: PropTypes.string, + query: PropTypes.string, + selectedItemId: PropTypes.string, + shouldFocus: PropTypes.bool, + showClose: PropTypes.bool.isRequired, + showExcludePatterns: PropTypes.bool.isRequired, + excludePatternsLabel: PropTypes.string, + excludePatternsPlaceholder: PropTypes.string, + showErrorEmoji: PropTypes.bool.isRequired, + size: PropTypes.string, + summaryMsg: PropTypes.string, + searchKey: PropTypes.string.isRequired, + searchOptions: PropTypes.object, + setSearchOptions: PropTypes.func, + showSearchModifiers: PropTypes.bool.isRequired, + onToggleSearchModifier: PropTypes.func, + }; + } + + componentDidMount() { + this.setFocus(); + } + + componentDidUpdate(prevProps) { + if (this.props.shouldFocus && !prevProps.shouldFocus) { + this.setFocus(); + } + } + + setFocus() { + if (this.$input) { + const input = this.$input; + input.focus(); + + if (!input.value) { + return; + } + + // omit prefix @:# from being selected + const selectStartPos = this.props.hasPrefix ? 1 : 0; + input.setSelectionRange(selectStartPos, input.value.length + 1); + } + } + + renderArrowButtons() { + const { handleNext, handlePrev } = this.props; + + return [ + arrowBtn( + handlePrev, + "arrow-up", + classnames("nav-btn", "prev"), + L10N.getFormatStr("editor.searchResults.prevResult") + ), + arrowBtn( + handleNext, + "arrow-down", + classnames("nav-btn", "next"), + L10N.getFormatStr("editor.searchResults.nextResult") + ), + ]; + } + + onFocus = e => { + const { onFocus } = this.props; + + if (onFocus) { + onFocus(e); + } + }; + + onBlur = e => { + const { onBlur } = this.props; + + if (onBlur) { + onBlur(e); + } + }; + + onKeyDown = e => { + const { onHistoryScroll, onKeyDown } = this.props; + if (!onHistoryScroll) { + onKeyDown(e); + return; + } + + const inputValue = e.target.value; + const { history } = this.state; + const currentHistoryIndex = history.indexOf(inputValue); + + if (e.key === "Enter") { + this.saveEnteredTerm(inputValue); + onKeyDown(e); + return; + } + + if (e.key === "ArrowUp") { + const previous = + currentHistoryIndex > -1 ? currentHistoryIndex - 1 : history.length - 1; + const previousInHistory = history[previous]; + if (previousInHistory) { + e.preventDefault(); + onHistoryScroll(previousInHistory); + } + return; + } + + if (e.key === "ArrowDown") { + const next = currentHistoryIndex + 1; + const nextInHistory = history[next]; + if (nextInHistory) { + onHistoryScroll(nextInHistory); + } + } + }; + + onExcludeKeyDown = e => { + if (e.key === "Enter") { + this.props.setSearchOptions(this.props.searchKey, { + excludePatterns: this.state.excludePatterns, + }); + this.props.onKeyDown(e); + } + }; + + saveEnteredTerm(query) { + const { history } = this.state; + const previousIndex = history.indexOf(query); + if (previousIndex !== -1) { + history.splice(previousIndex, 1); + } + history.push(query); + this.setState({ history }); + } + + renderSummaryMsg() { + const { summaryMsg } = this.props; + + if (!summaryMsg) { + return null; + } + + return <div className="search-field-summary">{summaryMsg}</div>; + } + + renderSpinner() { + const { isLoading } = this.props; + if (!isLoading) { + return null; + } + return <AccessibleImage className="loader spin" />; + } + + renderNav() { + const { count, handleNext, handlePrev } = this.props; + if ((!handleNext && !handlePrev) || !count || count == 1) { + return null; + } + + return ( + <div className="search-nav-buttons">{this.renderArrowButtons()}</div> + ); + } + + renderSearchModifiers() { + if (!this.props.showSearchModifiers) { + return null; + } + return ( + <SearchModifiers + modifiers={this.props.searchOptions} + onToggleSearchModifier={updatedOptions => { + this.props.setSearchOptions(this.props.searchKey, updatedOptions); + this.props.onToggleSearchModifier(); + }} + /> + ); + } + + renderExcludePatterns() { + if (!this.props.showExcludePatterns) { + return null; + } + + return ( + <div className={classnames("exclude-patterns-field", this.props.size)}> + <label>{this.props.excludePatternsLabel}</label> + <input + placeholder={this.props.excludePatternsPlaceholder} + value={this.state.excludePatterns} + onKeyDown={this.onExcludeKeyDown} + onChange={e => this.setState({ excludePatterns: e.target.value })} + /> + </div> + ); + } + + renderClose() { + if (!this.props.showClose) { + return null; + } + return ( + <React.Fragment> + <span className="pipe-divider" /> + <CloseButton + handleClick={this.props.handleClose} + buttonClass={this.props.size} + /> + </React.Fragment> + ); + } + + render() { + const { + expanded, + onChange, + onKeyUp, + placeholder, + query, + selectedItemId, + showErrorEmoji, + size, + } = this.props; + + const inputProps = { + className: classnames({ + empty: showErrorEmoji, + }), + onChange, + onKeyDown: e => this.onKeyDown(e), + onKeyUp, + onFocus: e => this.onFocus(e), + onBlur: e => this.onBlur(e), + "aria-autocomplete": "list", + "aria-controls": "result-list", + "aria-activedescendant": + expanded && selectedItemId ? `${selectedItemId}-title` : "", + placeholder, + value: query, + spellCheck: false, + ref: c => (this.$input = c), + }; + + return ( + <div className="search-outline"> + <div + className={classnames("search-field", size)} + role="combobox" + aria-haspopup="listbox" + aria-owns="result-list" + aria-expanded={expanded} + > + <AccessibleImage className="search" /> + <input {...inputProps} /> + {this.renderSpinner()} + {this.renderSummaryMsg()} + {this.renderNav()} + <div className="search-buttons-bar"> + {this.renderSearchModifiers()} + {this.renderClose()} + </div> + </div> + {this.renderExcludePatterns()} + </div> + ); + } +} +const mapStateToProps = (state, props) => ({ + searchOptions: getSearchOptions(state, props.searchKey), +}); + +export default connect(mapStateToProps, { + setSearchOptions: actions.setSearchOptions, +})(SearchInput); diff --git a/devtools/client/debugger/src/components/shared/SmartGap.js b/devtools/client/debugger/src/components/shared/SmartGap.js new file mode 100644 index 0000000000..785d7496fb --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SmartGap.js @@ -0,0 +1,166 @@ +/* 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 PropTypes from "prop-types"; + +function shorten(coordinates) { + // In cases where the token is wider than the preview, the smartGap + // gets distorted. This shortens the coordinate array so that the smartGap + // is only touching 2 corners of the token (instead of all 4 corners) + coordinates.splice(0, 2); + coordinates.splice(4, 2); + return coordinates; +} + +function getSmartGapCoordinates( + preview, + token, + offset, + orientation, + gapHeight, + coords +) { + if (orientation === "up") { + const coordinates = [ + token.left - coords.left + offset, + token.top + token.height - (coords.top + preview.height) + gapHeight, + 0, + 0, + preview.width + offset, + 0, + token.left + token.width - coords.left + offset, + token.top + token.height - (coords.top + preview.height) + gapHeight, + token.left + token.width - coords.left + offset, + token.top - (coords.top + preview.height) + gapHeight, + token.left - coords.left + offset, + token.top - (coords.top + preview.height) + gapHeight, + ]; + return preview.width > token.width ? coordinates : shorten(coordinates); + } + if (orientation === "down") { + const coordinates = [ + token.left + token.width - (coords.left + preview.top) + offset, + 0, + preview.width + offset, + coords.top - token.top + gapHeight, + 0, + coords.top - token.top + gapHeight, + token.left - (coords.left + preview.top) + offset, + 0, + token.left - (coords.left + preview.top) + offset, + token.height, + token.left + token.width - (coords.left + preview.top) + offset, + token.height, + ]; + return preview.width > token.width ? coordinates : shorten(coordinates); + } + return [ + 0, + token.top - coords.top, + gapHeight + token.width, + 0, + gapHeight + token.width, + preview.height - gapHeight, + 0, + token.top + token.height - coords.top, + token.width, + token.top + token.height - coords.top, + token.width, + token.top - coords.top, + ]; +} + +function getSmartGapDimensions( + previewRect, + tokenRect, + offset, + orientation, + gapHeight, + coords +) { + if (orientation === "up") { + return { + height: + tokenRect.top + + tokenRect.height - + coords.top - + previewRect.height + + gapHeight, + width: Math.max(previewRect.width, tokenRect.width) + offset, + }; + } + if (orientation === "down") { + return { + height: coords.top - tokenRect.top + gapHeight, + width: Math.max(previewRect.width, tokenRect.width) + offset, + }; + } + return { + height: previewRect.height - gapHeight, + width: coords.left - tokenRect.left + gapHeight, + }; +} + +export default function SmartGap({ + token, + preview, + type, + gapHeight, + coords, + offset, +}) { + const tokenRect = token.getBoundingClientRect(); + const previewRect = preview.getBoundingClientRect(); + const { orientation } = coords; + let optionalMarginLeft, optionalMarginTop; + + if (orientation === "down") { + optionalMarginTop = -tokenRect.height; + } else if (orientation === "right") { + optionalMarginLeft = -tokenRect.width; + } + + const { height, width } = getSmartGapDimensions( + previewRect, + tokenRect, + -offset, + orientation, + gapHeight, + coords + ); + const coordinates = getSmartGapCoordinates( + previewRect, + tokenRect, + -offset, + orientation, + gapHeight, + coords + ); + + return ( + <svg + version="1.1" + xmlns="http://www.w3.org/2000/svg" + style={{ + height, + width, + position: "absolute", + marginLeft: optionalMarginLeft, + marginTop: optionalMarginTop, + }} + > + <polygon points={coordinates} fill="transparent" /> + </svg> + ); +} + +SmartGap.propTypes = { + coords: PropTypes.object.isRequired, + gapHeight: PropTypes.number.isRequired, + offset: PropTypes.number.isRequired, + preview: PropTypes.object.isRequired, + token: PropTypes.object.isRequired, + type: PropTypes.oneOf(["popover", "tooltip"]).isRequired, +}; diff --git a/devtools/client/debugger/src/components/shared/SourceIcon.css b/devtools/client/debugger/src/components/shared/SourceIcon.css new file mode 100644 index 0000000000..0b9bf3e79e --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SourceIcon.css @@ -0,0 +1,176 @@ +/* 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/>. */ + +/** + * Variant of AccessibleImage used in sources list and tabs. + * Define the different source type / framework / library icons here. + */ + +.source-icon { + margin-inline-end: 4px; +} + +/* Icons for frameworks and libs */ + +.img.aframe { + background-image: url(chrome://devtools/content/debugger/images/sources/aframe.svg); + background-color: transparent !important; +} + +.img.angular { + background-image: url(chrome://devtools/content/debugger/images/sources/angular.svg); + background-color: transparent !important; +} + +.img.babel { + mask-image: url(chrome://devtools/content/debugger/images/sources/babel.svg); +} + +.img.backbone { + mask-image: url(chrome://devtools/content/debugger/images/sources/backbone.svg); +} + +.img.choo { + background-image: url(chrome://devtools/content/debugger/images/sources/choo.svg); + background-color: transparent !important; +} + +.img.coffeescript { + background-image: url(chrome://devtools/content/debugger/images/sources/coffeescript.svg); + background-color: transparent !important; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.img.dojo { + background-image: url(chrome://devtools/content/debugger/images/sources/dojo.svg); + background-color: transparent !important; +} + +.img.ember { + background-image: url(chrome://devtools/content/debugger/images/sources/ember.svg); + background-color: transparent !important; +} + +.img.express { + mask-image: url(chrome://devtools/content/debugger/images/sources/express.svg); +} + +.img.extension { + mask-image: url(chrome://devtools/content/debugger/images/sources/extension.svg); +} + +.img.immutable { + mask-image: url(chrome://devtools/content/debugger/images/sources/immutable.svg); +} + +.img.javascript { + background-image: url(chrome://devtools/content/debugger/images/sources/javascript.svg); + background-size: 14px 14px; + background-color: transparent !important; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.img.override::after { + content: ""; + display: block; + height: 5px; + width: 5px; + background-color: var(--purple-30); + border-radius: 100%; + outline: 1px solid var(--theme-sidebar-background); + translate: 12px 10px; +} + +.node.focused .img.override::after { + outline-color: var(--theme-selection-background); +} + +.img.jquery { + mask-image: url(chrome://devtools/content/debugger/images/sources/jquery.svg); +} + +.img.lodash { + mask-image: url(chrome://devtools/content/debugger/images/sources/lodash.svg); +} + +.img.marko { + background-image: url(chrome://devtools/content/debugger/images/sources/marko.svg); + background-color: transparent !important; +} + +.img.mobx { + background-image: url(chrome://devtools/content/debugger/images/sources/mobx.svg); + background-color: transparent !important; +} + +.img.nextjs { + background-image: url(chrome://devtools/content/debugger/images/sources/nextjs.svg); + background-color: transparent !important; +} + +.img.node { + background-image: url(chrome://devtools/content/debugger/images/sources/node.svg); + background-color: transparent !important; +} + +.img.nuxtjs { + background-image: url(chrome://devtools/content/debugger/images/sources/nuxtjs.svg); + background-color: transparent !important; +} + +.img.preact { + background-image: url(chrome://devtools/content/debugger/images/sources/preact.svg); + background-color: transparent !important; +} + +.img.pug { + background-image: url(chrome://devtools/content/debugger/images/sources/pug.svg); + background-color: transparent !important; +} + +.img.react { + background-image: url(chrome://devtools/content/debugger/images/sources/react.svg); + background-color: transparent !important; + fill: var(--theme-highlight-bluegrey); + -moz-context-properties: fill; +} + +.img.redux { + mask-image: url(chrome://devtools/content/debugger/images/sources/redux.svg); +} + +.img.rxjs { + background-image: url(chrome://devtools/content/debugger/images/sources/rxjs.svg); + background-color: transparent !important; +} + +.img.sencha-extjs { + background-image: url(chrome://devtools/content/debugger/images/sources/sencha-extjs.svg); + background-color: transparent !important; +} + +.img.typescript { + background-image: url(chrome://devtools/content/debugger/images/sources/typescript.svg); + background-color: transparent !important; + fill: var(--theme-icon-color); + -moz-context-properties: fill; +} + +.img.underscore { + mask-image: url(chrome://devtools/content/debugger/images/sources/underscore.svg); +} + +/* We use both 'Vue' and 'VueJS' when identifying frameworks */ +.img.vue, +.img.vuejs { + background-image: url(chrome://devtools/content/debugger/images/sources/vuejs.svg); + background-color: transparent !important; +} + +.img.webpack { + background-image: url(chrome://devtools/content/debugger/images/sources/webpack.svg); + background-color: transparent !important; +} diff --git a/devtools/client/debugger/src/components/shared/SourceIcon.js b/devtools/client/debugger/src/components/shared/SourceIcon.js new file mode 100644 index 0000000000..fed2e01f57 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/SourceIcon.js @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { PureComponent } from "react"; +import PropTypes from "prop-types"; + +import { connect } from "../../utils/connect"; + +import AccessibleImage from "./AccessibleImage"; + +import { getSourceClassnames } from "../../utils/source"; +import { getSymbols, isSourceBlackBoxed, hasPrettyTab } from "../../selectors"; + +import "./SourceIcon.css"; + +class SourceIcon extends PureComponent { + static get propTypes() { + return { + modifier: PropTypes.func.isRequired, + location: PropTypes.object.isRequired, + iconClass: PropTypes.string, + forTab: PropTypes.bool, + }; + } + + render() { + const { modifier } = this.props; + let { iconClass } = this.props; + + if (modifier) { + const modified = modifier(iconClass); + if (!modified) { + return null; + } + iconClass = modified; + } + + return <AccessibleImage className={`source-icon ${iconClass}`} />; + } +} + +export default connect((state, props) => { + const { forTab, location } = props; + // BreakpointHeading sometimes spawn locations without source actor for generated sources + // which disallows fetching symbols. In such race condition return the default icon. + // (this reproduces when running browser_dbg-breakpoints-popup.js) + if (!location.source.isOriginal && !location.sourceActor) { + return "file"; + } + const symbols = getSymbols(state, location); + const isBlackBoxed = isSourceBlackBoxed(state, location.source); + // For the tab icon, we don't want to show the pretty icon for the non-pretty tab + const hasMatchingPrettyTab = + !forTab && hasPrettyTab(state, location.source.url); + + // This is the key function that will compute the icon type, + // In addition to the "modifier" implemented by each callsite. + const iconClass = getSourceClassnames( + location.source, + symbols, + isBlackBoxed, + hasMatchingPrettyTab + ); + + return { + iconClass, + }; +})(SourceIcon); diff --git a/devtools/client/debugger/src/components/shared/menu.css b/devtools/client/debugger/src/components/shared/menu.css new file mode 100644 index 0000000000..37dfbc2e8f --- /dev/null +++ b/devtools/client/debugger/src/components/shared/menu.css @@ -0,0 +1,55 @@ +/* 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/>. */ + +menupopup { + position: fixed; + z-index: 10000; + border: 1px solid #cccccc; + padding: 5px 0; + background: #f2f2f2; + border-radius: 5px; + color: #585858; + box-shadow: 0 0 4px 0 rgba(190, 190, 190, 0.8); + min-width: 130px; +} + +menuitem { + display: block; + padding: 0 20px; + line-height: 20px; + font-weight: 500; + font-size: 13px; + user-select: none; +} + +menuitem:hover { + background: #3780fb; + color: white; +} + +menuitem[disabled="true"] { + color: #cccccc; +} + +menuitem[disabled="true"]:hover { + background-color: transparent; + cursor: default; +} + +menuseparator { + border-bottom: 1px solid #cacdd3; + width: 100%; + height: 5px; + display: block; + margin-bottom: 5px; +} + +#contextmenu-mask.show { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 999; +} diff --git a/devtools/client/debugger/src/components/shared/moz.build b/devtools/client/debugger/src/components/shared/moz.build new file mode 100644 index 0000000000..b30ea0ab4f --- /dev/null +++ b/devtools/client/debugger/src/components/shared/moz.build @@ -0,0 +1,23 @@ +# 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 += [ + "Button", +] + +CompiledModules( + "AccessibleImage.js", + "Accordion.js", + "Badge.js", + "BracketArrow.js", + "Dropdown.js", + "Modal.js", + "Popover.js", + "PreviewFunction.js", + "ResultList.js", + "SearchInput.js", + "SourceIcon.js", + "SmartGap.js", +) diff --git a/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js b/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js new file mode 100644 index 0000000000..c15dbb827c --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js @@ -0,0 +1,40 @@ +/* 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 { shallow } from "enzyme"; + +import Accordion from "../Accordion"; + +describe("Accordion", () => { + const testItems = [ + { + header: "Test Accordion Item 1", + className: "accordion-item-1", + component: <div />, + opened: false, + onToggle: jest.fn(), + }, + { + header: "Test Accordion Item 2", + className: "accordion-item-2", + component: <div />, + buttons: <button />, + opened: false, + onToggle: jest.fn(), + }, + { + header: "Test Accordion Item 3", + className: "accordion-item-3", + component: <div />, + opened: true, + onToggle: jest.fn(), + }, + ]; + const wrapper = shallow(<Accordion items={testItems} />); + it("basic render", () => expect(wrapper).toMatchSnapshot()); + wrapper.find(".accordion-item-1 ._header").simulate("click"); + it("handleClick and onToggle", () => + expect(testItems[0].onToggle).toHaveBeenCalledWith(true)); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/Badge.spec.js b/devtools/client/debugger/src/components/shared/tests/Badge.spec.js new file mode 100644 index 0000000000..6a10b7f9e4 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Badge.spec.js @@ -0,0 +1,12 @@ +/* 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 { shallow } from "enzyme"; + +import Badge from "../Badge"; + +describe("Badge", () => { + it("render", () => expect(shallow(<Badge>{3}</Badge>)).toMatchSnapshot()); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js b/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js new file mode 100644 index 0000000000..37f58fbfdc --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js @@ -0,0 +1,19 @@ +/* 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 { shallow } from "enzyme"; + +import BracketArrow from "../BracketArrow"; + +describe("BracketArrow", () => { + const wrapper = shallow( + <BracketArrow orientation="down" left={10} top={20} bottom={50} /> + ); + it("render", () => expect(wrapper).toMatchSnapshot()); + it("render up", () => { + wrapper.setProps({ orientation: null }); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js b/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js new file mode 100644 index 0000000000..b01f6fa059 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js @@ -0,0 +1,16 @@ +/* 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 { shallow } from "enzyme"; + +import Dropdown from "../Dropdown"; + +describe("Dropdown", () => { + const wrapper = shallow(<Dropdown panel={<div />} icon="✅" />); + it("render", () => expect(wrapper).toMatchSnapshot()); + wrapper.find(".dropdown").simulate("click"); + it("handle toggleDropdown", () => + expect(wrapper.state().dropdownShown).toEqual(true)); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/Modal.spec.js b/devtools/client/debugger/src/components/shared/tests/Modal.spec.js new file mode 100644 index 0000000000..d609d3fda0 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Modal.spec.js @@ -0,0 +1,50 @@ +/* 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 { shallow } from "enzyme"; + +import { Modal } from "../Modal"; + +describe("Modal", () => { + it("renders", () => { + const wrapper = shallow(<Modal handleClose={() => {}} status="entering" />); + expect(wrapper).toMatchSnapshot(); + }); + + it("handles close modal click", () => { + const handleCloseSpy = jest.fn(); + const wrapper = shallow( + <Modal handleClose={handleCloseSpy} status="entering" /> + ); + wrapper.find(".modal-wrapper").simulate("click"); + expect(handleCloseSpy).toHaveBeenCalled(); + }); + + it("renders children", () => { + const children = <div className="aChild" />; + const wrapper = shallow( + <Modal children={children} handleClose={() => {}} status="entering" /> + ); + expect(wrapper.find(".aChild")).toHaveLength(1); + }); + + it("passes additionalClass to child div class", () => { + const additionalClass = "testAddon"; + const wrapper = shallow( + <Modal + additionalClass={additionalClass} + handleClose={() => {}} + status="entering" + /> + ); + expect(wrapper.find(`.modal-wrapper .${additionalClass}`)).toHaveLength(1); + }); + + it("passes status to child div class", () => { + const status = "testStatus"; + const wrapper = shallow(<Modal status={status} handleClose={() => {}} />); + expect(wrapper.find(`.modal-wrapper .${status}`)).toHaveLength(1); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/Popover.spec.js b/devtools/client/debugger/src/components/shared/tests/Popover.spec.js new file mode 100644 index 0000000000..fb44f16597 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/Popover.spec.js @@ -0,0 +1,200 @@ +/* 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 { mount } from "enzyme"; + +import Popover from "../Popover"; + +describe("Popover", () => { + const onMouseLeave = jest.fn(); + const onKeyDown = jest.fn(); + const editorRef = { + getBoundingClientRect() { + return { + x: 0, + y: 0, + width: 100, + height: 100, + top: 250, + right: 0, + bottom: 0, + left: 20, + }; + }, + }; + + const targetRef = { + getBoundingClientRect() { + return { + x: 0, + y: 0, + width: 100, + height: 100, + top: 250, + right: 0, + bottom: 0, + left: 20, + }; + }, + }; + const targetPosition = { + x: 100, + y: 200, + width: 300, + height: 300, + top: 50, + right: 0, + bottom: 0, + left: 200, + }; + const popover = mount( + <Popover + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editorRef} + targetPosition={targetPosition} + mouseout={() => {}} + target={targetRef} + > + <h1>Poppy!</h1> + </Popover> + ); + + const tooltip = mount( + <Popover + type="tooltip" + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editorRef} + targetPosition={targetPosition} + mouseout={() => {}} + target={targetRef} + > + <h1>Toolie!</h1> + </Popover> + ); + + beforeEach(() => { + onMouseLeave.mockClear(); + onKeyDown.mockClear(); + }); + + it("render", () => expect(popover).toMatchSnapshot()); + + it("render (tooltip)", () => expect(tooltip).toMatchSnapshot()); + + it("mount popover", () => { + const mountedPopover = mount( + <Popover + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editorRef} + targetPosition={targetPosition} + mouseout={() => {}} + target={targetRef} + > + <h1>Poppy!</h1> + </Popover> + ); + expect(mountedPopover).toMatchSnapshot(); + }); + + it("mount tooltip", () => { + const mountedTooltip = mount( + <Popover + type="tooltip" + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editorRef} + targetPosition={targetPosition} + mouseout={() => {}} + target={targetRef} + > + <h1>Toolie!</h1> + </Popover> + ); + expect(mountedTooltip).toMatchSnapshot(); + }); + + it("tooltip normally displays above the target", () => { + const editor = { + getBoundingClientRect() { + return { + width: 500, + height: 500, + top: 0, + bottom: 500, + left: 0, + right: 500, + }; + }, + }; + const target = { + width: 30, + height: 10, + top: 100, + bottom: 110, + left: 20, + right: 50, + }; + + const mountedTooltip = mount( + <Popover + type="tooltip" + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editor} + targetPosition={target} + mouseout={() => {}} + target={targetRef} + > + <h1>Toolie!</h1> + </Popover> + ); + + const toolTipTop = parseInt(mountedTooltip.getDOMNode().style.top, 10); + expect(toolTipTop).toBeLessThanOrEqual(target.top); + }); + + it("tooltop won't display above the target when insufficient space", () => { + const editor = { + getBoundingClientRect() { + return { + width: 100, + height: 100, + top: 0, + bottom: 100, + left: 0, + right: 100, + }; + }, + }; + const target = { + width: 30, + height: 10, + top: 0, + bottom: 10, + left: 20, + right: 50, + }; + + const mountedTooltip = mount( + <Popover + type="tooltip" + onMouseLeave={onMouseLeave} + onKeyDown={onKeyDown} + editorRef={editor} + targetPosition={target} + mouseout={() => {}} + target={targetRef} + > + <h1>Toolie!</h1> + </Popover> + ); + + const toolTipTop = parseInt(mountedTooltip.getDOMNode().style.top, 10); + expect(toolTipTop).toBeGreaterThanOrEqual(target.bottom); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js b/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js new file mode 100644 index 0000000000..391e5628df --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import PreviewFunction from "../PreviewFunction"; + +function render(props) { + return shallow(<PreviewFunction {...props} />, { context: { l10n: L10N } }); +} + +describe("PreviewFunction", () => { + it("should return a span", () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan).toMatchSnapshot(); + expect(returnedSpan.name()).toEqual("span"); + }); + + it('should return a span with a class of "function-signature"', () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.hasClass("function-signature")).toBe(true); + }); + + it("should return a span with 3 children", () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children()).toHaveLength(3); + }); + + describe("function name", () => { + it("should be a span", () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().name()).toEqual("span"); + }); + + it('should have a "function-name" class', () => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().hasClass("function-name")).toBe( + true + ); + }); + + it("should be be set to userDisplayName if defined", () => { + const item = { + name: "", + userDisplayName: "chuck", + displayName: "norris", + }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().first().text()).toEqual("chuck"); + }); + + it('should use displayName if defined & no "userDisplayName" exist', () => { + const item = { + displayName: "norris", + name: "last", + }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().first().text()).toEqual("norris"); + }); + + it('should use to name if no "userDisplayName"/"displayName" exist', () => { + const item = { + name: "last", + }; + const returnedSpan = render({ func: item }); + expect(returnedSpan.children().first().first().text()).toEqual("last"); + }); + }); + + describe("render parentheses", () => { + let leftParen; + let rightParen; + + beforeAll(() => { + const item = { name: "" }; + const returnedSpan = render({ func: item }); + const children = returnedSpan.children(); + leftParen = returnedSpan.childAt(1); + rightParen = returnedSpan.childAt(children.length - 1); + }); + + it("should be spans", () => { + expect(leftParen.name()).toEqual("span"); + expect(rightParen.name()).toEqual("span"); + }); + + it("should create a left paren", () => { + expect(leftParen.text()).toEqual("("); + }); + + it("should create a right paren", () => { + expect(rightParen.text()).toEqual(")"); + }); + }); + + describe("render parameters", () => { + let returnedSpan; + let children; + + beforeAll(() => { + const item = { + name: "", + parameterNames: ["one", "two", "three"], + }; + returnedSpan = render({ func: item }); + children = returnedSpan.children(); + }); + + it("should render spans according to the dynamic params given", () => { + expect(children).toHaveLength(8); + }); + + it("should render the parameters names", () => { + expect(returnedSpan.childAt(2).text()).toEqual("one"); + }); + + it("should render the parameters commas", () => { + expect(returnedSpan.childAt(3).text()).toEqual(", "); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js b/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js new file mode 100644 index 0000000000..2751f3abd6 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js @@ -0,0 +1,49 @@ +/* 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 { shallow } from "enzyme"; +import ResultList from "../ResultList"; + +const selectItem = jest.fn(); +const selectedIndex = 1; +const payload = { + items: [ + { + id: 0, + subtitle: "subtitle", + title: "title", + value: "value", + }, + { + id: 1, + subtitle: "subtitle 1", + title: "title 1", + value: "value 1", + }, + ], + selected: selectedIndex, + selectItem, +}; + +describe("Result list", () => { + it("should call onClick function", () => { + const wrapper = shallow(<ResultList {...payload} />); + + wrapper.childAt(selectedIndex).simulate("click"); + expect(selectItem).toHaveBeenCalled(); + }); + + it("should render the component", () => { + const wrapper = shallow(<ResultList {...payload} />); + expect(wrapper).toMatchSnapshot(); + }); + + it("selected index should have 'selected class'", () => { + const wrapper = shallow(<ResultList {...payload} />); + const childHasClass = wrapper.childAt(selectedIndex).hasClass("selected"); + + expect(childHasClass).toEqual(true); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js b/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js new file mode 100644 index 0000000000..c0fff81b24 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js @@ -0,0 +1,126 @@ +/* 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 { shallow } from "enzyme"; +import configureStore from "redux-mock-store"; + +import SearchInput from "../SearchInput"; + +describe("SearchInput", () => { + // !! wrapper is defined outside test scope + // so it will keep values between tests + const mockStore = configureStore([]); + const store = mockStore({ + ui: { mutableSearchOptions: { "foo-search": {} } }, + }); + const wrapper = shallow( + <SearchInput + store={store} + query="" + count={5} + placeholder="A placeholder" + summaryMsg="So many results" + showErrorEmoji={false} + isLoading={false} + onChange={() => {}} + onKeyDown={() => {}} + searchKey="foo-search" + showSearchModifiers={false} + showExcludePatterns={false} + showClose={true} + handleClose={jest.fn()} + setSearchOptions={jest.fn()} + /> + ).dive(); + + it("renders", () => expect(wrapper).toMatchSnapshot()); + + it("shows nav buttons", () => { + wrapper.setProps({ + handleNext: jest.fn(), + handlePrev: jest.fn(), + }); + expect(wrapper).toMatchSnapshot(); + }); + + it("shows svg error emoji", () => { + wrapper.setProps({ showErrorEmoji: true }); + expect(wrapper).toMatchSnapshot(); + }); + + it("shows svg magnifying glass", () => { + wrapper.setProps({ showErrorEmoji: false }); + expect(wrapper).toMatchSnapshot(); + }); + + describe("with optional onHistoryScroll", () => { + const searches = ["foo", "bar", "baz"]; + const createSearch = term => ({ + target: { value: term }, + key: "Enter", + }); + + const scrollUp = currentTerm => ({ + key: "ArrowUp", + target: { value: currentTerm }, + preventDefault: jest.fn(), + }); + const scrollDown = currentTerm => ({ + key: "ArrowDown", + target: { value: currentTerm }, + preventDefault: jest.fn(), + }); + + it("stores entered history in state", () => { + wrapper.setProps({ + onHistoryScroll: jest.fn(), + onKeyDown: jest.fn(), + }); + wrapper.find("input").simulate("keyDown", createSearch(searches[0])); + expect(wrapper.state().history[0]).toEqual(searches[0]); + }); + + it("stores scroll history in state", () => { + const onHistoryScroll = jest.fn(); + wrapper.setProps({ + onHistoryScroll, + onKeyDown: jest.fn(), + }); + wrapper.find("input").simulate("keyDown", createSearch(searches[0])); + wrapper.find("input").simulate("keyDown", createSearch(searches[1])); + expect(wrapper.state().history[0]).toEqual(searches[0]); + expect(wrapper.state().history[1]).toEqual(searches[1]); + }); + + it("scrolls up stored history on arrow up", () => { + const onHistoryScroll = jest.fn(); + wrapper.setProps({ + onHistoryScroll, + onKeyDown: jest.fn(), + }); + wrapper.find("input").simulate("keyDown", createSearch(searches[0])); + wrapper.find("input").simulate("keyDown", createSearch(searches[1])); + wrapper.find("input").simulate("keyDown", scrollUp(searches[1])); + expect(wrapper.state().history[0]).toEqual(searches[0]); + expect(wrapper.state().history[1]).toEqual(searches[1]); + expect(onHistoryScroll).toHaveBeenCalledWith(searches[0]); + }); + + it("scrolls down stored history on arrow down", () => { + const onHistoryScroll = jest.fn(); + wrapper.setProps({ + onHistoryScroll, + onKeyDown: jest.fn(), + }); + wrapper.find("input").simulate("keyDown", createSearch(searches[0])); + wrapper.find("input").simulate("keyDown", createSearch(searches[1])); + wrapper.find("input").simulate("keyDown", createSearch(searches[2])); + wrapper.find("input").simulate("keyDown", scrollUp(searches[2])); + wrapper.find("input").simulate("keyDown", scrollUp(searches[1])); + wrapper.find("input").simulate("keyDown", scrollDown(searches[0])); + expect(onHistoryScroll.mock.calls[2][0]).toBe(searches[1]); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap new file mode 100644 index 0000000000..7ab4ed1ee6 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Accordion basic render 1`] = ` +<ul + className="accordion" +> + <li + className="accordion-item-1" + key="0" + > + <h2 + className="_header" + onClick={[Function]} + onKeyDown={[Function]} + tabIndex="0" + > + <AccessibleImage + className="arrow expanded" + /> + <span + className="header-label" + > + Test Accordion Item 1 + </span> + </h2> + <div + className="_content" + > + <div /> + </div> + </li> + <li + className="accordion-item-2" + key="1" + > + <h2 + className="_header" + onClick={[Function]} + onKeyDown={[Function]} + tabIndex="0" + > + <AccessibleImage + className="arrow " + /> + <span + className="header-label" + > + Test Accordion Item 2 + </span> + <div + className="header-buttons" + tabIndex="-1" + > + <button /> + </div> + </h2> + </li> + <li + className="accordion-item-3" + key="2" + > + <h2 + className="_header" + onClick={[Function]} + onKeyDown={[Function]} + tabIndex="0" + > + <AccessibleImage + className="arrow expanded" + /> + <span + className="header-label" + > + Test Accordion Item 3 + </span> + </h2> + <div + className="_content" + > + <div /> + </div> + </li> +</ul> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap new file mode 100644 index 0000000000..cbeeeaa3f2 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Badge render 1`] = ` +<span + className="badge text-white text-center" +> + 3 +</span> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap new file mode 100644 index 0000000000..5078cebc9e --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BracketArrow render 1`] = ` +<div + className="bracket-arrow down" + style={ + Object { + "bottom": 50, + "left": 10, + "top": 20, + } + } +/> +`; + +exports[`BracketArrow render up 1`] = ` +<div + className="bracket-arrow up" + style={ + Object { + "bottom": 50, + "left": 10, + "top": 20, + } + } +/> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap new file mode 100644 index 0000000000..fd60784327 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Dropdown render 1`] = ` +<div + className="dropdown-block" +> + <div + className="dropdown" + onClick={[Function]} + style={ + Object { + "display": "block", + } + } + > + <div /> + </div> + <button + className="dropdown-button" + onClick={[Function]} + > + ✅ + </button> + <div + className="dropdown-mask" + onClick={[Function]} + style={ + Object { + "display": "block", + } + } + /> +</div> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap new file mode 100644 index 0000000000..e9b9639749 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal renders 1`] = ` +<div + className="modal-wrapper" + onClick={[Function]} +> + <div + className="modal entering" + onClick={[Function]} + /> +</div> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap new file mode 100644 index 0000000000..1c3589a6f8 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap @@ -0,0 +1,549 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Popover mount popover 1`] = ` +<Popover + editorRef={ + Object { + "getBoundingClientRect": [Function], + } + } + mouseout={[Function]} + onKeyDown={[MockFunction]} + onMouseLeave={[MockFunction]} + target={ + Object { + "getBoundingClientRect": [Function], + } + } + targetPosition={ + Object { + "bottom": 0, + "height": 300, + "left": 200, + "right": 0, + "top": 50, + "width": 300, + "x": 100, + "y": 200, + } + } + type="popover" +> + <div + className="popover orientation-right" + style={ + Object { + "left": 500, + "top": -50, + } + } + > + <BracketArrow + left={-4} + orientation="left" + top={98} + > + <div + className="bracket-arrow left" + style={ + Object { + "bottom": undefined, + "left": -4, + "top": 98, + } + } + /> + </BracketArrow> + <div + className="gap" + key="gap" + > + <SmartGap + coords={ + Object { + "left": 500, + "orientation": "right", + "targetMid": Object { + "x": -14, + "y": 98, + }, + "top": -50, + } + } + gapHeight={0} + offset={0} + preview={ + <div + class="popover orientation-right" + style="top: -50px; left: 500px;" + > + <div + class="bracket-arrow left" + style="left: -4px; top: 98px;" + /> + <div + class="gap" + > + <svg + style="height: 0px; width: 480px; position: absolute; margin-left: -100px;" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points="0,300,100,0,100,0,0,400,100,400,100,300" + /> + </svg> + </div> + <h1> + Poppy! + </h1> + </div> + } + token={ + Object { + "getBoundingClientRect": [Function], + } + } + type="popover" + > + <svg + style={ + Object { + "height": 0, + "marginLeft": -100, + "marginTop": undefined, + "position": "absolute", + "width": 480, + } + } + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points={ + Array [ + 0, + 300, + 100, + 0, + 100, + 0, + 0, + 400, + 100, + 400, + 100, + 300, + ] + } + /> + </svg> + </SmartGap> + </div> + <h1> + Poppy! + </h1> + </div> +</Popover> +`; + +exports[`Popover mount tooltip 1`] = ` +<Popover + editorRef={ + Object { + "getBoundingClientRect": [Function], + } + } + mouseout={[Function]} + onKeyDown={[MockFunction]} + onMouseLeave={[MockFunction]} + target={ + Object { + "getBoundingClientRect": [Function], + } + } + targetPosition={ + Object { + "bottom": 0, + "height": 300, + "left": 200, + "right": 0, + "top": 50, + "width": 300, + "x": 100, + "y": 200, + } + } + type="tooltip" +> + <div + className="tooltip orientation-down" + style={ + Object { + "left": -8, + "top": 0, + } + } + > + <div + className="gap" + key="gap" + > + <SmartGap + coords={ + Object { + "left": -8, + "orientation": "down", + "targetMid": Object { + "x": 0, + "y": 0, + }, + "top": 0, + } + } + gapHeight={0} + offset={0} + preview={ + <div + class="tooltip orientation-down" + style="top: 0px; left: -8px;" + > + <div + class="gap" + > + <svg + style="height: -250px; width: 100px; position: absolute; margin-top: -100px;" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points="0,-250,0,-250,28,100,128,100" + /> + </svg> + </div> + <h1> + Toolie! + </h1> + </div> + } + token={ + Object { + "getBoundingClientRect": [Function], + } + } + type="tooltip" + > + <svg + style={ + Object { + "height": -250, + "marginLeft": undefined, + "marginTop": -100, + "position": "absolute", + "width": 100, + } + } + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points={ + Array [ + 0, + -250, + 0, + -250, + 28, + 100, + 128, + 100, + ] + } + /> + </svg> + </SmartGap> + </div> + <h1> + Toolie! + </h1> + </div> +</Popover> +`; + +exports[`Popover render (tooltip) 1`] = ` +<Popover + editorRef={ + Object { + "getBoundingClientRect": [Function], + } + } + mouseout={[Function]} + onKeyDown={[MockFunction]} + onMouseLeave={[MockFunction]} + target={ + Object { + "getBoundingClientRect": [Function], + } + } + targetPosition={ + Object { + "bottom": 0, + "height": 300, + "left": 200, + "right": 0, + "top": 50, + "width": 300, + "x": 100, + "y": 200, + } + } + type="tooltip" +> + <div + className="tooltip orientation-down" + style={ + Object { + "left": -8, + "top": 0, + } + } + > + <div + className="gap" + key="gap" + > + <SmartGap + coords={ + Object { + "left": -8, + "orientation": "down", + "targetMid": Object { + "x": 0, + "y": 0, + }, + "top": 0, + } + } + gapHeight={0} + offset={0} + preview={ + <div + class="tooltip orientation-down" + style="top: 0px; left: -8px;" + > + <div + class="gap" + > + <svg + style="height: -250px; width: 100px; position: absolute; margin-top: -100px;" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points="0,-250,0,-250,28,100,128,100" + /> + </svg> + </div> + <h1> + Toolie! + </h1> + </div> + } + token={ + Object { + "getBoundingClientRect": [Function], + } + } + type="tooltip" + > + <svg + style={ + Object { + "height": -250, + "marginLeft": undefined, + "marginTop": -100, + "position": "absolute", + "width": 100, + } + } + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points={ + Array [ + 0, + -250, + 0, + -250, + 28, + 100, + 128, + 100, + ] + } + /> + </svg> + </SmartGap> + </div> + <h1> + Toolie! + </h1> + </div> +</Popover> +`; + +exports[`Popover render 1`] = ` +<Popover + editorRef={ + Object { + "getBoundingClientRect": [Function], + } + } + mouseout={[Function]} + onKeyDown={[MockFunction]} + onMouseLeave={[MockFunction]} + target={ + Object { + "getBoundingClientRect": [Function], + } + } + targetPosition={ + Object { + "bottom": 0, + "height": 300, + "left": 200, + "right": 0, + "top": 50, + "width": 300, + "x": 100, + "y": 200, + } + } + type="popover" +> + <div + className="popover orientation-right" + style={ + Object { + "left": 500, + "top": -50, + } + } + > + <BracketArrow + left={-4} + orientation="left" + top={98} + > + <div + className="bracket-arrow left" + style={ + Object { + "bottom": undefined, + "left": -4, + "top": 98, + } + } + /> + </BracketArrow> + <div + className="gap" + key="gap" + > + <SmartGap + coords={ + Object { + "left": 500, + "orientation": "right", + "targetMid": Object { + "x": -14, + "y": 98, + }, + "top": -50, + } + } + gapHeight={0} + offset={0} + preview={ + <div + class="popover orientation-right" + style="top: -50px; left: 500px;" + > + <div + class="bracket-arrow left" + style="left: -4px; top: 98px;" + /> + <div + class="gap" + > + <svg + style="height: 0px; width: 480px; position: absolute; margin-left: -100px;" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points="0,300,100,0,100,0,0,400,100,400,100,300" + /> + </svg> + </div> + <h1> + Poppy! + </h1> + </div> + } + token={ + Object { + "getBoundingClientRect": [Function], + } + } + type="popover" + > + <svg + style={ + Object { + "height": 0, + "marginLeft": -100, + "marginTop": undefined, + "position": "absolute", + "width": 480, + } + } + version="1.1" + xmlns="http://www.w3.org/2000/svg" + > + <polygon + fill="transparent" + points={ + Array [ + 0, + 300, + 100, + 0, + 100, + 0, + 0, + 400, + 100, + 400, + 100, + 300, + ] + } + /> + </svg> + </SmartGap> + </div> + <h1> + Poppy! + </h1> + </div> +</Popover> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap new file mode 100644 index 0000000000..e766bd45aa --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PreviewFunction should return a span 1`] = ` +<span + className="function-signature" +> + <span + className="function-name" + > + <anonymous> + </span> + <span + className="paren" + > + ( + </span> + <span + className="paren" + > + ) + </span> +</span> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap new file mode 100644 index 0000000000..d3d8b27575 --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Result list should render the component 1`] = ` +<ul + aria-live="polite" + className="result-list small" + id="result-list" + role="listbox" +> + <li + aria-describedby="0-subtitle" + aria-labelledby="0-title" + className="result-item" + key="0value0" + onClick={[Function]} + role="option" + title="value" + > + <div + className="title" + id="0-title" + > + title + </div> + <div + className="subtitle" + id="0-subtitle" + > + subtitle + </div> + </li> + <li + aria-describedby="1-subtitle" + aria-labelledby="1-title" + className="result-item selected" + key="1value 11" + onClick={[Function]} + role="option" + title="value 1" + > + <div + className="title" + id="1-title" + > + title 1 + </div> + <div + className="subtitle" + id="1-subtitle" + > + subtitle 1 + </div> + </li> +</ul> +`; diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap new file mode 100644 index 0000000000..c56a13dc3b --- /dev/null +++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap @@ -0,0 +1,267 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchInput renders 1`] = ` +<div + className="search-outline" +> + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + /> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="A placeholder" + spellCheck={false} + value="" + /> + <div + className="search-field-summary" + > + So many results + </div> + <div + className="search-buttons-bar" + > + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="" + handleClick={[MockFunction]} + /> + </div> + </div> +</div> +`; + +exports[`SearchInput shows nav buttons 1`] = ` +<div + className="search-outline" +> + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + /> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="A placeholder" + spellCheck={false} + value="" + /> + <div + className="search-field-summary" + > + So many results + </div> + <div + className="search-nav-buttons" + > + <button + className="nav-btn prev" + key="arrow-up" + onClick={[MockFunction]} + title="Previous result" + type="arrow-up" + > + <AccessibleImage + className="arrow-up" + /> + </button> + <button + className="nav-btn next" + key="arrow-down" + onClick={[MockFunction]} + title="Next result" + type="arrow-down" + > + <AccessibleImage + className="arrow-down" + /> + </button> + </div> + <div + className="search-buttons-bar" + > + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="" + handleClick={[MockFunction]} + /> + </div> + </div> +</div> +`; + +exports[`SearchInput shows svg error emoji 1`] = ` +<div + className="search-outline" +> + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + /> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="A placeholder" + spellCheck={false} + value="" + /> + <div + className="search-field-summary" + > + So many results + </div> + <div + className="search-nav-buttons" + > + <button + className="nav-btn prev" + key="arrow-up" + onClick={[MockFunction]} + title="Previous result" + type="arrow-up" + > + <AccessibleImage + className="arrow-up" + /> + </button> + <button + className="nav-btn next" + key="arrow-down" + onClick={[MockFunction]} + title="Next result" + type="arrow-down" + > + <AccessibleImage + className="arrow-down" + /> + </button> + </div> + <div + className="search-buttons-bar" + > + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="" + handleClick={[MockFunction]} + /> + </div> + </div> +</div> +`; + +exports[`SearchInput shows svg magnifying glass 1`] = ` +<div + className="search-outline" +> + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + /> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="A placeholder" + spellCheck={false} + value="" + /> + <div + className="search-field-summary" + > + So many results + </div> + <div + className="search-nav-buttons" + > + <button + className="nav-btn prev" + key="arrow-up" + onClick={[MockFunction]} + title="Previous result" + type="arrow-up" + > + <AccessibleImage + className="arrow-up" + /> + </button> + <button + className="nav-btn next" + key="arrow-down" + onClick={[MockFunction]} + title="Next result" + type="arrow-down" + > + <AccessibleImage + className="arrow-down" + /> + </button> + </div> + <div + className="search-buttons-bar" + > + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="" + handleClick={[MockFunction]} + /> + </div> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/A11yIntention.spec.js b/devtools/client/debugger/src/components/test/A11yIntention.spec.js new file mode 100644 index 0000000000..6a529b851d --- /dev/null +++ b/devtools/client/debugger/src/components/test/A11yIntention.spec.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import A11yIntention from "../A11yIntention"; + +function render() { + return shallow( + <A11yIntention> + <span>hello world</span> + </A11yIntention> + ); +} + +describe("A11yIntention", () => { + it("renders its children", () => { + const component = render(); + expect(component).toMatchSnapshot(); + }); + + it("indicates that the mouse or keyboard is being used", () => { + const component = render(); + expect(component.prop("className")).toEqual("A11y-mouse"); + + component.simulate("keyDown"); + expect(component.prop("className")).toEqual("A11y-keyboard"); + + component.simulate("mouseDown"); + expect(component.prop("className")).toEqual("A11y-mouse"); + }); +}); diff --git a/devtools/client/debugger/src/components/test/Outline.spec.js b/devtools/client/debugger/src/components/test/Outline.spec.js new file mode 100644 index 0000000000..c104da53c3 --- /dev/null +++ b/devtools/client/debugger/src/components/test/Outline.spec.js @@ -0,0 +1,304 @@ +/* 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 { shallow } from "enzyme"; +import Outline from "../../components/PrimaryPanes/Outline"; +import { makeSymbolDeclaration } from "../../utils/test-head"; +import { mockcx } from "../../utils/test-mockup"; +import { showMenu } from "../../context-menu/menu"; +import { copyToTheClipboard } from "../../utils/clipboard"; + +jest.mock("../../context-menu/menu", () => ({ showMenu: jest.fn() })); +jest.mock("../../utils/clipboard", () => ({ copyToTheClipboard: jest.fn() })); + +const sourceId = "id"; +const mockFunctionText = "mock function text"; + +function generateDefaults(overrides) { + return { + cx: mockcx, + selectLocation: jest.fn(), + selectedSource: { id: sourceId }, + getFunctionText: jest.fn().mockReturnValue(mockFunctionText), + flashLineRange: jest.fn(), + isHidden: false, + symbols: {}, + selectedLocation: { id: sourceId }, + onAlphabetizeClick: jest.fn(), + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<Outline.WrappedComponent {...props} />); + const instance = component.instance(); + return { component, props, instance }; +} + +describe("Outline", () => { + afterEach(() => { + copyToTheClipboard.mockClear(); + showMenu.mockClear(); + }); + + it("renders a list of functions when properties change", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("my_example_function1", 21), + makeSymbolDeclaration("my_example_function2", 22), + ], + }; + + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + + it("selects a line of code in the current file on click", async () => { + const startLine = 12; + const symbols = { + functions: [makeSymbolDeclaration("my_example_function", startLine)], + }; + + const { component, props } = render({ symbols }); + + const { selectLocation } = props; + const listItem = component.find("li").first(); + listItem.simulate("click"); + expect(selectLocation).toHaveBeenCalledWith(mockcx, { + line: startLine, + column: undefined, + sourceId, + source: { + id: sourceId, + }, + sourceActor: null, + sourceActorId: undefined, + sourceUrl: "", + }); + }); + + describe("renders outline", () => { + describe("renders loading", () => { + it("if symbols is not defined", () => { + const { component } = render({ + symbols: null, + }); + expect(component).toMatchSnapshot(); + }); + }); + + it("renders ignore anonymous functions", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("my_example_function1", 21), + makeSymbolDeclaration("anonymous", 25), + ], + }; + + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + describe("renders placeholder", () => { + it("`No File Selected` if selectedSource is not defined", async () => { + const { component } = render({ + selectedSource: null, + }); + expect(component).toMatchSnapshot(); + }); + + it("`No functions` if all func are anonymous", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("anonymous", 25), + makeSymbolDeclaration("anonymous", 30), + ], + }; + + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + + it("`No functions` if symbols has no func", async () => { + const symbols = { + functions: [], + }; + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + }); + + it("sorts functions alphabetically by function name", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("c_function", 25), + makeSymbolDeclaration("x_function", 30), + makeSymbolDeclaration("a_function", 70), + ], + }; + + const { component } = render({ + symbols, + alphabetizeOutline: true, + }); + expect(component).toMatchSnapshot(); + }); + + it("calls onAlphabetizeClick when sort button is clicked", async () => { + const symbols = { + functions: [makeSymbolDeclaration("example_function", 25)], + }; + + const { component, props } = render({ symbols }); + + await component + .find(".outline-footer") + .find("button") + .simulate("click", {}); + + expect(props.onAlphabetizeClick).toHaveBeenCalled(); + }); + + it("renders functions by function class", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("x_function", 25, 26, "x_klass"), + makeSymbolDeclaration("a2_function", 30, 31, "a_klass"), + makeSymbolDeclaration("a1_function", 70, 71, "a_klass"), + ], + classes: [ + makeSymbolDeclaration("x_klass", 24, 27), + makeSymbolDeclaration("a_klass", 29, 72), + ], + }; + + const { component } = render({ symbols }); + expect(component).toMatchSnapshot(); + }); + + it("renders functions by function class, alphabetically", async () => { + const symbols = { + functions: [ + makeSymbolDeclaration("x_function", 25, 26, "x_klass"), + makeSymbolDeclaration("a2_function", 30, 31, "a_klass"), + makeSymbolDeclaration("a1_function", 70, 71, "a_klass"), + ], + classes: [ + makeSymbolDeclaration("x_klass", 24, 27), + makeSymbolDeclaration("a_klass", 29, 72), + ], + }; + + const { component } = render({ + symbols, + alphabetizeOutline: true, + }); + expect(component).toMatchSnapshot(); + }); + + it("selects class on click on class headline", async () => { + const symbols = { + functions: [makeSymbolDeclaration("x_function", 25, 26, "x_klass")], + classes: [makeSymbolDeclaration("x_klass", 24, 27)], + }; + + const { component, props } = render({ symbols }); + + await component.find("h2").simulate("click", {}); + + expect(props.selectLocation).toHaveBeenCalledWith(mockcx, { + line: 24, + column: undefined, + sourceId, + source: { + id: sourceId, + }, + sourceActor: null, + sourceActorId: undefined, + sourceUrl: "", + }); + }); + + it("does not select an item if selectedSource is not defined", async () => { + const { instance, props } = render({ selectedSource: null }); + await instance.selectItem({}); + expect(props.selectLocation).not.toHaveBeenCalled(); + }); + }); + + describe("onContextMenu of Outline", () => { + it("is called onContextMenu for each item", async () => { + const event = { event: "oncontextmenu" }; + const fn = makeSymbolDeclaration("exmple_function", 2); + const symbols = { + functions: [fn], + }; + + const { component, instance } = render({ symbols }); + instance.onContextMenu = jest.fn(() => {}); + await component + .find(".outline-list__element") + .simulate("contextmenu", event); + + expect(instance.onContextMenu).toHaveBeenCalledWith(event, fn); + }); + + it("does not show menu with no selected source", async () => { + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const { instance } = render({ + selectedSource: null, + }); + await instance.onContextMenu(mockEvent, {}); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + expect(showMenu).not.toHaveBeenCalled(); + }); + + it("shows menu to copy func, copies to clipboard on click", async () => { + const startLine = 12; + const endLine = 21; + const func = makeSymbolDeclaration( + "my_example_function", + startLine, + endLine + ); + const symbols = { + functions: [func], + }; + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + const { instance, props } = render({ symbols }); + await instance.onContextMenu(mockEvent, func); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + + const expectedMenuOptions = [ + { + accesskey: "F", + click: expect.any(Function), + disabled: false, + id: "node-menu-copy-function", + label: "Copy function", + }, + ]; + expect(props.getFunctionText).toHaveBeenCalledWith(12); + expect(showMenu).toHaveBeenCalledWith(mockEvent, expectedMenuOptions); + + showMenu.mock.calls[0][1][0].click(); + expect(copyToTheClipboard).toHaveBeenCalledWith(mockFunctionText); + expect(props.flashLineRange).toHaveBeenCalledWith({ + end: endLine, + sourceId, + start: startLine, + }); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/test/OutlineFilter.spec.js b/devtools/client/debugger/src/components/test/OutlineFilter.spec.js new file mode 100644 index 0000000000..91ec7c0d97 --- /dev/null +++ b/devtools/client/debugger/src/components/test/OutlineFilter.spec.js @@ -0,0 +1,45 @@ +/* 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 { shallow } from "enzyme"; +import OutlineFilter from "../../components/PrimaryPanes/OutlineFilter"; + +function generateDefaults(overrides) { + return { + filter: "", + updateFilter: jest.fn(), + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<OutlineFilter {...props} />); + const instance = component.instance(); + return { component, props, instance }; +} + +describe("OutlineFilter", () => { + it("shows an input with no value when filter is empty", async () => { + const { component } = render({ filter: "" }); + expect(component).toMatchSnapshot(); + }); + + it("shows an input with the filter when it is not empty", async () => { + const { component } = render({ filter: "abc" }); + expect(component).toMatchSnapshot(); + }); + + it("calls props.updateFilter on change", async () => { + const updateFilter = jest.fn(); + const { component } = render({ updateFilter }); + const input = component.find("input"); + input.simulate("change", { target: { value: "a" } }); + input.simulate("change", { target: { value: "ab" } }); + expect(updateFilter).toHaveBeenCalled(); + expect(updateFilter.mock.calls[0][0]).toBe("a"); + expect(updateFilter.mock.calls[1][0]).toBe("ab"); + }); +}); diff --git a/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js b/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js new file mode 100644 index 0000000000..3cd21bac05 --- /dev/null +++ b/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js @@ -0,0 +1,898 @@ +/* eslint max-nested-callbacks: ["error", 4] */ +/* 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 { shallow, mount } from "enzyme"; +import { QuickOpenModal } from "../QuickOpenModal"; +import { mockcx } from "../../utils/test-mockup"; +import { getDisplayURL } from "../../utils/sources-tree/getURL"; +import { searchKeys } from "../../constants"; + +jest.mock("fuzzaldrin-plus"); + +import { filter } from "fuzzaldrin-plus"; + +function generateModal(propOverrides, renderType = "shallow") { + const mockStore = configureStore([]); + const store = mockStore({ + ui: { + mutableSearchOptions: { + [searchKeys.QUICKOPEN_SEARCH]: { + regexMatch: false, + wholeWord: false, + caseSensitive: false, + excludePatterns: "", + }, + }, + }, + }); + const props = { + cx: mockcx, + enabled: false, + query: "", + searchType: "sources", + displayedSources: [], + blackBoxRanges: {}, + tabUrls: [], + selectSpecificLocation: jest.fn(), + setQuickOpenQuery: jest.fn(), + highlightLineRange: jest.fn(), + clearHighlightLineRange: jest.fn(), + closeQuickOpen: jest.fn(), + shortcutsModalEnabled: false, + symbols: { functions: [] }, + symbolsLoading: false, + toggleShortcutsModal: jest.fn(), + isOriginal: false, + thread: "FakeThread", + ...propOverrides, + }; + return { + wrapper: + renderType === "shallow" + ? shallow( + <Provider store={store}> + <QuickOpenModal {...props} /> + </Provider> + ).dive() + : mount( + <Provider store={store}> + <QuickOpenModal {...props} /> + </Provider> + ), + props, + }; +} + +function generateQuickOpenResult(title) { + return { + id: "qor", + value: "", + title, + }; +} + +async function waitForUpdateResultsThrottle() { + await new Promise(res => + setTimeout(res, QuickOpenModal.UPDATE_RESULTS_THROTTLE) + ); +} + +describe("QuickOpenModal", () => { + beforeEach(() => { + filter.mockClear(); + }); + test("Doesn't render when disabled", () => { + const { wrapper } = generateModal(); + expect(wrapper).toMatchSnapshot(); + }); + + test("Renders when enabled", () => { + const { wrapper } = generateModal({ enabled: true }); + expect(wrapper).toMatchSnapshot(); + }); + + test("Basic render with mount", () => { + const { wrapper } = generateModal({ enabled: true }, "mount"); + expect(wrapper).toMatchSnapshot(); + }); + + test("Basic render with mount & searchType = functions", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "@", + searchType: "functions", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + test("toggles shortcut modal if enabled", () => { + const { props } = generateModal( + { + enabled: true, + query: "test", + shortcutsModalEnabled: true, + toggleShortcutsModal: jest.fn(), + }, + "shallow" + ); + expect(props.toggleShortcutsModal).toHaveBeenCalled(); + }); + + test("shows top sources", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "", + displayedSources: [ + { + url: "mozilla.com", + displayURL: getDisplayURL("mozilla.com"), + }, + ], + tabUrls: ["mozilla.com"], + }, + "shallow" + ); + expect(wrapper.state("results")).toEqual([ + { + id: undefined, + icon: "tab result-item-icon", + subtitle: "mozilla.com", + title: "mozilla.com", + url: "mozilla.com", + value: "mozilla.com", + source: { + url: "mozilla.com", + displayURL: getDisplayURL("mozilla.com"), + }, + }, + ]); + }); + + describe("shows loading", () => { + it("loads with function type search", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "", + searchType: "functions", + symbolsLoading: true, + }, + "shallow" + ); + expect(wrapper).toMatchSnapshot(); + }); + }); + + test("Ensure anonymous functions do not render in QuickOpenModal", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "@", + searchType: "functions", + symbols: { + functions: [ + generateQuickOpenResult("anonymous"), + generateQuickOpenResult("c"), + generateQuickOpenResult("anonymous"), + ], + variables: [], + }, + }, + "mount" + ); + expect(wrapper.find("ResultList")).toHaveLength(1); + expect(wrapper.find("li")).toHaveLength(1); + }); + + test("Basic render with mount & searchType = variables", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "#", + searchType: "variables", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + test("Basic render with mount & searchType = shortcuts", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "?", + searchType: "shortcuts", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + expect(wrapper.find("ResultList")).toHaveLength(1); + expect(wrapper.find("li")).toHaveLength(3); + }); + + test("updateResults on enable", () => { + const { wrapper } = generateModal({}, "mount"); + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ enabled: true }); + expect(wrapper).toMatchSnapshot(); + }); + + test("basic source search", async () => { + const { wrapper } = generateModal( + { + enabled: true, + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + wrapper.find("input").simulate("change", { target: { value: "somefil" } }); + await waitForUpdateResultsThrottle(); + expect(filter).toHaveBeenCalledWith([], "somefil", { + key: "value", + maxResults: 100, + }); + }); + + test("basic gotoSource search", async () => { + const { wrapper } = generateModal( + { + enabled: true, + searchType: "gotoSource", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + wrapper + .find("input") + .simulate("change", { target: { value: "somefil:33" } }); + + await waitForUpdateResultsThrottle(); + + expect(filter).toHaveBeenCalledWith([], "somefil", { + key: "value", + maxResults: 100, + }); + }); + + describe("empty symbol search", () => { + it("basic symbol search", async () => { + const { wrapper } = generateModal( + { + enabled: true, + searchType: "functions", + symbols: { + functions: [], + variables: [], + }, + // symbol searching relies on a source being selected. + // So we dummy out the source and the API. + selectedSource: { id: "foo", text: "yo" }, + selectedContentLoaded: true, + }, + "mount" + ); + + wrapper + .find("input") + .simulate("change", { target: { value: "@someFunc" } }); + await waitForUpdateResultsThrottle(); + expect(filter).toHaveBeenCalledWith([], "someFunc", { + key: "value", + maxResults: 100, + }); + }); + + it("does not do symbol search if no selected source", () => { + const { wrapper } = generateModal( + { + enabled: true, + searchType: "functions", + symbols: { + functions: [], + variables: [], + }, + // symbol searching relies on a source being selected. + // So we dummy out the source and the API. + selectedSource: null, + selectedContentLoaded: false, + }, + "mount" + ); + wrapper + .find("input") + .simulate("change", { target: { value: "@someFunc" } }); + expect(filter).not.toHaveBeenCalled(); + }); + }); + + test("Simple goto search query = :abc & searchType = goto", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: ":abc", + searchType: "goto", + symbols: { + functions: [], + variables: [], + }, + }, + "mount" + ); + expect(wrapper.childAt(0)).toMatchSnapshot(); + expect(wrapper.childAt(0).state().results).toEqual(null); + }); + + describe("onEnter", () => { + it("on Enter go to location", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":34:12", + searchType: "goto", + selectedSource: { id: "foo" }, + }, + "shallow" + ); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: 12, + line: 34, + sourceId: "foo", + source: { + id: "foo", + }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + }); + + it("on Enter go to location with sourceId", () => { + const sourceId = "source_id"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":34:12", + searchType: "goto", + selectedSource: { id: sourceId }, + selectedContentLoaded: true, + }, + "shallow" + ); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: 12, + line: 34, + sourceId, + source: { + id: sourceId, + }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + }); + + it("on Enter with no location, does no action", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":", + searchType: "goto", + }, + "shallow" + ); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + }); + + it("on Enter with empty results, handle no item", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "", + searchType: "shortcuts", + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [], + selectedIndex: 0, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + }); + + it("on Enter with results, handle symbol shortcut", () => { + const symbols = [":", "#", "@"]; + for (const symbol of symbols) { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "", + searchType: "shortcuts", + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{ id: symbol }], + selectedIndex: 0, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.setQuickOpenQuery).toHaveBeenCalledWith(symbol); + } + }); + + it("on Enter, returns the result with the selected index", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "@test", + searchType: "shortcuts", + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{ id: "@" }, { id: ":" }, { id: "#" }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.setQuickOpenQuery).toHaveBeenCalledWith(":"); + }); + + it("on Enter with results, handle result item", () => { + const id = "test_id"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "@test", + searchType: "other", + selectedSource: { id }, + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{}, { id }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: undefined, + sourceId: id, + line: 0, + source: { id }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + }); + + it("on Enter with results, handle functions result item", () => { + const id = "test_id"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "@test", + searchType: "functions", + symbols: { + functions: [], + variables: {}, + }, + selectedSource: { id }, + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{}, { id }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: undefined, + line: 0, + sourceId: id, + source: { id }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + }); + + it("on Enter with results, handle gotoSource search", () => { + const id = "test_id"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":3:4", + searchType: "gotoSource", + symbols: { + functions: [], + variables: {}, + }, + selectedSource: { id }, + }, + "shallow" + ); + wrapper.setState(() => ({ + results: [{}, { id }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, { + column: 4, + line: 3, + sourceId: id, + source: { id }, + sourceActorId: undefined, + sourceActor: null, + sourceUrl: "", + }); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + }); + + it("on Enter with results, handle shortcuts search", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "@", + searchType: "shortcuts", + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const id = "#"; + wrapper.setState(() => ({ + results: [{}, { id }], + selectedIndex: 1, + })); + const event = { + key: "Enter", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.setQuickOpenQuery).toHaveBeenCalledWith(id); + }); + }); + + describe("onKeyDown", () => { + it("does nothing if search type is not goto", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "other", + }, + "shallow" + ); + wrapper.find("Connect(SearchInput)").simulate("keydown", {}); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.setQuickOpenQuery).not.toHaveBeenCalled(); + }); + + it("on Tab, close modal", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: ":34:12", + searchType: "goto", + }, + "shallow" + ); + const event = { + key: "Tab", + }; + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(props.closeQuickOpen).toHaveBeenCalled(); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + }); + }); + + describe("with arrow keys", () => { + it("on ArrowUp, traverse results up with functions", () => { + const sourceId = "sourceId"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "functions", + selectedSource: { id: sourceId }, + selectedContentLoaded: true, + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowUp", + }; + const location = { + sourceId: "sourceId", + start: { + line: 1, + }, + end: { + line: 3, + }, + }; + + wrapper.setState(() => ({ + results: [{ id: "0", location }, { id: "1" }, { id: "2" }], + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(0); + expect(props.highlightLineRange).toHaveBeenCalledWith({ + sourceId: "sourceId", + end: 3, + start: 1, + }); + }); + + it("on ArrowDown, traverse down with no results", () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "goto", + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowDown", + }; + wrapper.setState(() => ({ + results: null, + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(0); + expect(props.selectSpecificLocation).not.toHaveBeenCalledWith(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + }); + + it("on ArrowUp, traverse results up to function with no location", () => { + const sourceId = "sourceId"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "functions", + selectedSource: { id: sourceId }, + selectedContentLoaded: true, + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowUp", + }; + wrapper.setState(() => ({ + results: [{ id: "0", location: null }, { id: "1" }, { id: "2" }], + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(0); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + expect(props.clearHighlightLineRange).toHaveBeenCalled(); + }); + + it( + "on ArrowDown, traverse down results, without " + + "taking action if no selectedSource", + () => { + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "variables", + selectedSource: null, + selectedContentLoaded: true, + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowDown", + }; + const location = { + sourceId: "sourceId", + start: { + line: 7, + }, + }; + wrapper.setState(() => ({ + results: [{ id: "0", location }, { id: "1" }, { id: "2" }], + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(2); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + } + ); + + it( + "on ArrowUp, traverse up results, without taking action if " + + "the query is not for variables or functions", + () => { + const sourceId = "sourceId"; + const { wrapper, props } = generateModal( + { + enabled: true, + query: "test", + searchType: "other", + selectedSource: { id: sourceId }, + selectedContentLoaded: true, + symbols: { + functions: [], + variables: {}, + }, + }, + "shallow" + ); + const event = { + preventDefault: jest.fn(), + key: "ArrowUp", + }; + const location = { + sourceId: "sourceId", + start: { + line: 7, + }, + }; + wrapper.setState(() => ({ + results: [{ id: "0", location }, { id: "1" }, { id: "2" }], + selectedIndex: 1, + })); + wrapper.find("Connect(SearchInput)").simulate("keydown", event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(wrapper.state().selectedIndex).toEqual(0); + expect(props.selectSpecificLocation).not.toHaveBeenCalled(); + expect(props.highlightLineRange).not.toHaveBeenCalled(); + } + ); + }); + + describe("showErrorEmoji", () => { + it("true when no count + query", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "test", + searchType: "other", + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("false when count + query", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "dasdasdas", + }, + "mount" + ); + wrapper.setState(() => ({ + results: [1, 2], + })); + expect(wrapper).toMatchSnapshot(); + }); + + it("false when no query", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: "", + searchType: "other", + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("false when goto numeric ':2222'", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: ":2222", + searchType: "goto", + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("true when goto not numeric ':22k22'", () => { + const { wrapper } = generateModal( + { + enabled: true, + query: ":22k22", + searchType: "goto", + }, + "mount" + ); + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/test/ShortcutsModal.spec.js b/devtools/client/debugger/src/components/test/ShortcutsModal.spec.js new file mode 100644 index 0000000000..d3264c02e0 --- /dev/null +++ b/devtools/client/debugger/src/components/test/ShortcutsModal.spec.js @@ -0,0 +1,32 @@ +/* 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 { shallow } from "enzyme"; +import { ShortcutsModal } from "../ShortcutsModal"; + +function render(overrides = {}) { + const props = { + enabled: true, + handleClose: jest.fn(), + ...overrides, + }; + const component = shallow(<ShortcutsModal {...props} />); + + return { component, props }; +} + +describe("ShortcutsModal", () => { + it("renders when enabled", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("renders nothing when not enabled", () => { + const { component } = render({ + enabled: false, + }); + expect(component.text()).toBe(""); + }); +}); diff --git a/devtools/client/debugger/src/components/test/WelcomeBox.spec.js b/devtools/client/debugger/src/components/test/WelcomeBox.spec.js new file mode 100644 index 0000000000..0a1dbc7459 --- /dev/null +++ b/devtools/client/debugger/src/components/test/WelcomeBox.spec.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; + +import { WelcomeBox } from "../WelcomeBox"; + +function render(overrides = {}) { + const props = { + horizontal: false, + togglePaneCollapse: jest.fn(), + endPanelCollapsed: false, + setActiveSearch: jest.fn(), + openQuickOpen: jest.fn(), + toggleShortcutsModal: jest.fn(), + setPrimaryPaneTab: jest.fn(), + ...overrides, + }; + const component = shallow(<WelcomeBox {...props} />); + + return { component, props }; +} + +describe("WelomeBox", () => { + it("renders with default values", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + + it("doesn't render toggle button in horizontal mode", () => { + const { component } = render({ + horizontal: true, + }); + expect(component.find("PaneToggleButton")).toHaveLength(0); + }); + + it("calls correct function on searchSources click", () => { + const { component, props } = render(); + + component.find(".welcomebox__searchSources").simulate("click"); + expect(props.openQuickOpen).toHaveBeenCalled(); + }); + + it("calls correct function on searchProject click", () => { + const { component, props } = render(); + + component.find(".welcomebox__searchProject").simulate("click"); + expect(props.setActiveSearch).toHaveBeenCalled(); + }); + + it("calls correct function on allShotcuts click", () => { + const { component, props } = render(); + + component.find(".welcomebox__allShortcuts").simulate("click"); + expect(props.toggleShortcutsModal).toHaveBeenCalled(); + }); +}); diff --git a/devtools/client/debugger/src/components/test/WhyPaused.spec.js b/devtools/client/debugger/src/components/test/WhyPaused.spec.js new file mode 100644 index 0000000000..eff87c7cd1 --- /dev/null +++ b/devtools/client/debugger/src/components/test/WhyPaused.spec.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React from "react"; +import { shallow } from "enzyme"; +import WhyPaused from "../SecondaryPanes/WhyPaused.js"; + +function render(why, delay) { + const props = { why, delay }; + const component = shallow(<WhyPaused.WrappedComponent {...props} />); + + return { component, props }; +} + +describe("WhyPaused", () => { + it("should pause reason with message", () => { + const why = { + type: "breakpoint", + message: "bla is hit", + }; + const { component } = render(why); + expect(component).toMatchSnapshot(); + }); + + it("should show pause reason with exception details", () => { + const why = { + type: "exception", + exception: { + class: "ReferenceError", + isError: true, + preview: { + name: "ReferenceError", + message: "o is not defined", + }, + }, + }; + + const { component } = render(why); + expect(component).toMatchSnapshot(); + }); + + it("should show pause reason with exception string", () => { + const why = { + type: "exception", + exception: "Not Available", + }; + + const { component } = render(why); + expect(component).toMatchSnapshot(); + }); + + it("should show an empty div when there is no pause reason", () => { + const why = undefined; + + const { component } = render(why); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap new file mode 100644 index 0000000000..80fdfa1dec --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`A11yIntention renders its children 1`] = ` +<div + className="A11y-mouse" + onKeyDown={[Function]} + onMouseDown={[Function]} +> + <span> + hello world + </span> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap new file mode 100644 index 0000000000..4e2e2c98fd --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap @@ -0,0 +1,505 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Outline renders a list of functions when properties change 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__element" + key="my_example_function1:21:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "my_example_function1", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="my_example_function2:22:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "my_example_function2", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; + +exports[`Outline renders outline renders functions by function class 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__class" + key="x_klass" + > + <h2 + className="" + onClick={[Function]} + > + <div> + <span + className="keyword" + > + class + </span> + + x_klass + </div> + </h2> + <ul + className="outline-list__class-list" + > + <li + className="outline-list__element" + key="x_function:25:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "x_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + </li> + <li + className="outline-list__class" + key="a_klass" + > + <h2 + className="" + onClick={[Function]} + > + <div> + <span + className="keyword" + > + class + </span> + + a_klass + </div> + </h2> + <ul + className="outline-list__class-list" + > + <li + className="outline-list__element" + key="a2_function:30:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a2_function", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="a1_function:70:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a1_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; + +exports[`Outline renders outline renders functions by function class, alphabetically 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__class" + key="a_klass" + > + <h2 + className="" + onClick={[Function]} + > + <div> + <span + className="keyword" + > + class + </span> + + a_klass + </div> + </h2> + <ul + className="outline-list__class-list" + > + <li + className="outline-list__element" + key="a1_function:70:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a1_function", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="a2_function:30:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a2_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + </li> + <li + className="outline-list__class" + key="x_klass" + > + <h2 + className="" + onClick={[Function]} + > + <div> + <span + className="keyword" + > + class + </span> + + x_klass + </div> + </h2> + <ul + className="outline-list__class-list" + > + <li + className="outline-list__element" + key="x_function:25:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "x_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="active" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; + +exports[`Outline renders outline renders ignore anonymous functions 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__element" + key="my_example_function1:21:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "my_example_function1", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; + +exports[`Outline renders outline renders loading if symbols is not defined 1`] = ` +<div + className="outline-pane-info" +> + Loading… +</div> +`; + +exports[`Outline renders outline renders placeholder \`No File Selected\` if selectedSource is not defined 1`] = ` +<div + className="outline-pane-info" +> + No file selected +</div> +`; + +exports[`Outline renders outline renders placeholder \`No functions\` if all func are anonymous 1`] = ` +<div + className="outline-pane-info" +> + No functions +</div> +`; + +exports[`Outline renders outline renders placeholder \`No functions\` if symbols has no func 1`] = ` +<div + className="outline-pane-info" +> + No functions +</div> +`; + +exports[`Outline renders outline sorts functions alphabetically by function name 1`] = ` +<div + className="outline" +> + <div> + <OutlineFilter + filter="" + updateFilter={[Function]} + /> + <ul + className="outline-list devtools-monospace" + dir="ltr" + > + <li + className="outline-list__element" + key="a_function:70:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "a_function", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="c_function:25:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "c_function", + "parameterNames": undefined, + } + } + /> + </li> + <li + className="outline-list__element" + key="x_function:30:undefined" + onClick={[Function]} + onContextMenu={[Function]} + > + <span + className="outline-list__element-icon" + > + λ + </span> + <PreviewFunction + func={ + Object { + "name": "x_function", + "parameterNames": undefined, + } + } + /> + </li> + </ul> + <div + className="outline-footer" + > + <button + className="active" + onClick={[MockFunction]} + > + Sort by name + </button> + </div> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap new file mode 100644 index 0000000000..c4e03b77cd --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OutlineFilter shows an input with no value when filter is empty 1`] = ` +<div + className="outline-filter" +> + <form> + <input + className="outline-filter-input devtools-filterinput" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter functions" + type="text" + value="" + /> + </form> +</div> +`; + +exports[`OutlineFilter shows an input with the filter when it is not empty 1`] = ` +<div + className="outline-filter" +> + <form> + <input + className="outline-filter-input devtools-filterinput" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Filter functions" + type="text" + value="abc" + /> + </form> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap new file mode 100644 index 0000000000..83d643a597 --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap @@ -0,0 +1,1694 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`QuickOpenModal Basic render with mount & searchType = functions 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="@" + searchType="functions" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + "variables": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="@" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="@" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="@" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="small" + > + <ul + aria-live="polite" + className="result-list small" + id="result-list" + role="listbox" + /> + </ResultList> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal Basic render with mount & searchType = variables 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="#" + searchType="variables" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + "variables": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="#" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="#" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="#" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal Basic render with mount 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="" + searchType="sources" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field big" + 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="Go to file…" + spellCheck={false} + value="" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="big" + > + <ul + aria-live="polite" + className="result-list big" + id="result-list" + role="listbox" + /> + </ResultList> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal Doesn't render when disabled 1`] = `""`; + +exports[`QuickOpenModal Renders when enabled 1`] = ` +<Slide + handleClose={[Function]} + in={true} +> + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + /> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="big" + /> +</Slide> +`; + +exports[`QuickOpenModal Simple goto search query = :abc & searchType = goto 1`] = ` +<QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query=":abc" + searchType="goto" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + "variables": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} +> + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":abc" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="Go to line" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":abc" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="Go to line" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value=":abc" + /> + <div + className="search-field-summary" + > + Go to line + </div> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> +</QuickOpenModal> +`; + +exports[`QuickOpenModal showErrorEmoji false when count + query 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="dasdasdas" + searchType="sources" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="dasdasdas" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="dasdasdas" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="big" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field big" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="dasdasdas" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal showErrorEmoji false when goto numeric ':2222' 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query=":2222" + searchType="goto" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":2222" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="Go to line" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":2222" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="Go to line" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + 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="Go to file…" + spellCheck={false} + value=":2222" + /> + <div + className="search-field-summary" + > + Go to line + </div> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal showErrorEmoji false when no query 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="" + searchType="other" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + 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="Go to file…" + spellCheck={false} + value="" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="small" + > + <ul + aria-live="polite" + className="result-list small" + id="result-list" + role="listbox" + /> + </ResultList> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal showErrorEmoji true when goto not numeric ':22k22' 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query=":22k22" + searchType="goto" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":22k22" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="Go to line" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query=":22k22" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="Go to line" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value=":22k22" + /> + <div + className="search-field-summary" + > + Go to line + </div> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal showErrorEmoji true when no count + query 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={true} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="test" + searchType="other" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + > + <Slide + handleClose={[Function]} + in={true} + > + <Transition + appear={true} + enter={true} + exit={true} + in={true} + mountOnEnter={false} + onEnter={[Function]} + onEntered={[Function]} + onEntering={[Function]} + onExit={[Function]} + onExited={[Function]} + onExiting={[Function]} + timeout={50} + unmountOnExit={false} + > + <Modal + handleClose={[Function]} + status="entering" + > + <div + className="modal-wrapper" + onClick={[Function]} + > + <div + className="modal entering" + onClick={[Function]} + > + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="test" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="" + > + <SearchInput + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="test" + searchKey="quickopen-search" + searchOptions={ + Object { + "caseSensitive": false, + "excludePatterns": "", + "regexMatch": false, + "wholeWord": false, + } + } + selectedItemId="" + setSearchOptions={[Function]} + showClose={false} + showErrorEmoji={true} + showExcludePatterns={false} + showSearchModifiers={false} + size="" + summaryMsg="" + > + <div + className="search-outline" + > + <div + aria-expanded={false} + aria-haspopup="listbox" + aria-owns="result-list" + className="search-field" + role="combobox" + > + <AccessibleImage + className="search" + > + <span + className="img search" + /> + </AccessibleImage> + <input + aria-activedescendant="" + aria-autocomplete="list" + aria-controls="result-list" + className="empty" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + spellCheck={false} + value="test" + /> + <div + className="search-buttons-bar" + /> + </div> + </div> + </SearchInput> + </Connect(SearchInput)> + </div> + </div> + </Modal> + </Transition> + </Slide> + </QuickOpenModal> +</Provider> +`; + +exports[`QuickOpenModal shows loading loads with function type search 1`] = ` +<Slide + handleClose={[Function]} + in={true} +> + <Connect(SearchInput) + count={0} + expanded={false} + handleClose={[Function]} + hasPrefix={true} + isLoading={false} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="Go to file…" + query="" + searchKey="quickopen-search" + selectedItemId="" + showClose={false} + showErrorEmoji={false} + showExcludePatterns={false} + showSearchModifiers={false} + summaryMsg="Loading…" + /> + <ResultList + expanded={false} + items={Array []} + key="results" + role="listbox" + selectItem={[Function]} + selected={0} + size="small" + /> +</Slide> +`; + +exports[`QuickOpenModal updateResults on enable 1`] = ` +<Provider + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={false} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="" + searchType="sources" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + /> +</Provider> +`; + +exports[`QuickOpenModal updateResults on enable 2`] = ` +<Provider + enabled={true} + store={ + Object { + "clearActions": [Function], + "dispatch": [Function], + "getActions": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + } + } +> + <QuickOpenModal + blackBoxRanges={Object {}} + clearHighlightLineRange={[MockFunction]} + closeQuickOpen={[MockFunction]} + cx={ + Object { + "navigateCounter": 0, + } + } + displayedSources={Array []} + enabled={false} + highlightLineRange={[MockFunction]} + isOriginal={false} + query="" + searchType="sources" + selectSpecificLocation={[MockFunction]} + setQuickOpenQuery={[MockFunction]} + shortcutsModalEnabled={false} + symbols={ + Object { + "functions": Array [], + } + } + symbolsLoading={false} + tabUrls={Array []} + thread="FakeThread" + toggleShortcutsModal={[MockFunction]} + /> +</Provider> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap new file mode 100644 index 0000000000..06ddc45c91 --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap @@ -0,0 +1,190 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShortcutsModal renders when enabled 1`] = ` +<Slide + additionalClass="shortcuts-modal" + handleClose={[MockFunction]} + in={true} +> + <div + className="shortcuts-content" + > + <div + className="shortcuts-section" + > + <h2> + Editor + </h2> + <ul + className="shortcuts-list" + > + <li> + <span> + Toggle Breakpoint + </span> + <span> + <span + className="keystroke" + key="Ctrl+B" + > + Ctrl+B + </span> + </span> + </li> + <li> + <span> + Edit Conditional Breakpoint + </span> + <span> + <span + className="keystroke" + key="Ctrl+Shift+B" + > + Ctrl+Shift+B + </span> + </span> + </li> + <li> + <span> + Edit Log Point + </span> + <span> + <span + className="keystroke" + key="Ctrl+Shift+Y" + > + Ctrl+Shift+Y + </span> + </span> + </li> + </ul> + </div> + <div + className="shortcuts-section" + > + <h2> + Stepping + </h2> + <ul + className="shortcuts-list" + > + <li> + <span> + Pause/Resume + </span> + <span> + <span + className="keystroke" + key="F8" + > + F8 + </span> + </span> + </li> + <li> + <span> + Step Over + </span> + <span> + <span + className="keystroke" + key="F10" + > + F10 + </span> + </span> + </li> + <li> + <span> + Step In + </span> + <span> + <span + className="keystroke" + key="F11" + > + F11 + </span> + </span> + </li> + <li> + <span> + Step Out + </span> + <span> + <span + className="keystroke" + key="Shift+F11" + > + Shift+F11 + </span> + </span> + </li> + </ul> + </div> + <div + className="shortcuts-section" + > + <h2> + Search + </h2> + <ul + className="shortcuts-list" + > + <li> + <span> + Go to file + </span> + <span> + <span + className="keystroke" + key="Ctrl+P" + > + Ctrl+P + </span> + </span> + </li> + <li> + <span> + Find in files + </span> + <span> + <span + className="keystroke" + key="Ctrl+Shift+F" + > + Ctrl+Shift+F + </span> + </span> + </li> + <li> + <span> + Find function + </span> + <span> + <span + className="keystroke" + key="Ctrl+Shift+O" + > + Ctrl+Shift+O + </span> + </span> + </li> + <li> + <span> + Go to line + </span> + <span> + <span + className="keystroke" + key="Ctrl+G" + > + Ctrl+G + </span> + </span> + </li> + </ul> + </div> + </div> +</Slide> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap new file mode 100644 index 0000000000..9828e88ef4 --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WelomeBox renders with default values 1`] = ` +<div + className="welcomebox" +> + <div + className="alignlabel" + > + <div + className="shortcutFunction" + > + <p + className="welcomebox__searchSources" + onClick={[Function]} + role="button" + tabIndex="0" + > + <span + className="shortcutKey" + > + Ctrl+P + </span> + <span + className="shortcutLabel" + > + Go to file + </span> + </p> + <p + className="welcomebox__searchProject" + onClick={[Function]} + role="button" + tabIndex="0" + > + <span + className="shortcutKey" + > + Ctrl+Shift+F + </span> + <span + className="shortcutLabel" + > + Find in files + </span> + </p> + <p + className="welcomebox__allShortcuts" + onClick={[Function]} + role="button" + tabIndex="0" + > + <span + className="shortcutKey" + > + Ctrl+/ + </span> + <span + className="shortcutLabel" + > + Show all shortcuts + </span> + </p> + </div> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap new file mode 100644 index 0000000000..0762a0b69d --- /dev/null +++ b/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WhyPaused should pause reason with message 1`] = ` +<LocalizationProvider + bundles={Array []} +> + <div + className="pane why-paused" + > + <div> + <div + className="info icon" + > + <AccessibleImage + className="info" + /> + </div> + <div + className="pause reason" + > + <Localized + id="whypaused-breakpoint" + /> + <div + className="message" + > + bla is hit + </div> + </div> + </div> + </div> +</LocalizationProvider> +`; + +exports[`WhyPaused should show an empty div when there is no pause reason 1`] = ` +<div + className="" +/> +`; + +exports[`WhyPaused should show pause reason with exception details 1`] = ` +<LocalizationProvider + bundles={Array []} +> + <div + className="pane why-paused" + > + <div> + <div + className="info icon" + > + <AccessibleImage + className="info" + /> + </div> + <div + className="pause reason" + > + <Localized + id="whypaused-exception" + /> + <div + className="message warning" + > + ReferenceError: o is not defined + </div> + </div> + </div> + </div> +</LocalizationProvider> +`; + +exports[`WhyPaused should show pause reason with exception string 1`] = ` +<LocalizationProvider + bundles={Array []} +> + <div + className="pane why-paused" + > + <div> + <div + className="info icon" + > + <AccessibleImage + className="info" + /> + </div> + <div + className="pause reason" + > + <Localized + id="whypaused-exception" + /> + <div + className="message warning" + > + Not Available + </div> + </div> + </div> + </div> +</LocalizationProvider> +`; diff --git a/devtools/client/debugger/src/components/variables.css b/devtools/client/debugger/src/components/variables.css new file mode 100644 index 0000000000..628c590714 --- /dev/null +++ b/devtools/client/debugger/src/components/variables.css @@ -0,0 +1,45 @@ +/* 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/>. */ + +:root { + /* header height is 28px + 1px for its border */ + --editor-header-height: 29px; + /* footer height is 24px + 1px for its border */ + --editor-footer-height: 25px; + /* searchbar height is 24px + 1px for its top border */ + --editor-searchbar-height: 25px; + /* Remove once https://bugzilla.mozilla.org/show_bug.cgi?id=1520440 lands */ + --theme-code-line-height: calc(15 / 11); + /* Background and text colors and opacity for skipped breakpoint panes */ + --skip-pausing-background-color: var(--theme-toolbar-hover); + --skip-pausing-opacity: 0.6; + --skip-pausing-color: var(--theme-body-color); +} + +:root.theme-light, +:root .theme-light { + --search-overlays-semitransparent: rgba(221, 225, 228, 0.66); + --popup-shadow-color: #d0d0d0; + --theme-inline-preview-background: rgba(192, 105, 255, 0.05); + --theme-inline-preview-border-color: #ebd1ff; + --theme-inline-preview-label-color: #6300a6; + --theme-inline-preview-label-background: rgb(244, 230, 255); +} + +:root.theme-dark, +:root .theme-dark { + --search-overlays-semitransparent: rgba(42, 46, 56, 0.66); + --popup-shadow-color: #5c667b; + --theme-inline-preview-background: rgba(192, 105, 255, 0.05); + --theme-inline-preview-border-color: #47326c; + --theme-inline-preview-label-color: #dfccff; + --theme-inline-preview-label-background: #3f2e5f; +} + +/* Animations */ + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} |