diff options
Diffstat (limited to 'devtools/client/debugger/src/components/Editor')
42 files changed, 6531 insertions, 0 deletions
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..1281bc635a --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/BlackboxLines.js @@ -0,0 +1,139 @@ +/* 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 "devtools/client/shared/vendor/react-prop-types"; +import { Component } from "devtools/client/shared/vendor/react"; +import { toEditorLine, fromEditorLine } from "../../utils/editor/index"; +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); + // CodeMirror.eachLine doesn't include `end` line offset, so bump by one + const end = toEditorLine(selectedSource.id, range.end.line) + 1; + 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..4559a20289 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoint.js @@ -0,0 +1,157 @@ +/* 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 "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; + +import { getDocument, toEditorLine } from "../../utils/editor/index"; +import { getSelectedLocation } from "../../utils/selected-location"; +import { features } from "../../utils/prefs"; +const classnames = require("resource://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 { + breakpoint: PropTypes.object.isRequired, + editor: PropTypes.object.isRequired, + selectedSource: PropTypes.object, + }; + } + + 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 { + continueToHere, + toggleBreakpointsAtLine, + removeBreakpointsAtLine, + 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) { + continueToHere(selectedLocation); + return; + } + + if (event.shiftKey) { + toggleBreakpointsAtLine(!breakpoint.disabled, selectedLocation.line); + return; + } + + removeBreakpointsAtLine(selectedLocation.source, selectedLocation.line); + }; + + onContextMenu = event => { + event.stopPropagation(); + event.preventDefault(); + + this.props.showEditorEditBreakpointContextMenu( + event, + this.props.breakpoint + ); + }; + + addBreakpoint(props) { + const { breakpoint, editor, selectedSource } = props; + // Hidden Breakpoints are never rendered on the client + if (breakpoint.options.hidden) { + return; + } + + if (!selectedSource) { + return; + } + + const doc = getDocument(selectedSource.id); + if (!doc) { + return; + } + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const line = toEditorLine(selectedSource.id, selectedLocation.line); + + 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 doc = getDocument(selectedSource.id); + if (!doc) { + return; + } + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const line = toEditorLine(selectedSource.id, 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..6d1d088f11 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoints.js @@ -0,0 +1,78 @@ +/* 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 "devtools/client/shared/vendor/react-prop-types"; +import React, { Component } from "devtools/client/shared/vendor/react"; +import { div } from "devtools/client/shared/vendor/react-dom-factories"; +import Breakpoint from "./Breakpoint"; + +import { + getSelectedSource, + getFirstVisibleBreakpoints, +} from "../../selectors/index"; +import { makeBreakpointId } from "../../utils/breakpoint/index"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import actions from "../../actions/index"; + +class Breakpoints extends Component { + static get propTypes() { + return { + breakpoints: PropTypes.array, + editor: PropTypes.object, + selectedSource: PropTypes.object, + removeBreakpointsAtLine: PropTypes.func, + toggleBreakpointsAtLine: PropTypes.func, + continueToHere: PropTypes.func, + showEditorEditBreakpointContextMenu: PropTypes.func, + }; + } + render() { + const { + breakpoints, + selectedSource, + editor, + showEditorEditBreakpointContextMenu, + continueToHere, + toggleBreakpointsAtLine, + removeBreakpointsAtLine, + } = this.props; + + if (!selectedSource || !breakpoints) { + return null; + } + return div( + null, + breakpoints.map(breakpoint => { + return React.createElement(Breakpoint, { + key: makeBreakpointId(breakpoint.location), + breakpoint, + selectedSource, + showEditorEditBreakpointContextMenu, + continueToHere, + toggleBreakpointsAtLine, + removeBreakpointsAtLine, + editor, + }); + }) + ); + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + return { + // Retrieves only the first breakpoint per line so that the + // breakpoint marker represents only the first breakpoint + breakpoints: getFirstVisibleBreakpoints(state), + selectedSource, + }; +}; + +export default connect(mapStateToProps, { + showEditorEditBreakpointContextMenu: + actions.showEditorEditBreakpointContextMenu, + continueToHere: actions.continueToHere, + toggleBreakpointsAtLine: actions.toggleBreakpointsAtLine, + removeBreakpointsAtLine: actions.removeBreakpointsAtLine, +})(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..867f3af4b7 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js @@ -0,0 +1,128 @@ +/* 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 "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; + +import { getDocument } from "../../utils/editor/index"; +const classnames = require("resource://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 { + columnBreakpoint: 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 { + columnBreakpoint, + toggleDisabledBreakpoint, + removeBreakpoint, + addBreakpoint, + } = this.props; + + // disable column breakpoint on shift-click. + if (event.shiftKey) { + toggleDisabledBreakpoint(columnBreakpoint.breakpoint); + return; + } + + if (columnBreakpoint.breakpoint) { + removeBreakpoint(columnBreakpoint.breakpoint); + } else { + addBreakpoint(columnBreakpoint.location); + } + }; + + onContextMenu = event => { + event.stopPropagation(); + event.preventDefault(); + + const { + columnBreakpoint: { breakpoint, location }, + } = this.props; + + if (breakpoint) { + this.props.showEditorEditBreakpointContextMenu(event, breakpoint); + } else { + this.props.showEditorCreateBreakpointContextMenu(event, location); + } + }; + + 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..33ccfad325 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.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 "devtools/client/shared/vendor/react"; +import { div } from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; + +import ColumnBreakpoint from "./ColumnBreakpoint"; + +import { + getSelectedSource, + visibleColumnBreakpoints, + isSourceBlackBoxed, +} from "../../selectors/index"; +import actions from "../../actions/index"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import { makeBreakpointId } from "../../utils/breakpoint/index"; + +// eslint-disable-next-line max-len + +class ColumnBreakpoints extends Component { + static get propTypes() { + return { + columnBreakpoints: PropTypes.array.isRequired, + editor: PropTypes.object.isRequired, + selectedSource: PropTypes.object, + addBreakpoint: PropTypes.func, + removeBreakpoint: PropTypes.func, + toggleDisabledBreakpoint: PropTypes.func, + showEditorCreateBreakpointContextMenu: PropTypes.func, + showEditorEditBreakpointContextMenu: PropTypes.func, + }; + } + + render() { + const { + editor, + columnBreakpoints, + selectedSource, + showEditorCreateBreakpointContextMenu, + showEditorEditBreakpointContextMenu, + toggleDisabledBreakpoint, + removeBreakpoint, + addBreakpoint, + } = this.props; + + if (!selectedSource || columnBreakpoints.length === 0) { + return null; + } + + let breakpoints; + editor.codeMirror.operation(() => { + breakpoints = columnBreakpoints.map(columnBreakpoint => + React.createElement(ColumnBreakpoint, { + key: makeBreakpointId(columnBreakpoint.location), + columnBreakpoint, + editor, + source: selectedSource, + showEditorCreateBreakpointContextMenu, + showEditorEditBreakpointContextMenu, + toggleDisabledBreakpoint, + removeBreakpoint, + addBreakpoint, + }) + ); + }); + return div(null, breakpoints); + } +} + +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 { + selectedSource, + columnBreakpoints: visibleColumnBreakpoints(state), + }; +}; + +export default connect(mapStateToProps, { + showEditorCreateBreakpointContextMenu: + actions.showEditorCreateBreakpointContextMenu, + showEditorEditBreakpointContextMenu: + actions.showEditorEditBreakpointContextMenu, + toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint, + removeBreakpoint: actions.removeBreakpoint, + addBreakpoint: actions.addBreakpoint, +})(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..8ff84c287a --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js @@ -0,0 +1,280 @@ +/* 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 "devtools/client/shared/vendor/react"; +import { + div, + textarea, +} from "devtools/client/shared/vendor/react-dom-factories"; +import ReactDOM from "devtools/client/shared/vendor/react-dom"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import { toEditorLine } from "../../utils/editor/index"; +import { prefs } from "../../utils/prefs"; +import actions from "../../actions/index"; + +import { + getClosestBreakpoint, + getConditionalPanelLocation, + getLogPointStatus, +} from "../../selectors/index"; + +const classnames = require("resource://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, + 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 { 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(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.source.id, 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", + }, + "»" + ), + textarea({ + defaultValue, + ref: input => this.createEditor(input), + }) + ), + 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 { + 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..1b8e59ba64 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/DebugLine.js @@ -0,0 +1,137 @@ +/* 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 "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { + toEditorPosition, + getDocument, + hasDocument, + startOperation, + endOperation, + getTokenEnd, +} from "../../utils/editor/index"; +import { isException } from "../../utils/pause/index"; +import { getIndentation } from "../../utils/indentation"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import { + getVisibleSelectedFrame, + getPauseReason, + getSourceTextContent, + getCurrentThread, +} from "../../selectors/index"; + +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 doc = getDocument(location.source.id); + + 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.source.id)) { + return; + } + + if (this.debugExpression) { + this.debugExpression.clear(); + } + + const { line } = toEditorPosition(location); + const doc = getDocument(location.source.id); + 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.source.id); +} + +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..0c48da019e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Editor.css @@ -0,0 +1,216 @@ +/* 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 { + width: calc(100% - 1px); + overflow-y: auto; + grid-area: editor; +} + +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-fill-mode: forwards; +} + +@keyframes fade-highlight-out { + 0%, 30% { + /* We want to use a color with some transparency so text selection is visible through it */ + background-color: var(--theme-contrast-background-alpha); + } + 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/EmptyLines.js b/devtools/client/debugger/src/components/Editor/EmptyLines.js new file mode 100644 index 0000000000..31513408a8 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/EmptyLines.js @@ -0,0 +1,91 @@ +/* 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 "devtools/client/shared/vendor/react-redux"; +import { Component } from "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { + getSelectedSource, + getSelectedBreakableLines, +} from "../../selectors/index"; +import { fromEditorLine } from "../../utils/editor/index"; +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..b76923e597 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Exception.js @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import { PureComponent } from "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; + +import { + toEditorPosition, + getTokenEnd, + hasDocument, +} from "../../utils/editor/index"; + +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({ + source: selectedSource, + line: lineNumber, + // Exceptions are reported with column being 1-based + // while the frontend uses 0-based column. + column: columnNumber - 1, + }); + + 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..2fb183f135 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Exceptions.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, { Component } from "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import Exception from "./Exception"; + +import { + getSelectedSource, + getSelectedSourceExceptions, +} from "../../selectors/index"; +import { getDocument } from "../../utils/editor/index"; + +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 React.createElement( + React.Fragment, + null, + exceptions.map(exception => + React.createElement(Exception, { + exception, + doc, + key: `${exception.sourceActorId}:${exception.lineNumber}`, + 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..4a3272879b --- /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); + grid-area: editor-footer; + display: flex; + opacity: 1; + width: calc(100% - 1px); + user-select: none; + height: var(--editor-footer-height); + box-sizing: border-box; +} + +.source-footer button { + outline-offset: -2px; +} + +.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..c4ff02caf4 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Footer.js @@ -0,0 +1,288 @@ +/* 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 "devtools/client/shared/vendor/react"; +import { + div, + button, + span, +} from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import actions from "../../actions/index"; +import { + getSelectedSource, + getSelectedLocation, + getSelectedSourceTextContent, + getPrettySource, + getPaneCollapse, + isSourceBlackBoxed, + canPrettyPrintSource, + getPrettyPrintMessage, + isSourceOnSourceMapIgnoreList, + isSourceMapIgnoreListEnabled, + getSelectedMappedSource, +} from "../../selectors/index"; + +import { isPretty, getFilename, shouldBlackbox } from "../../utils/source"; + +import { PaneToggleButton } from "../shared/Button/index"; +import AccessibleImage from "../shared/AccessibleImage"; + +const classnames = require("resource://devtools/client/shared/classnames.js"); + +class SourceFooter extends PureComponent { + static get propTypes() { + return { + canPrettyPrint: PropTypes.bool.isRequired, + prettyPrintMessage: PropTypes.string.isRequired, + endPanelCollapsed: PropTypes.bool.isRequired, + horizontal: PropTypes.bool.isRequired, + jumpToMappedLocation: PropTypes.func.isRequired, + mappedSource: PropTypes.object, + selectedSource: PropTypes.object, + selectedLocation: PropTypes.object, + isSelectedSourceBlackBoxed: PropTypes.bool.isRequired, + sourceLoaded: PropTypes.bool.isRequired, + toggleBlackBox: PropTypes.func.isRequired, + togglePaneCollapse: PropTypes.func.isRequired, + prettyPrintAndSelectSource: PropTypes.func.isRequired, + isSourceOnIgnoreList: PropTypes.bool.isRequired, + }; + } + + prettyPrintButton() { + const { + selectedSource, + canPrettyPrint, + prettyPrintMessage, + prettyPrintAndSelectSource, + sourceLoaded, + } = this.props; + + if (!selectedSource) { + return null; + } + + if (!sourceLoaded && selectedSource.isPrettyPrinted) { + return div( + { + className: "action", + key: "pretty-loader", + }, + React.createElement(AccessibleImage, { + className: "loader spin", + }) + ); + } + + const type = "prettyPrint"; + return button( + { + onClick: () => { + if (!canPrettyPrint) { + return; + } + prettyPrintAndSelectSource(selectedSource); + }, + className: classnames("action", type, { + active: sourceLoaded && canPrettyPrint, + pretty: isPretty(selectedSource), + }), + key: type, + title: prettyPrintMessage, + "aria-label": prettyPrintMessage, + disabled: !canPrettyPrint, + }, + React.createElement(AccessibleImage, { + className: type, + }) + ); + } + + blackBoxButton() { + const { + 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(selectedSource), + className: classnames("action", type, { + active: sourceLoaded, + blackboxed: isSelectedSourceBlackBoxed || isSourceOnIgnoreList, + }), + key: type, + title: tooltip, + "aria-label": tooltip, + disabled: isSourceOnIgnoreList, + }, + React.createElement(AccessibleImage, { + className: "blackBox", + }) + ); + } + + renderToggleButton() { + if (this.props.horizontal) { + return null; + } + return React.createElement(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 + ) + : null; + } + + renderSourceSummary() { + const { mappedSource, jumpToMappedLocation, selectedLocation } = this.props; + + if (!mappedSource) { + return null; + } + + const tooltip = L10N.getFormatStr( + mappedSource.isOriginal + ? "sourceFooter.mappedOriginalSource.tooltip" + : "sourceFooter.mappedGeneratedSource.tooltip", + mappedSource.url + ); + const filename = getFilename(mappedSource); + const label = L10N.getFormatStr( + mappedSource.isOriginal + ? "sourceFooter.mappedOriginalSource.title" + : "sourceFooter.mappedGeneratedSource.title", + filename + ); + return button( + { + className: "mapped-source", + onClick: () => jumpToMappedLocation(selectedLocation), + title: tooltip, + }, + span(null, label) + ); + } + + renderCursorPosition() { + // When we open a new source, there is no particular location selected and the line will be set to zero or falsy + if (!this.props.selectedLocation || !this.props.selectedLocation.line) { + return null; + } + + // Note that line is 1-based while column is 0-based. + const { line, column } = this.props.selectedLocation; + + const text = L10N.getFormatStr( + "sourceFooter.currentCursorPosition", + line, + column + 1 + ); + const title = L10N.getFormatStr( + "sourceFooter.currentCursorPosition.tooltip", + line, + column + 1 + ); + return div( + { + className: "cursor-position", + title, + }, + text + ); + } + + render() { + return div( + { + className: "source-footer", + }, + div( + { + className: "source-footer-start", + }, + this.renderCommands() + ), + div( + { + className: "source-footer-end", + }, + this.renderSourceSummary(), + this.renderCursorPosition(), + this.renderToggleButton() + ) + ); + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + const selectedLocation = getSelectedLocation(state); + const sourceTextContent = getSelectedSourceTextContent(state); + + return { + selectedSource, + selectedLocation, + isSelectedSourceBlackBoxed: selectedSource + ? isSourceBlackBoxed(state, selectedSource) + : null, + isSourceOnIgnoreList: + isSourceMapIgnoreListEnabled(state) && + isSourceOnSourceMapIgnoreList(state, selectedSource), + sourceLoaded: !!sourceTextContent, + mappedSource: getSelectedMappedSource(state), + 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, { + prettyPrintAndSelectSource: actions.prettyPrintAndSelectSource, + toggleBlackBox: actions.toggleBlackBox, + jumpToMappedLocation: actions.jumpToMappedLocation, + togglePaneCollapse: actions.togglePaneCollapse, +})(SourceFooter); 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..8639128905 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightLine.js @@ -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/>. */ + +import { Component } from "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { + toEditorLine, + endOperation, + startOperation, +} from "../../utils/editor/index"; +import { getDocument, hasDocument } from "../../utils/editor/source-documents"; + +import { connect } from "devtools/client/shared/vendor/react-redux"; +import { + getVisibleSelectedFrame, + getSelectedLocation, + getSelectedSourceTextContent, + getPauseCommand, + getCurrentThread, + getShouldHighlightSelectedLocation, +} from "../../selectors/index"; + +function isDebugLine(selectedFrame, selectedLocation) { + if (!selectedFrame) { + return false; + } + + return ( + selectedFrame.location.source.id == selectedLocation.source.id && + selectedFrame.location.line == selectedLocation.line + ); +} + +function isDocumentReady(selectedLocation, selectedSourceTextContent) { + return ( + selectedLocation && + selectedSourceTextContent && + hasDocument(selectedLocation.source.id) + ); +} + +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 { line } = selectedLocation; + const editorLine = toEditorLine(selectedLocation.source.id, line); + + if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) { + return false; + } + + if (this.isStepping && editorLine === this.previousEditorLine) { + return false; + } + + return true; + } + + completeHighlightLine(prevProps) { + const { + pauseCommand, + selectedLocation, + selectedFrame, + selectedSourceTextContent, + shouldHighlightSelectedLocation, + } = this.props; + if (pauseCommand) { + this.isStepping = true; + } + + startOperation(); + if (prevProps) { + this.clearHighlightLine( + prevProps.selectedLocation, + prevProps.selectedSourceTextContent + ); + } + if (shouldHighlightSelectedLocation) { + this.setHighlightLine( + selectedLocation, + selectedFrame, + selectedSourceTextContent + ); + } + endOperation(); + } + + setHighlightLine(selectedLocation, selectedFrame, selectedSourceTextContent) { + const { line } = selectedLocation; + if ( + !this.shouldSetHighlightLine(selectedLocation, selectedSourceTextContent) + ) { + return; + } + + this.isStepping = false; + const sourceId = selectedLocation.source.id; + 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 } = selectedLocation; + const sourceId = selectedLocation.source.id; + 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)), + shouldHighlightSelectedLocation: getShouldHighlightSelectedLocation(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..e34a86aba9 --- /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 "devtools/client/shared/vendor/react"; +import PropTypes from "devtools/client/shared/vendor/react-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..552143dcf2 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreview.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, { PureComponent } from "devtools/client/shared/vendor/react"; +import { span } from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-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( + { + className: "inline-preview-value", + }, + React.createElement(Rep, { + object: value, + mode, + onDOMNodeClick: grip => openElementInInspector(grip), + onInspectIconClick: grip => openElementInInspector(grip), + onDOMNodeMouseOver: grip => highlightDomElement(grip), + onDOMNodeMouseOut: grip => unHighlightDomElement(grip), + }) + ) + ); + } +} + +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..bc54fc5b4d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js @@ -0,0 +1,103 @@ +/* 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 "devtools/client/shared/vendor/react"; +import ReactDOM from "devtools/client/shared/vendor/react-dom"; + +import actions from "../../actions/index"; +import assert from "../../utils/assert"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import InlinePreview from "./InlinePreview"; + +// 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.createElement( + React.Fragment, + null, + previews.map(preview => + React.createElement(InlinePreview, { + line: line, + key: `${line}-${preview.name}`, + variable: preview.name, + value: preview.value, + openElementInInspector: openElementInInspector, + highlightDomElement: highlightDomElement, + unHighlightDomElement: unHighlightDomElement, + }) + ) + ), + this.widgetNode, + () => { + // Only set the codeMirror bookmark once React rendered the element into 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..18616ae3ed --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreviews.js @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "devtools/client/shared/vendor/react"; +import { div } from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import InlinePreviewRow from "./InlinePreviewRow"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import { + getSelectedFrame, + getCurrentThread, + getInlinePreviews, +} from "../../selectors/index"; + +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.source.id !== 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 React.createElement(InlinePreviewRow, { + editor: editor, + key: line, + line: lineNum, + previews: previewsObj[line], + }); + }); + }); + return div(null, inlinePreviewRows); + } +} + +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..7e3d788c68 --- /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-link-color); + 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..0789b82694 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js @@ -0,0 +1,142 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "devtools/client/shared/vendor/react"; +import { div, span } from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { StringRep }, +} = Reps; + +import actions from "../../../actions/index"; + +import AccessibleImage from "../../shared/AccessibleImage"; +const classnames = require("resource://devtools/client/shared/classnames.js"); +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: true, + }; + } + + static get propTypes() { + return { + mouseout: PropTypes.func.isRequired, + selectSourceURL: PropTypes.func.isRequired, + exception: PropTypes.object.isRequired, + }; + } + + onExceptionMessageClick() { + const isStacktraceExpanded = this.state.isStacktraceExpanded; + this.setState({ isStacktraceExpanded: !isStacktraceExpanded }); + } + + buildStackFrame(frame) { + const { filename, lineNumber } = frame; + const functionName = frame.functionName || ANONYMOUS_FN_NAME; + return div( + { + className: "frame", + onClick: () => + this.props.selectSourceURL(filename, { + line: lineNumber, + }), + }, + span( + { + className: "title", + }, + functionName + ), + span( + { + className: "location", + }, + span( + { + className: "filename", + }, + filename + ), + ":", + span( + { + className: "line", + }, + lineNumber + ) + ) + ); + } + + renderStacktrace(stacktrace) { + const isStacktraceExpanded = this.state.isStacktraceExpanded; + + if (stacktrace.length && isStacktraceExpanded) { + return div( + { + className: "exception-stacktrace", + }, + stacktrace.map(frame => this.buildStackFrame(frame)) + ); + } + return null; + } + + renderArrowIcon(stacktrace) { + if (stacktrace.length) { + return React.createElement(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", + }) + ), + this.renderStacktrace(stacktrace) + ); + } +} + +const mapDispatchToProps = { + selectSourceURL: actions.selectSourceURL, +}; + +export default connect(null, 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..745d272fbb --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.css @@ -0,0 +1,174 @@ +/* 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/>. */ + +.preview-popup { + border: 1px solid var(--theme-splitter-color); + height: auto; + overflow: auto; + background: var(--theme-body-background); + box-shadow: 1px 1px 3px var(--popup-shadow-color); +} + +.popover .preview-popup { + padding: 5px 10px; + max-width: 450px; + min-width: 200px; +} + +.tooltip .preview-popup { + max-width: inherit; + padding: 5px; + min-height: inherit; + max-height: 200px; +} + +.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; +} + +.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; +} + +.theme-dark .popover .preview-popup { + border-color: var(--theme-body-color); +} + +.tooltip { + position: fixed; + z-index: 100; +} + + + +.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: 20px 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..a010358dc1 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js @@ -0,0 +1,277 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +import React, { Component } from "devtools/client/shared/vendor/react"; +import { div } from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { Grip }, + MODE, + objectInspector, +} = Reps; + +const { ObjectInspector, utils } = objectInspector; + +const { + node: { nodeIsPrimitive }, +} = utils; + +import ExceptionPopup from "./ExceptionPopup"; + +import actions from "../../../actions/index"; +import Popover from "../../shared/Popover"; + +export class Popup extends Component { + constructor(props) { + super(props); + } + + static get propTypes() { + return { + clearPreview: PropTypes.func.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(this.props.preview.target); + } + + componentWillUnmount() { + this.removeHighlightFromToken(this.props.preview.target); + } + + componentDidUpdate(prevProps) { + const { target } = this.props.preview; + if (prevProps.target == target) { + return; + } + + this.removeHighlightFromToken(prevProps.preview.target); + this.addHighlightToToken(target); + } + + addHighlightToToken(target) { + if (!target) { + return; + } + + target.classList.add("preview-token"); + addHighlightToTargetSiblings(target, this.props); + } + + removeHighlightFromToken(target) { + if (!target) { + return; + } + + 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); + } + + renderExceptionPreview(exception) { + return React.createElement(ExceptionPopup, { + exception: exception, + clearPreview: this.props.clearPreview, + }); + } + + renderPreview() { + const { + preview: { root, exception, resultGrip }, + } = this.props; + + const usesCustomFormatter = + root?.contents?.value?.useCustomFormatter ?? false; + + if (exception) { + return this.renderExceptionPreview(exception); + } + + return div( + { + className: "preview-popup", + style: { + maxHeight: this.calculateMaxHeight(), + }, + }, + React.createElement(ObjectInspector, { + roots: [root], + autoExpandDepth: 1, + autoReleaseObjectActors: false, + mode: usesCustomFormatter ? MODE.LONG : MODE.SHORT, + disableWrap: true, + displayRootNodeAsHeader: true, + focusable: false, + openLink: this.props.openLink, + defaultRep: Grip, + createElement: this.createElement, + onDOMNodeClick: grip => this.props.openElementInInspector(grip), + onInspectIconClick: grip => this.props.openElementInInspector(grip), + onDOMNodeMouseOver: grip => this.props.highlightDomElement(grip), + onDOMNodeMouseOut: grip => this.props.unHighlightDomElement(grip), + mayUseCustomFormatter: true, + onViewSourceInDebugger: () => { + return ( + resultGrip.location && + this.props.selectSourceURL(resultGrip.location.url, { + line: resultGrip.location.line, + column: resultGrip.location.column, + }) + ); + }, + }) + ); + } + + getPreviewType() { + const { + preview: { root, exception }, + } = this.props; + if (exception || nodeIsPrimitive(root)) { + return "tooltip"; + } + + return "popover"; + } + + render() { + const { + preview: { cursorPos, resultGrip, exception }, + editorRef, + } = this.props; + + if ( + !exception && + (typeof resultGrip == "undefined" || resultGrip?.optimizedOut) + ) { + return null; + } + + const type = this.getPreviewType(); + return React.createElement( + Popover, + { + targetPosition: cursorPos, + type: type, + editorRef: editorRef, + target: this.props.preview.target, + mouseout: this.props.clearPreview, + }, + this.renderPreview() + ); + } +} + +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 mapDispatchToProps = { + addExpression: actions.addExpression, + selectSourceURL: actions.selectSourceURL, + openLink: actions.openLink, + openElementInInspector: actions.openElementInInspectorCommand, + highlightDomElement: actions.highlightDomElement, + unHighlightDomElement: actions.unHighlightDomElement, +}; + +export default connect(null, 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..218d33007f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/index.js @@ -0,0 +1,128 @@ +/* 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 "devtools/client/shared/vendor/react-prop-types"; +import React, { PureComponent } from "devtools/client/shared/vendor/react"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import Popup from "./Popup"; + +import { getIsCurrentThreadPaused } from "../../../selectors/index"; +import actions from "../../../actions/index"; + +const EXCEPTION_MARKER = "mark-text-exception"; + +class Preview extends PureComponent { + target = null; + constructor(props) { + super(props); + this.state = { selecting: false }; + } + + static get propTypes() { + return { + editor: PropTypes.object.isRequired, + editorRef: PropTypes.object.isRequired, + isPaused: PropTypes.bool.isRequired, + getExceptionPreview: PropTypes.func.isRequired, + getPreview: PropTypes.func, + }; + } + + 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); + } + + // Note that these events are emitted by utils/editor/tokens.js + onTokenEnter = async ({ target, tokenPos }) => { + // Use a temporary object to uniquely identify the asynchronous processing of this user event + // and bail out if we started hovering another token. + const tokenId = {}; + this.currentTokenId = tokenId; + + const { editor, getPreview, getExceptionPreview } = this.props; + const isTargetException = target.classList.contains(EXCEPTION_MARKER); + + let preview; + if (isTargetException) { + preview = await getExceptionPreview(target, tokenPos, editor.codeMirror); + } + + if (!preview && this.props.isPaused && !this.state.selecting) { + preview = await getPreview(target, tokenPos, editor.codeMirror); + } + + // Prevent modifying state and showing this preview if we started hovering another token + if (!preview || this.currentTokenId !== tokenId) { + return; + } + this.setState({ preview }); + }; + + onMouseUp = () => { + if (this.props.isPaused) { + this.setState({ selecting: false }); + } + }; + + onMouseDown = () => { + if (this.props.isPaused) { + this.setState({ selecting: true }); + } + }; + + onScroll = () => { + if (this.props.isPaused) { + this.clearPreview(); + } + }; + + clearPreview = () => { + this.setState({ preview: null }); + }; + + render() { + const { preview } = this.state; + if (!preview || this.state.selecting) { + return null; + } + return React.createElement(Popup, { + preview: preview, + editor: this.props.editor, + editorRef: this.props.editorRef, + clearPreview: this.clearPreview, + }); + } +} + +const mapStateToProps = state => { + return { + isPaused: getIsCurrentThreadPaused(state), + }; +}; + +export default connect(mapStateToProps, { + addExpression: actions.addExpression, + getPreview: actions.getPreview, + getExceptionPreview: actions.getExceptionPreview, +})(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..e504c9f12c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js @@ -0,0 +1,99 @@ +/* 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; + expect(previous.className.includes("preview-token")).toEqual(true); + + const next = target.nextElementSibling; + 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; + expect(previous.className.includes("preview-token")).toEqual(false); + + const next = target.nextElementSibling; + 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..fe90b2a960 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.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/>. */ + +.search-bar { + position: relative; + display: flex; + border-top: 1px solid var(--theme-splitter-color); + height: var(--editor-searchbar-height); + transition: outline 150ms ease-out; +} + +/* Display an outline on the container when the child input is focused. If another element + is focused (e.g. a button), we only want the outline on that element */ +.search-bar:focus-within:has(input:focus-visible) { + outline: var(--theme-focus-outline); + outline-offset: -2px; +} + +.search-bar .search-outline { + flex-grow: 1; + border-width: 0; +} + +/* The outline is set on .search-bar already */ +.search-bar input:focus-visible { + outline: none; +} + +.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..a3491a3fef --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js @@ -0,0 +1,368 @@ +/* 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 "devtools/client/shared/vendor/react-prop-types"; +import React, { Component } from "devtools/client/shared/vendor/react"; +import { div } from "devtools/client/shared/vendor/react-dom-factories"; +import { connect } from "devtools/client/shared/vendor/react-redux"; +import actions from "../../actions/index"; +import { + getActiveSearch, + getSelectedSource, + getSelectedSourceTextContent, + getSearchOptions, +} from "../../selectors/index"; + +import { searchKeys } from "../../constants"; +import { scrollList } from "../../utils/result-list"; + +import SearchInput from "../shared/SearchInput"; + +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const { debounce } = require("resource://devtools/shared/debounce.js"); +import { renderWasmText } from "../../utils/wasm"; +import { + clearSearch, + find, + findNext, + findPrev, + removeOverlay, +} from "../../utils/editor/index"; +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, + 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 { closeFileSearch, editor, searchInFileEnabled } = this.props; + this.clearSearch(); + if (editor && searchInFileEnabled) { + closeFileSearch(); + 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(null); + } + return div( + { + className: "search-bar", + }, + React.createElement(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), + }) + ); + } +} + +SearchInFileBar.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = (state, p) => { + const selectedSource = getSelectedSource(state); + + return { + 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..ba5e1c1934 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tab.js @@ -0,0 +1,148 @@ +/* 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 "devtools/client/shared/vendor/react"; +import { div, span } from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import SourceIcon from "../shared/SourceIcon"; +import { CloseButton } from "../shared/Button/index"; + +import actions from "../../actions/index"; + +import { + getDisplayPath, + getFileURL, + getSourceQueryString, + getTruncatedFileName, + isPretty, +} from "../../utils/source"; +import { createLocation } from "../../utils/location"; + +import { + getSelectedLocation, + getSourcesForTabs, + isSourceBlackBoxed, +} from "../../selectors/index"; + +const classnames = require("resource://devtools/client/shared/classnames.js"); + +class Tab extends PureComponent { + static get propTypes() { + return { + closeTab: PropTypes.func.isRequired, + onDragEnd: PropTypes.func.isRequired, + onDragOver: PropTypes.func.isRequired, + onDragStart: PropTypes.func.isRequired, + selectSource: PropTypes.func.isRequired, + source: PropTypes.object.isRequired, + sourceActor: PropTypes.object.isRequired, + tabSources: PropTypes.array.isRequired, + isBlackBoxed: PropTypes.bool.isRequired, + }; + } + + onContextMenu = event => { + event.preventDefault(); + this.props.showTabContextMenu(event, this.props.source); + }; + + isSourceSearchEnabled() { + return this.props.activeSearch === "source"; + } + + render() { + const { + selectSource, + closeTab, + source, + sourceActor, + tabSources, + onDragOver, + onDragStart, + onDragEnd, + index, + isActive, + } = this.props; + const sourceId = source.id; + const isPrettyCode = isPretty(source); + + function onClickClose(e) { + e.stopPropagation(); + closeTab(source); + } + + function handleTabClick(e) { + e.preventDefault(); + e.stopPropagation(); + return selectSource(source, sourceActor); + } + + const className = classnames("source-tab", { + active: isActive, + pretty: isPrettyCode, + blackboxed: this.props.isBlackBoxed, + }); + + const path = getDisplayPath(source, tabSources); + const query = getSourceQueryString(source); + return div( + { + draggable: true, + onDragOver: onDragOver, + onDragStart: onDragStart, + onDragEnd: onDragEnd, + className: className, + "data-index": index, + "data-source-id": sourceId, + onClick: handleTabClick, + // Accommodate middle click to close tab + onMouseUp: e => e.button === 1 && closeTab(source), + onContextMenu: this.onContextMenu, + title: getFileURL(source, false), + }, + React.createElement(SourceIcon, { + location: createLocation({ + source, + sourceActor, + }), + forTab: true, + modifier: icon => (["file", "javascript"].includes(icon) ? null : icon), + }), + div( + { + className: "filename", + }, + getTruncatedFileName(source, query), + path && span(null, `../${path}/..`) + ), + React.createElement(CloseButton, { + handleClick: onClickClose, + tooltip: L10N.getStr("sourceTabs.closeTabButtonTooltip"), + }) + ); + } +} + +const mapStateToProps = (state, { source }) => { + return { + tabSources: getSourcesForTabs(state), + isBlackBoxed: isSourceBlackBoxed(state, source), + isActive: source.id === getSelectedLocation(state)?.source.id, + }; +}; + +export default connect( + mapStateToProps, + { + selectSource: actions.selectSource, + closeTab: actions.closeTab, + showTabContextMenu: actions.showTabContextMenu, + }, + 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..ab70876d5e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tabs.css @@ -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/>. */ + +.source-header { + grid-area: editor-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; + /* Reserve space for the overflow button (even if not visible) */ + padding-inline-end: 28px; + /* Make sure that overflowing tabs don't show through other elements (see Bug 1855458) */ + max-height: 100%; + overflow: hidden; +} + +.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 { + 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..3577a4909c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tabs.js @@ -0,0 +1,320 @@ +/* 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 "devtools/client/shared/vendor/react"; +import { + div, + ul, + li, + span, +} from "devtools/client/shared/vendor/react-dom-factories"; +import PropTypes from "devtools/client/shared/vendor/react-prop-types"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import { + getSourceTabs, + getSelectedSource, + getSourcesForTabs, + getIsPaused, + getCurrentThread, + getBlackBoxRanges, +} from "../../selectors/index"; +import { isVisible } from "../../utils/ui"; + +import { getHiddenTabs } from "../../utils/tabs"; +import { getFilename, isPretty, getFileURL } from "../../utils/source"; +import actions from "../../actions/index"; + +import Tab from "./Tab"; +import { PaneToggleButton } from "../shared/Button/index"; +import Dropdown from "../shared/Dropdown"; +import AccessibleImage from "../shared/AccessibleImage"; +import CommandBar from "../SecondaryPanes/CommandBar"; + +const { debounce } = require("resource://devtools/shared/debounce.js"); + +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 { + 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, + }; + } + + 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 { selectSource } = this.props; + const filename = getFilename(source); + + const onClick = () => selectSource(source); + return li( + { + key: source.id, + onClick: onClick, + title: getFileURL(source, false), + }, + React.createElement(AccessibleImage, { + className: `dropdown-icon ${this.getIconClass(source)}`, + }), + span( + { + className: "dropdown-label", + }, + filename + ) + ); + }; + + // Note that these three listener will be called from Tab component + // so that e.target will be Tab's DOM (and not Tabs one). + onTabDragStart = e => { + this.draggedSourceId = e.target.dataset.sourceId; + this.draggedSourceIndex = e.target.dataset.index; + }; + + onTabDragEnd = () => { + this.draggedSourceId = null; + this.draggedSourceIndex = -1; + }; + + onTabDragOver = e => { + e.preventDefault(); + + const hoveredTabIndex = e.target.dataset.index; + const { moveTabBySourceId } = this.props; + + if (hoveredTabIndex === this.draggedSourceIndex) { + return; + } + + const tabDOMRect = e.target.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.draggedSourceId, 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.draggedSourceId, 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 React.createElement(Tab, { + onDragStart: this.onTabDragStart, + onDragOver: this.onTabDragOver, + onDragEnd: this.onTabDragEnd, + key: source.id + sourceActor?.id, + index, + source, + sourceActor, + }); + }) + ); + } + + renderDropdown() { + const { hiddenTabs } = this.state; + if (!hiddenTabs || !hiddenTabs.length) { + return null; + } + const panel = ul(null, hiddenTabs.map(this.renderDropdownSource)); + const icon = React.createElement(AccessibleImage, { + className: "more-tabs", + }); + return React.createElement(Dropdown, { + panel, + icon, + }); + } + + renderCommandBar() { + const { horizontal, endPanelCollapsed, isPaused } = this.props; + if (!endPanelCollapsed || !isPaused) { + return null; + } + return React.createElement(CommandBar, { + horizontal, + }); + } + + renderStartPanelToggleButton() { + return React.createElement(PaneToggleButton, { + position: "start", + collapsed: this.props.startPanelCollapsed, + handleClick: this.props.togglePaneCollapse, + }); + } + + renderEndPanelToggleButton() { + const { horizontal, endPanelCollapsed, togglePaneCollapse } = this.props; + if (!horizontal) { + return null; + } + return React.createElement(PaneToggleButton, { + position: "end", + collapsed: endPanelCollapsed, + handleClick: togglePaneCollapse, + horizontal, + }); + } + + render() { + return div( + { + className: "source-header", + }, + this.renderStartPanelToggleButton(), + this.renderTabs(), + this.renderDropdown(), + this.renderEndPanelToggleButton(), + this.renderCommandBar() + ); + } +} + +const mapStateToProps = state => { + return { + 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..c659de77d2 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/index.js @@ -0,0 +1,795 @@ +/* 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 "devtools/client/shared/vendor/react-prop-types"; +import React, { PureComponent } from "devtools/client/shared/vendor/react"; +import { div } from "devtools/client/shared/vendor/react-dom-factories"; +import { bindActionCreators } from "devtools/client/shared/vendor/redux"; +import ReactDOM from "devtools/client/shared/vendor/react-dom"; +import { connect } from "devtools/client/shared/vendor/react-redux"; + +import { getLineText, isLineBlackboxed } from "./../../utils/source"; +import { createLocation } from "./../../utils/location"; +import { getIndentation } from "../../utils/indentation"; + +import { + getActiveSearch, + getSelectedLocation, + getSelectedSource, + getSelectedSourceTextContent, + getSelectedBreakableLines, + getConditionalPanelLocation, + getSymbols, + getIsCurrentThreadPaused, + getSkipPausing, + getInlinePreview, + getBlackBoxRanges, + isSourceBlackBoxed, + getHighlightedLineRangeForSelectedSource, + isSourceMapIgnoreListEnabled, + isSourceOnSourceMapIgnoreList, + isMapScopesEnabled, +} from "../../selectors/index"; + +// Redux actions +import actions from "../../actions/index"; + +import SearchInFileBar from "./SearchInFileBar"; +import HighlightLines from "./HighlightLines"; +import Preview from "./Preview/index"; +import Breakpoints from "./Breakpoints"; +import ColumnBreakpoints from "./ColumnBreakpoints"; +import DebugLine from "./DebugLine"; +import HighlightLine from "./HighlightLine"; +import EmptyLines from "./EmptyLines"; +import ConditionalPanel from "./ConditionalPanel"; +import InlinePreviews from "./InlinePreviews"; +import Exceptions from "./Exceptions"; +import BlackboxLines from "./BlackboxLines"; + +import { + showSourceText, + showLoading, + showErrorMessage, + getEditor, + clearEditor, + getCursorLine, + getCursorColumn, + lineAtHeight, + toSourceLine, + getDocument, + scrollToPosition, + toEditorPosition, + getSourceLocationFromMouseEvent, + hasDocument, + onMouseOver, + startOperation, + endOperation, +} from "../../utils/editor/index"; + +import { resizeToggleButton, resizeBreakpointGutter } from "../../utils/ui"; + +const { debounce } = require("resource://devtools/shared/debounce.js"); +const classnames = require("resource://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; +} + +const cssVars = { + searchbarHeight: "var(--editor-searchbar-height)", +}; + +class Editor extends PureComponent { + static get propTypes() { + return { + selectedSource: PropTypes.object, + selectedSourceTextContent: PropTypes.object, + selectedSourceIsBlackBoxed: PropTypes.bool, + 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, + addBreakpointAtLine: PropTypes.func.isRequired, + continueToHere: 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, + skipPausing: PropTypes.bool.isRequired, + blackboxedRanges: PropTypes.object.isRequired, + breakableLines: PropTypes.object.isRequired, + highlightedLineRange: PropTypes.object, + isSourceOnIgnoreList: PropTypes.bool, + mapScopesEnabled: PropTypes.bool, + }; + } + + $editorWrapper; + constructor(props) { + super(props); + + this.state = { + editor: 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?.value !== + this.props.selectedSourceTextContent?.value || + 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; + + this.abortController = new window.AbortController(); + + // CodeMirror refreshes its internal state on window resize, but we need to also + // refresh it when the side panels are resized. + // We could have a ResizeObserver instead, but we wouldn't be able to differentiate + // between window resize and side panel resize and as a result, might refresh + // codeMirror twice, which is wasteful. + window.document + .querySelector(".editor-pane") + .addEventListener("resizeend", () => codeMirror.refresh(), { + signal: this.abortController.signal, + }); + + codeMirror.on("gutterClick", this.onGutterClick); + codeMirror.on("cursorActivity", this.onCursorChange); + + const codeMirrorWrapper = codeMirror.getWrapperElement(); + // 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 { selectedSource } = this.props; + if (selectedSource) { + e.preventDefault(); + e.stopPropagation(); + this.props.closeTab(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")); + + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + 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(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); + + 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 { + selectedSource, + selectedSourceTextContent, + conditionalPanelLocation, + closeConditionalPanel, + } = 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; + } + + if (target.classList.contains("CodeMirror-linenumber")) { + const location = createLocation({ + line, + column: undefined, + source: selectedSource, + }); + + const lineText = getLineText( + sourceId, + selectedSourceTextContent, + line + ).trim(); + + this.props.showEditorGutterContextMenu(event, editor, location, lineText); + return; + } + + if (target.getAttribute("id") === "columnmarker") { + return; + } + + const location = getSourceLocationFromMouseEvent( + editor, + selectedSource, + event + ); + + this.props.showEditorContextMenu(event, editor, location); + } + + /** + * CodeMirror event handler, called whenever the cursor moves + * for user-driven or programatic reasons. + */ + onCursorChange = event => { + const { line, ch } = event.doc.getCursor(); + this.props.selectLocation( + createLocation({ + source: this.props.selectedSource, + // CodeMirror cursor location is all 0-based. + // Whereast in DevTools frontend and backend, + // only colunm is 0-based, the line is 1 based. + line: line + 1, + column: ch, + }), + { + // Reset the context, so that we don't switch to original + // while moving the cursor within a bundle + keepContext: false, + + // Avoid highlighting the selected line + highlight: false, + } + ); + }; + + onGutterClick = (cm, line, gutter, ev) => { + const { + 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( + createLocation({ + line: sourceLine, + column: undefined, + source: selectedSource, + }) + ); + return; + } + + addBreakpointAtLine( + sourceLine, + ev.altKey, + ev.shiftKey || + isLineBlackboxed( + blackboxedRanges[selectedSource.url], + sourceLine, + isSourceOnIgnoreList + ) + ); + }; + + onGutterContextMenu = event => { + this.openMenu(event); + }; + + onClick(e) { + const { selectedSource, updateCursorPosition, jumpToMappedLocation } = + this.props; + + if (selectedSource) { + const sourceLocation = getSourceLocationFromMouseEvent( + this.state.editor, + selectedSource, + e + ); + + if (e.metaKey && e.altKey) { + jumpToMappedLocation(sourceLocation); + } + + updateCursorPosition(sourceLocation); + } + } + + shouldScrollToLocation(nextProps, editor) { + if ( + !nextProps.selectedLocation?.line || + !nextProps.selectedSourceTextContent + ) { + return false; + } + + const { selectedLocation, selectedSourceTextContent } = this.props; + const contentChanged = + !selectedSourceTextContent?.value && + nextProps.selectedSourceTextContent?.value; + const locationChanged = selectedLocation !== nextProps.selectedLocation; + const symbolsChanged = nextProps.symbols != this.props.symbols; + + return contentChanged || 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)); + } + + scrollToPosition(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 { + selectedSource, + conditionalPanelLocation, + isPaused, + inlinePreviewEnabled, + highlightedLineRange, + blackboxedRanges, + isSourceOnIgnoreList, + selectedSourceIsBlackBoxed, + mapScopesEnabled, + } = this.props; + const { editor } = this.state; + + if (!selectedSource || !editor || !getDocument(selectedSource.id)) { + return null; + } + return div( + null, + React.createElement(DebugLine, null), + React.createElement(HighlightLine, null), + React.createElement(EmptyLines, { + editor, + }), + React.createElement(Breakpoints, { + editor, + }), + isPaused && + selectedSource.isOriginal && + !selectedSource.isPrettyPrinted && + !mapScopesEnabled + ? null + : React.createElement(Preview, { + editor, + editorRef: this.$editorWrapper, + }), + highlightedLineRange + ? React.createElement(HighlightLines, { + editor, + range: highlightedLineRange, + }) + : null, + isSourceOnIgnoreList || selectedSourceIsBlackBoxed + ? React.createElement(BlackboxLines, { + editor, + selectedSource, + isSourceOnIgnoreList, + blackboxedRangesForSelectedSource: + blackboxedRanges[selectedSource.url], + }) + : null, + React.createElement(Exceptions, null), + conditionalPanelLocation + ? React.createElement(ConditionalPanel, { + editor, + }) + : null, + React.createElement(ColumnBreakpoints, { + editor, + }), + isPaused && + inlinePreviewEnabled && + (!selectedSource.isOriginal || + (selectedSource.isOriginal && selectedSource.isPrettyPrinted) || + (selectedSource.isOriginal && mapScopesEnabled)) + ? React.createElement(InlinePreviews, { + editor, + selectedSource, + }) + : null + ); + } + + renderSearchInFileBar() { + if (!this.props.selectedSource) { + return null; + } + return React.createElement(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() + ); + } +} + +Editor.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = state => { + const selectedSource = getSelectedSource(state); + const selectedLocation = getSelectedLocation(state); + + return { + 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), + blackboxedRanges: getBlackBoxRanges(state), + breakableLines: getSelectedBreakableLines(state), + highlightedLineRange: getHighlightedLineRangeForSelectedSource(state), + mapScopesEnabled: selectedSource?.isOriginal + ? isMapScopesEnabled(state) + : null, + }; +}; + +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, + showEditorContextMenu: actions.showEditorContextMenu, + showEditorGutterContextMenu: actions.showEditorGutterContextMenu, + selectLocation: actions.selectLocation, + }, + dispatch + ), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Editor); 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..909e57d4eb --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/moz.build @@ -0,0 +1,31 @@ +# 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 += [ + "Preview", +] + +CompiledModules( + "BlackboxLines.js", + "Breakpoint.js", + "Breakpoints.js", + "ColumnBreakpoint.js", + "ColumnBreakpoints.js", + "ConditionalPanel.js", + "DebugLine.js", + "EmptyLines.js", + "Exception.js", + "Exceptions.js", + "Footer.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..41024081ca --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js @@ -0,0 +1,53 @@ +/* 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 "devtools/client/shared/vendor/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: {}, + breakpointActions: {}, + editorActions: {}, + breakpoints: matchingBreakpoints, + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(React.createElement(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..f0adb096c4 --- /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 "devtools/client/shared/vendor/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(React.createElement(ConditionalPanel, props)); + return { wrapper, props }; +} + +describe("ConditionalPanel", () => { + it("should render at location of selected breakpoint", () => { + const { wrapper } = render(false, 2, 2); + expect(wrapper).toMatchSnapshot(); + }); + 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("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..767dde9e6d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js @@ -0,0 +1,88 @@ +/* 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 "devtools/client/shared/vendor/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( + React.createElement(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..c132a28aa3 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js @@ -0,0 +1,70 @@ +/* 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 "devtools/client/shared/vendor/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( + React.createElement(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..a2e47aae58 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap @@ -0,0 +1,32 @@ +// 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", + }, + }, + } + } + editor={ + Object { + "codeMirror": Object { + "setGutterMarker": [MockFunction], + }, + } + } + key="server1.conn1.child1/source1: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..58e86f5009 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap @@ -0,0 +1,747 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConditionalPanel should render at location of selected breakpoint 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 2, + "line": 2, + "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", + }, + }, + "id": "breakpoint", + "location": Object { + "column": 2, + "line": 2, + "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", + }, + }, + "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 { + "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", + }, + } + } + 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 should render with condition at selected breakpoint location 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 3, + "line": 3, + "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", + }, + }, + "id": "breakpoint", + "location": Object { + "column": 3, + "line": 3, + "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", + }, + }, + "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 { + "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", + }, + } + } + 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 should render with logpoint at selected breakpoint location 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "disabled": false, + "generatedLocation": Object { + "column": 4, + "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", + }, + }, + "id": "breakpoint", + "location": Object { + "column": 4, + "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", + }, + }, + "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 { + "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", + }, + } + } + 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..a453b034ff --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap @@ -0,0 +1,93 @@ +// 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" + > + <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" + > + <PaneToggleButton + collapsed={false} + horizontal={false} + key="toggle" + position="end" + /> + </div> +</div> +`; |