diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/client/debugger/src/components/Editor | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/components/Editor')
52 files changed, 8433 insertions, 0 deletions
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..b94c95655f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoint.js @@ -0,0 +1,181 @@ +/* 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/>. */ + +// @flow + +import { PureComponent } from "react"; +import classnames from "classnames"; + +import { getDocument, toEditorLine } from "../../utils/editor"; +import { getSelectedLocation } from "../../utils/selected-location"; +import { features } from "../../utils/prefs"; +import { showMenu } from "../../context-menu/menu"; +import { breakpointItems } from "./menus/breakpoints"; +import type { BreakpointItemActions } from "./menus/breakpoints"; +import type { EditorItemActions } from "./menus/editor"; + +import type { + Source, + Breakpoint as BreakpointType, + ThreadContext, +} from "../../types"; + +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>'; + +type Props = { + cx: ThreadContext, + breakpoint: BreakpointType, + selectedSource: Source, + editor: Object, + breakpointActions: BreakpointItemActions, + editorActions: EditorItemActions, +}; + +class Breakpoint extends PureComponent<Props> { + componentDidMount() { + this.addBreakpoint(this.props); + } + + componentDidUpdate(prevProps: Props) { + 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: MouseEvent) => { + const { + cx, + breakpointActions, + editorActions, + breakpoint, + selectedSource, + } = this.props; + + // ignore right clicks + if ((event.ctrlKey && event.button === 0) || event.button === 2) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + if (event.metaKey) { + return editorActions.continueToHere(cx, selectedLocation.line); + } + + if (event.shiftKey) { + if (features.columnBreakpoints) { + return breakpointActions.toggleBreakpointsAtLine( + cx, + !breakpoint.disabled, + selectedLocation.line + ); + } + + return breakpointActions.toggleDisabledBreakpoint(cx, breakpoint); + } + + return breakpointActions.removeBreakpointsAtLine( + cx, + selectedLocation.sourceId, + selectedLocation.line + ); + }; + + onContextMenu = (event: MouseEvent) => { + const { cx, breakpoint, selectedSource, breakpointActions } = this.props; + event.stopPropagation(); + event.preventDefault(); + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + + showMenu( + event, + breakpointItems(cx, breakpoint, selectedLocation, breakpointActions) + ); + }; + + addBreakpoint(props: Props) { + const { breakpoint, editor, selectedSource } = props; + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + + // Hidden Breakpoints are never rendered on the client + if (breakpoint.options.hidden) { + return; + } + + if (!selectedSource) { + return; + } + + const sourceId = selectedSource.id; + const line = toEditorLine(sourceId, selectedLocation.line); + const doc = getDocument(sourceId); + + doc.setGutterMarker(line, "breakpoints", this.makeMarker()); + + editor.codeMirror.addLineClass(line, "wrapClass", "new-breakpoint"); + editor.codeMirror.removeLineClass(line, "wrapClass", "breakpoint-disabled"); + editor.codeMirror.removeLineClass(line, "wrapClass", "has-condition"); + editor.codeMirror.removeLineClass(line, "wrapClass", "has-log"); + + if (breakpoint.disabled) { + editor.codeMirror.addLineClass(line, "wrapClass", "breakpoint-disabled"); + } + + if (breakpoint.options.logValue) { + editor.codeMirror.addLineClass(line, "wrapClass", "has-log"); + } else if (breakpoint.options.condition) { + editor.codeMirror.addLineClass(line, "wrapClass", "has-condition"); + } + } + + removeBreakpoint(props: Props) { + const { selectedSource, breakpoint } = props; + if (!selectedSource) { + return; + } + + const sourceId = selectedSource.id; + const doc = getDocument(sourceId); + + if (!doc) { + return; + } + + const selectedLocation = getSelectedLocation(breakpoint, selectedSource); + const line = toEditorLine(sourceId, selectedLocation.line); + + doc.setGutterMarker(line, "breakpoints", null); + doc.removeLineClass(line, "wrapClass", "new-breakpoint"); + doc.removeLineClass(line, "wrapClass", "breakpoint-disabled"); + doc.removeLineClass(line, "wrapClass", "has-condition"); + doc.removeLineClass(line, "wrapClass", "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..c4ef925356 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoints.css @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.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 { + 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..94335d6599 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Breakpoints.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import React, { Component } from "react"; +import Breakpoint from "./Breakpoint"; + +import { getSelectedSource, getFirstVisibleBreakpoints } from "../../selectors"; +import { makeBreakpointId } from "../../utils/breakpoint"; +import { connect } from "../../utils/connect"; +import { breakpointItemActions } from "./menus/breakpoints"; +import { editorItemActions } from "./menus/editor"; + +import type { BreakpointItemActions } from "./menus/breakpoints"; +import type { EditorItemActions } from "./menus/editor"; +import type { + Breakpoint as BreakpointType, + Source, + ThreadContext, +} from "../../types"; + +type OwnProps = {| + cx: ThreadContext, + editor: Object, +|}; +type Props = { + cx: ThreadContext, + selectedSource: ?Source, + breakpoints: BreakpointType[], + editor: Object, + breakpointActions: BreakpointItemActions, + editorActions: EditorItemActions, +}; + +class Breakpoints extends Component<Props> { + render() { + const { + cx, + breakpoints, + selectedSource, + editor, + breakpointActions, + editorActions, + } = this.props; + + if (!selectedSource || !breakpoints || selectedSource.isBlackBoxed) { + return null; + } + + return ( + <div> + {breakpoints.map(bp => { + return ( + <Breakpoint + cx={cx} + key={makeBreakpointId(bp.location)} + breakpoint={bp} + selectedSource={selectedSource} + editor={editor} + breakpointActions={breakpointActions} + editorActions={editorActions} + /> + ); + })} + </div> + ); + } +} + +export default connect<Props, OwnProps, _, _, _, _>( + state => ({ + // Retrieves only the first breakpoint per line so that the + // breakpoint marker represents only the first breakpoint + breakpoints: getFirstVisibleBreakpoints(state), + selectedSource: getSelectedSource(state), + }), + dispatch => ({ + breakpointActions: breakpointItemActions(dispatch), + editorActions: editorItemActions(dispatch), + }) +)(Breakpoints); diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js new file mode 100644 index 0000000000..b9d6769874 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js @@ -0,0 +1,146 @@ +/* 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/>. */ + +// @flow +import { PureComponent } from "react"; +import classnames from "classnames"; +import { showMenu } from "../../context-menu/menu"; + +import { getDocument } from "../../utils/editor"; +import { breakpointItems, createBreakpointItems } from "./menus/breakpoints"; +import { getSelectedLocation } from "../../utils/selected-location"; + +// eslint-disable-next-line max-len +import type { ColumnBreakpoint as ColumnBreakpointType } from "../../selectors/visibleColumnBreakpoints"; +import type { BreakpointItemActions } from "./menus/breakpoints"; +import type { Source, Context } from "../../types"; + +type Bookmark = { + clear: Function, +}; + +type Props = { + cx: Context, + editor: Object, + source: Source, + columnBreakpoint: ColumnBreakpointType, + breakpointActions: BreakpointItemActions, +}; + +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<Props> { + addColumnBreakpoint: Function; + bookmark: ?Bookmark; + + addColumnBreakpoint = (nextProps: ?Props) => { + 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: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + const { cx, columnBreakpoint, breakpointActions } = this.props; + + // disable column breakpoint on shift-click. + if (event.shiftKey) { + const breakpoint: breakpoint = columnBreakpoint.breakpoint; + return breakpointActions.toggleDisabledBreakpoint(cx, breakpoint); + } + + if (columnBreakpoint.breakpoint) { + breakpointActions.removeBreakpoint(cx, columnBreakpoint.breakpoint); + } else { + breakpointActions.addBreakpoint(cx, columnBreakpoint.location); + } + }; + + onContextMenu = (event: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + const { + cx, + columnBreakpoint: { breakpoint, location }, + source, + breakpointActions, + } = this.props; + + let items = createBreakpointItems(cx, location, breakpointActions); + + if (breakpoint) { + const selectedLocation = getSelectedLocation(breakpoint, source); + + items = breakpointItems( + cx, + breakpoint, + selectedLocation, + breakpointActions + ); + } + + showMenu(event, items); + }; + + componentDidMount() { + this.addColumnBreakpoint(); + } + + componentWillUnmount() { + this.clearColumnBreakpoint(); + } + + componentDidUpdate() { + this.clearColumnBreakpoint(); + this.addColumnBreakpoint(); + } + + render() { + return null; + } +} diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js new file mode 100644 index 0000000000..7a34037ce2 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import React, { Component } from "react"; + +import ColumnBreakpoint from "./ColumnBreakpoint"; + +import { + getSelectedSource, + visibleColumnBreakpoints, + getContext, +} from "../../selectors"; +import { connect } from "../../utils/connect"; +import { makeBreakpointId } from "../../utils/breakpoint"; +import { breakpointItemActions } from "./menus/breakpoints"; +import type { BreakpointItemActions } from "./menus/breakpoints"; + +import type { Source, Context } from "../../types"; +// eslint-disable-next-line max-len +import type { ColumnBreakpoint as ColumnBreakpointType } from "../../selectors/visibleColumnBreakpoints"; + +type OwnProps = {| + editor: Object, +|}; +type Props = { + cx: Context, + editor: Object, + selectedSource: ?Source, + columnBreakpoints: ColumnBreakpointType[], + breakpointActions: BreakpointItemActions, +}; + +class ColumnBreakpoints extends Component<Props> { + props: Props; + + render() { + const { + cx, + editor, + columnBreakpoints, + selectedSource, + breakpointActions, + } = this.props; + + if ( + !selectedSource || + selectedSource.isBlackBoxed || + columnBreakpoints.length === 0 + ) { + return null; + } + + let breakpoints; + editor.codeMirror.operation(() => { + breakpoints = columnBreakpoints.map(breakpoint => ( + <ColumnBreakpoint + cx={cx} + key={makeBreakpointId(breakpoint.location)} + columnBreakpoint={breakpoint} + editor={editor} + source={selectedSource} + breakpointActions={breakpointActions} + /> + )); + }); + return <div>{breakpoints}</div>; + } +} + +const mapStateToProps = state => ({ + cx: getContext(state), + selectedSource: getSelectedSource(state), + columnBreakpoints: visibleColumnBreakpoints(state), +}); + +export default connect<Props, OwnProps, _, _, _, _>( + mapStateToProps, + dispatch => ({ breakpointActions: breakpointItemActions(dispatch) }) +)(ColumnBreakpoints); diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.css b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css new file mode 100644 index 0000000000..4ce8dbcd8c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.conditional-breakpoint-panel { + cursor: initial; + margin: 1em 0; + position: relative; + display: flex; + align-items: center; + background: var(--theme-toolbar-background); + border-top: 1px solid var(--theme-splitter-color); + border-bottom: 1px solid var(--theme-splitter-color); +} + +.conditional-breakpoint-panel .prompt { + font-size: 1.8em; + color: var(--theme-graphs-orange); + padding-left: 3px; + padding-right: 3px; + padding-bottom: 3px; + text-align: right; + width: 30px; + align-self: baseline; + margin-top: 3px; +} + +.conditional-breakpoint-panel.log-point .prompt { + color: var(--purple-60); +} + +.conditional-breakpoint-panel .CodeMirror { + margin: 6px 10px; +} + +.conditional-breakpoint-panel .CodeMirror pre.CodeMirror-placeholder { + /* Match the color of the placeholder text to existing inputs in the Debugger */ + color: var(--theme-text-color-alt); +} diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.js b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js new file mode 100644 index 0000000000..c74ebe8bec --- /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/>. */ + +// @flow +import React, { PureComponent } from "react"; +import ReactDOM from "react-dom"; +import { connect } from "../../utils/connect"; +import classNames from "classnames"; +import "./ConditionalPanel.css"; +import { toEditorLine } from "../../utils/editor"; +import { prefs } from "../../utils/prefs"; +import actions from "../../actions"; + +import { + getClosestBreakpoint, + getConditionalPanelLocation, + getLogPointStatus, + getContext, +} from "../../selectors"; + +import type { SourceLocation, Context, Breakpoint } from "../../types"; + +function addNewLine(doc: Object) { + const cursor = doc.getCursor(); + const pos = { line: cursor.line, ch: cursor.ch }; + doc.replaceRange("\n", pos); +} + +type OwnProps = {| + editor: Object, +|}; +type Props = { + cx: Context, + breakpoint: ?Object, + setBreakpointOptions: typeof actions.setBreakpointOptions, + location: SourceLocation, + log: boolean, + editor: Object, + openConditionalPanel: typeof actions.openConditionalPanel, + closeConditionalPanel: typeof actions.closeConditionalPanel, +}; + +export class ConditionalPanel extends PureComponent<Props> { + cbPanel: null | Object; + input: ?HTMLTextAreaElement; + codeMirror: ?Object; + panelNode: ?HTMLDivElement; + scrollParent: ?HTMLElement; + + constructor() { + super(); + this.cbPanel = null; + } + + keepFocusOnInput() { + if (this.input) { + this.input.focus(); + } + } + + saveAndClose = () => { + if (this.input) { + this.setBreakpoint(this.input.value.trim()); + } + + this.props.closeConditionalPanel(); + }; + + onKey = (e: SyntheticKeyboardEvent<HTMLTextAreaElement>) => { + 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: string) { + const { cx, log, breakpoint } = this.props; + // If breakpoint is `pending`, props will not contain a breakpoint. + // If source is a URL without location, breakpoint will contain no generatedLocation. + const location = + breakpoint && breakpoint.generatedLocation + ? breakpoint.generatedLocation + : this.props.location; + const options = breakpoint ? breakpoint.options : {}; + const type = log ? "logValue" : "condition"; + return this.props.setBreakpointOptions(cx, location, { + ...options, + [type]: value, + }); + } + + clearConditionalPanel() { + if (this.cbPanel) { + this.cbPanel.clear(); + this.cbPanel = null; + } + if (this.scrollParent) { + this.scrollParent.removeEventListener("scroll", this.repositionOnScroll); + } + } + + repositionOnScroll = () => { + if (this.panelNode && this.scrollParent) { + const { scrollLeft } = this.scrollParent; + this.panelNode.style.transform = `translateX(${scrollLeft}px)`; + } + }; + + componentWillMount() { + return this.renderToWidget(this.props); + } + + componentWillUpdate() { + return this.clearConditionalPanel(); + } + + componentDidUpdate(prevProps: Props) { + 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: Props) { + if (this.cbPanel) { + this.clearConditionalPanel(); + } + const { location, editor } = props; + + const editorLine = toEditorLine(location.sourceId, location.line || 0); + this.cbPanel = editor.codeMirror.addLineWidget( + editorLine, + this.renderConditionalPanel(props), + { + coverGutter: true, + noHScroll: true, + } + ); + + if (this.input) { + let parent: ?Node = this.input.parentNode; + while (parent) { + if ( + parent instanceof HTMLElement && + parent.classList.contains("CodeMirror-scroll") + ) { + this.scrollParent = parent; + break; + } + parent = (parent.parentNode: ?Node); + } + + if (this.scrollParent) { + this.scrollParent.addEventListener("scroll", this.repositionOnScroll); + this.repositionOnScroll(); + } + } + } + + createEditor = (input: ?HTMLTextAreaElement) => { + 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: Props) { + const { log } = props; + const defaultValue = this.getDefaultValue(); + + const panel = document.createElement("div"); + ReactDOM.render( + <div + className={classNames("conditional-breakpoint-panel", { + "log-point": log, + })} + onClick={() => this.keepFocusOnInput()} + ref={node => (this.panelNode = node)} + > + <div className="prompt">»</div> + <textarea + defaultValue={defaultValue} + ref={input => this.createEditor(input)} + /> + </div>, + panel + ); + return panel; + } + + render() { + return null; + } +} + +const mapStateToProps = state => { + const location = getConditionalPanelLocation(state); + + if (!location) { + throw new Error("Conditional panel location needed."); + } + + const breakpoint: ?Breakpoint = getClosestBreakpoint(state, location); + + return { + cx: getContext(state), + breakpoint, + location, + log: getLogPointStatus(state), + }; +}; + +const { + setBreakpointOptions, + openConditionalPanel, + closeConditionalPanel, +} = actions; + +const mapDispatchToProps = { + setBreakpointOptions, + openConditionalPanel, + closeConditionalPanel, +}; + +export default connect<Props, OwnProps, _, _, _, _>( + 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..cb926de092 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/DebugLine.js @@ -0,0 +1,149 @@ +/* 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/>. */ + +// @flow +import { PureComponent } from "react"; +import { + toEditorPosition, + getDocument, + hasDocument, + startOperation, + endOperation, + getTokenEnd, +} from "../../utils/editor"; +import { isException } from "../../utils/pause"; +import { getIndentation } from "../../utils/indentation"; +import { connect } from "../../utils/connect"; +import { + getVisibleSelectedFrame, + getPauseReason, + getSourceWithContent, + getCurrentThread, + getPausePreviewLocation, +} from "../../selectors"; + +import type { SourceLocation, Why, SourceWithContent } from "../../types"; + +type OwnProps = {||}; +type Props = { + location: ?SourceLocation, + why: ?Why, + source: ?SourceWithContent, +}; + +type TextClasses = { + markTextClass: string, + lineClass: string, +}; + +function isDocumentReady( + source: ?SourceWithContent, + location: ?SourceLocation +) { + return location && source && source.content && hasDocument(location.sourceId); +} + +export class DebugLine extends PureComponent<Props> { + debugExpression: null; + + componentDidMount() { + const { why, location, source } = this.props; + this.setDebugLine(why, location, source); + } + + componentWillUnmount() { + const { why, location, source } = this.props; + this.clearDebugLine(why, location, source); + } + + componentDidUpdate(prevProps: Props) { + const { why, location, source } = this.props; + + startOperation(); + this.clearDebugLine(prevProps.why, prevProps.location, prevProps.source); + this.setDebugLine(why, location, source); + endOperation(); + } + + setDebugLine( + why: ?Why, + location: ?SourceLocation, + source: ?SourceWithContent + ) { + if (!location || !isDocumentReady(source, location)) { + return; + } + const { sourceId } = location; + const doc = getDocument(sourceId); + + let { line, column } = toEditorPosition(location); + let { markTextClass, lineClass } = this.getTextClasses(why); + doc.addLineClass(line, "wrapClass", 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: ?Why, + location: ?SourceLocation, + source: ?SourceWithContent + ) { + if (!location || !isDocumentReady(source, location)) { + return; + } + + if (this.debugExpression) { + this.debugExpression.clear(); + } + + const { line } = toEditorPosition(location); + const doc = getDocument(location.sourceId); + const { lineClass } = this.getTextClasses(why); + doc.removeLineClass(line, "wrapClass", lineClass); + } + + getTextClasses(why: ?Why): TextClasses { + 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; + } +} + +const mapStateToProps = state => { + const frame = getVisibleSelectedFrame(state); + const previewLocation = getPausePreviewLocation(state); + const location = previewLocation || frame?.location; + return { + frame, + location, + source: location && getSourceWithContent(state, location.sourceId), + why: getPauseReason(state, getCurrentThread(state)), + }; +}; + +export default connect<Props, OwnProps, _, _, _, _>(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..241b7d03a0 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Editor.css @@ -0,0 +1,228 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.editor-wrapper { + --debug-line-border: rgb(145, 188, 219); + --debug-expression-background: rgba(202, 227, 255, 0.5); + --debug-line-error-border: rgb(255, 0, 0); + --debug-expression-error-background: rgba(231, 116, 113, 0.3); + --line-exception-background: hsl(344, 73%, 97%); + --highlight-line-duration: 5000ms; +} + +.theme-dark .editor-wrapper { + --debug-expression-background: rgba(202, 227, 255, 0.3); + --debug-line-border: #7786a2; + --line-exception-background: hsl(345, 23%, 24%); +} + +.editor-wrapper .CodeMirror-linewidget { + margin-right: -7px; +} + +.editor-wrapper { + min-width: 0 !important; +} + +.CodeMirror.cm-s-mozilla, +.CodeMirror-scroll, +.CodeMirror-sizer { + overflow-anchor: none; +} + +/* Prevents inline preview from shifting source height (#1576163) */ +.CodeMirror-linewidget { + padding: 0; + display: flow-root; +} + +/** + * There's a known codemirror flex issue with chrome that this addresses. + * BUG https://github.com/firefox-devtools/debugger/issues/63 + */ +.editor-wrapper { + position: absolute; + width: calc(100% - 1px); + top: var(--editor-header-height); + bottom: var(--editor-footer-height); + left: 0px; +} + +html[dir="rtl"] .editor-mount { + direction: ltr; +} + +.theme-light .cm-s-mozilla .empty-line .CodeMirror-linenumber { + color: var(--grey-40); +} + +.theme-dark .cm-s-mozilla .empty-line .CodeMirror-linenumber { + color: var(--grey-50); +} + +.function-search { + max-height: 300px; + overflow: hidden; +} + +.function-search .results { + height: auto; +} + +.editor.hit-marker { + height: 15px; +} + +.editor-wrapper .highlight-lines { + background: var(--theme-selection-background-hover); +} + +.CodeMirror { + width: 100%; + height: 100%; +} + +.editor-wrapper .editor-mount { + width: 100%; + background-color: var(--theme-body-background); + font-size: var(--theme-code-font-size); + line-height: var(--theme-code-line-height); +} + +/* set the linenumber white when there is a breakpoint */ +.editor-wrapper:not(.skip-pausing) + .new-breakpoint + .CodeMirror-gutter-wrapper + .CodeMirror-linenumber { + color: white; +} + +/* move the breakpoint below the other gutter elements */ +.new-breakpoint .CodeMirror-gutter-elt:nth-child(2) { + z-index: 0; +} + +.theme-dark .editor-wrapper .CodeMirror-line .cm-comment { + color: var(--theme-comment); +} + +.debug-expression { + background-color: var(--debug-expression-background); + border-style: solid; + border-color: var(--debug-expression-background); + border-width: 1px 0px 1px 0px; + position: relative; +} + +.debug-expression::before { + content: ""; + line-height: 1px; + border-top: 1px solid var(--blue-50); + background: transparent; + position: absolute; + top: -2px; + left: 0px; + width: 100%; + } + +.debug-expression::after { + content: ""; + line-height: 1px; + border-bottom: 1px solid var(--blue-50); + position: absolute; + bottom: -2px; + left: 0px; + width: 100%; + } + +.to-line-end ~ .CodeMirror-widget { + background-color: var(--debug-expression-background); +} + +.debug-expression-error { + background-color: var(--debug-expression-error-background); +} + +.new-debug-line > .CodeMirror-line { + background-color: transparent !important; + outline: var(--debug-line-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.new-debug-line .CodeMirror-activeline-background { + display: none; +} + +.new-debug-line-error > .CodeMirror-line { + background-color: var(--debug-expression-error-background) !important; + outline: var(--debug-line-error-border) solid 1px; +} + +/* Don't display the highlight color since the debug line + is already highlighted */ +.new-debug-line-error .CodeMirror-activeline-background { + display: none; +} +.highlight-line .CodeMirror-line { + animation-name: fade-highlight-out; + animation-duration: var(--highlight-line-duration); + animation-timing-function: ease-out; + animation-direction: forwards; +} + +@keyframes fade-highlight-out { + 0% { + background-color: var(--theme-contrast-background); + } + 30% { + background-color: var(--theme-contrast-background); + } + 100% { + background-color: transparent; + } +} + +.visible { + visibility: visible; +} + +/* Code folding */ +.editor-wrapper .CodeMirror-foldgutter-open { + color: var(--grey-40); +} + +.editor-wrapper .CodeMirror-foldgutter-open, +.editor-wrapper .CodeMirror-foldgutter-folded { + fill: var(--grey-40); +} + +.editor-wrapper .CodeMirror-foldgutter-open::before, +.editor-wrapper .CodeMirror-foldgutter-open::after { + border-top: none; +} + +.editor-wrapper .CodeMirror-foldgutter-folded::before, +.editor-wrapper .CodeMirror-foldgutter-folded::after { + border-left: none; +} + +.editor-wrapper .CodeMirror-foldgutter .CodeMirror-guttermarker-subtle { + visibility: visible; +} + +.editor-wrapper .CodeMirror-foldgutter .CodeMirror-linenumber { + text-align: left; + padding: 0 0 0 2px; +} + +/* Exception line */ +.line-exception { + background-color: var(--line-exception-background); +} + +.mark-text-exception { + text-decoration: var(--red-50) wavy underline; + text-decoration-skip-ink: none; +} diff --git a/devtools/client/debugger/src/components/Editor/EditorMenu.js b/devtools/client/debugger/src/components/Editor/EditorMenu.js new file mode 100644 index 0000000000..ee8d6a7661 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/EditorMenu.js @@ -0,0 +1,112 @@ +/* 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/>. */ + +// @flow + +import { Component } from "react"; +import { connect } from "../../utils/connect"; +import { showMenu } from "../../context-menu/menu"; + +import { getSourceLocationFromMouseEvent } from "../../utils/editor"; +import { isPretty } from "../../utils/source"; +import { + getPrettySource, + getIsPaused, + getCurrentThread, + getThreadContext, + isSourceWithMap, +} from "../../selectors"; + +import { editorMenuItems, editorItemActions } from "./menus/editor"; + +import type { SourceWithContent, ThreadContext } from "../../types"; +import type { EditorItemActions } from "./menus/editor"; +import type SourceEditor from "../../utils/editor/source-editor"; + +type OwnProps = {| + selectedSource: SourceWithContent, + contextMenu: ?MouseEvent, + clearContextMenu: () => void, + editor: SourceEditor, + editorWrappingEnabled: boolean, +|}; + +type Props = { + cx: ThreadContext, + contextMenu: ?MouseEvent, + editorActions: EditorItemActions, + clearContextMenu: () => void, + editor: SourceEditor, + hasMappedLocation: boolean, + isPaused: boolean, + editorWrappingEnabled: boolean, + selectedSource: SourceWithContent, +}; + +class EditorMenu extends Component<Props> { + componentWillUpdate(nextProps: Props) { + this.props.clearContextMenu(); + if (nextProps.contextMenu) { + this.showMenu(nextProps); + } + } + + showMenu(props: Props) { + const { + cx, + editor, + selectedSource, + editorActions, + hasMappedLocation, + isPaused, + editorWrappingEnabled, + contextMenu: event, + } = props; + + const location = getSourceLocationFromMouseEvent( + editor, + selectedSource, + // Use a coercion, as contextMenu is optional + (event: any) + ); + + showMenu( + event, + editorMenuItems({ + cx, + editorActions, + selectedSource, + hasMappedLocation, + location, + isPaused, + editorWrappingEnabled, + selectionText: editor.codeMirror.getSelection().trim(), + isTextSelected: editor.codeMirror.somethingSelected(), + }) + ); + } + + render() { + return null; + } +} + +const mapStateToProps = (state, props) => ({ + cx: getThreadContext(state), + isPaused: getIsPaused(state, getCurrentThread(state)), + hasMappedLocation: + (props.selectedSource.isOriginal || + isSourceWithMap(state, props.selectedSource.id) || + isPretty(props.selectedSource)) && + !getPrettySource(state, props.selectedSource.id), +}); + +const mapDispatchToProps = dispatch => ({ + editorActions: editorItemActions(dispatch), +}); + +export default connect<Props, OwnProps, _, _, _, _>( + mapStateToProps, + mapDispatchToProps +)(EditorMenu); diff --git a/devtools/client/debugger/src/components/Editor/EmptyLines.js b/devtools/client/debugger/src/components/Editor/EmptyLines.js new file mode 100644 index 0000000000..6ee1440dce --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/EmptyLines.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import { connect } from "../../utils/connect"; +import { Component } from "react"; +import { getSelectedSource, getSelectedBreakableLines } from "../../selectors"; +import type { Source } from "../../types"; +import { fromEditorLine } from "../../utils/editor"; + +type OwnProps = {| + editor: Object, +|}; +type Props = { + selectedSource: Source, + editor: Object, + breakableLines: Set<number>, +}; + +class EmptyLines extends Component<Props> { + componentDidMount() { + this.disableEmptyLines(); + } + + componentDidUpdate() { + this.disableEmptyLines(); + } + + componentWillUnmount() { + const { editor } = this.props; + + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + editor.codeMirror.removeLineClass( + lineHandle, + "wrapClass", + "empty-line" + ); + }); + }); + } + + disableEmptyLines() { + const { breakableLines, selectedSource, editor } = this.props; + + editor.codeMirror.operation(() => { + editor.codeMirror.eachLine(lineHandle => { + const line = fromEditorLine( + selectedSource.id, + editor.codeMirror.getLineNumber(lineHandle) + ); + + if (breakableLines.has(line)) { + editor.codeMirror.removeLineClass( + lineHandle, + "wrapClass", + "empty-line" + ); + } else { + editor.codeMirror.addLineClass(lineHandle, "wrapClass", "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<Props, OwnProps, _, _, _, _>(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..baa1508c7f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Exception.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/>. */ + +// @flow +import { PureComponent } from "react"; + +import { toEditorPosition, getTokenEnd } from "../../utils/editor"; + +import { getIndentation } from "../../utils/indentation"; + +import type { SourceDocuments, Exception as Exc, SourceId } from "../../types"; + +type Props = { + exception: Exc, + doc: SourceDocuments, + selectedSourceId: SourceId, +}; + +type MarkText = { + clear: Function, +}; + +export default class Exception extends PureComponent<Props> { + exceptionLine: ?number; + markText: ?MarkText; + + componentDidMount() { + this.addEditorExceptionLine(); + } + + componentDidUpdate() { + this.clearEditorExceptionLine(); + this.addEditorExceptionLine(); + } + + componentWillUnmount() { + this.clearEditorExceptionLine(); + } + + setEditorExceptionLine( + doc: SourceDocuments, + line: number, + column: number, + lineText: string + ) { + doc.addLineClass(line, "wrapClass", "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, selectedSourceId } = this.props; + const { columnNumber, lineNumber } = exception; + + const location = { + column: columnNumber - 1, + line: lineNumber, + sourceId: selectedSourceId, + }; + + const { line, column } = toEditorPosition(location); + const lineText = doc.getLine(line); + + this.setEditorExceptionLine(doc, line, column, lineText); + } + + clearEditorExceptionLine() { + if (this.markText) { + const { doc } = this.props; + + this.markText.clear(); + doc.removeLineClass(this.exceptionLine, "wrapClass", "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..b787d65363 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Exceptions.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import React, { Component } from "react"; +import { connect } from "../../utils/connect"; + +import Exception from "./Exception"; + +import { + getSelectedSource, + getSelectedSourceExceptions, +} from "../../selectors"; +import { getDocument } from "../../utils/editor"; + +import type { Source, Exception as Exc } from "../../types"; + +type Props = { + selectedSource: ?Source, + exceptions: Exc[], +}; + +type OwnProps = {||}; + +class Exceptions extends Component<Props> { + render() { + const { exceptions, selectedSource } = this.props; + + if (!selectedSource || !exceptions.length) { + return null; + } + + const doc = getDocument(selectedSource.id); + + return ( + <> + {exceptions.map(exc => ( + <Exception + exception={exc} + doc={doc} + key={`${exc.sourceActorId}:${exc.lineNumber}`} + selectedSourceId={selectedSource.id} + /> + ))} + </> + ); + } +} + +export default connect<Props, OwnProps, _, _, _, _>(state => ({ + exceptions: getSelectedSourceExceptions(state), + selectedSource: getSelectedSource(state), +}))(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..61fbe9b52e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Footer.css @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.source-footer { + background: var(--theme-body-background); + border-top: 1px solid var(--theme-splitter-color); + position: absolute; + display: flex; + bottom: 0; + left: 0; + right: 0; + opacity: 1; + z-index: 1; + width: calc(100% - 1px); + user-select: none; + height: var(--editor-footer-height); + box-sizing: border-box; +} + +.source-footer-start { + display: flex; + align-items: center; + justify-self: start; +} + +.source-footer-end { + display: flex; + margin-left: auto; +} + +.source-footer .commands * { + user-select: none; +} + +.source-footer .commands { + display: flex; +} + +.source-footer .commands .action { + display: flex; + justify-content: center; + align-items: center; + transition: opacity 200ms; + border: none; + background: transparent; + padding: 4px 6px; +} + +.source-footer .commands button.action:hover { + background: var(--theme-toolbar-background-hover); +} + +:root.theme-dark .source-footer .commands .action { + fill: var(--theme-body-color); +} + +:root.theme-dark .source-footer .commands .action:hover { + fill: var(--theme-selection-color); +} + +.source-footer .blackboxed .img.blackBox { + background-color: var(--theme-icon-checked-color); +} + +.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..a1e4a0a6b0 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Footer.js @@ -0,0 +1,295 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import React, { PureComponent } from "react"; +import { connect } from "../../utils/connect"; +import classnames from "classnames"; +import actions from "../../actions"; +import { + getSelectedSourceWithContent, + getPrettySource, + getPaneCollapse, + getContext, +} from "../../selectors"; + +import { + isPretty, + getFilename, + isOriginal, + shouldBlackbox, +} from "../../utils/source"; +import { + getGeneratedSource, + canPrettyPrintSource, +} from "../../reducers/sources"; + +import { PaneToggleButton } from "../shared/Button"; +import AccessibleImage from "../shared/AccessibleImage"; + +import type { SourceWithContent, Source, Context } from "../../types"; + +import "./Footer.css"; + +type CursorPosition = { + line: number, + column: number, +}; + +type OwnProps = {| + horizontal: boolean, +|}; +type Props = { + cx: Context, + selectedSource: ?SourceWithContent, + mappedSource: ?Source, + endPanelCollapsed: boolean, + horizontal: boolean, + canPrettyPrint: boolean, + togglePrettyPrint: typeof actions.togglePrettyPrint, + toggleBlackBox: typeof actions.toggleBlackBox, + jumpToMappedLocation: typeof actions.jumpToMappedLocation, + togglePaneCollapse: typeof actions.togglePaneCollapse, +}; + +type State = { + cursorPosition: CursorPosition, +}; + +class SourceFooter extends PureComponent<Props, State> { + constructor() { + super(); + + this.state = { cursorPosition: { line: 0, column: 0 } }; + } + + componentDidUpdate() { + const eventDoc = document.querySelector(".editor-mount .CodeMirror"); + // querySelector can return null + if (eventDoc) { + this.toggleCodeMirror(eventDoc, true); + } + } + + componentWillUnmount() { + const eventDoc = document.querySelector(".editor-mount .CodeMirror"); + + if (eventDoc) { + this.toggleCodeMirror(eventDoc, false); + } + } + + toggleCodeMirror(eventDoc: Object, toggle: boolean) { + if (toggle === true) { + eventDoc.CodeMirror.on("cursorActivity", this.onCursorChange); + } else { + eventDoc.CodeMirror.off("cursorActivity", this.onCursorChange); + } + } + + prettyPrintButton() { + const { + cx, + selectedSource, + canPrettyPrint, + togglePrettyPrint, + } = this.props; + + if (!selectedSource) { + return; + } + + if (!selectedSource.content && selectedSource.isPrettyPrinted) { + return ( + <div className="action" key="pretty-loader"> + <AccessibleImage className="loader spin" /> + </div> + ); + } + + if (!canPrettyPrint) { + return; + } + + const tooltip = L10N.getStr("sourceTabs.prettyPrint"); + const sourceLoaded = !!selectedSource.content; + + const type = "prettyPrint"; + return ( + <button + onClick={() => togglePrettyPrint(cx, selectedSource.id)} + className={classnames("action", type, { + active: sourceLoaded, + pretty: isPretty(selectedSource), + })} + key={type} + title={tooltip} + aria-label={tooltip} + > + <AccessibleImage className={type} /> + </button> + ); + } + + blackBoxButton() { + const { cx, selectedSource, toggleBlackBox } = this.props; + const sourceLoaded = selectedSource?.content; + + if (!selectedSource) { + return; + } + + if (!shouldBlackbox(selectedSource)) { + return; + } + + const blackboxed = selectedSource.isBlackBoxed; + + const tooltip = blackboxed + ? L10N.getStr("sourceFooter.unignore") + : L10N.getStr("sourceFooter.ignore"); + + const type = "black-box"; + + return ( + <button + onClick={() => toggleBlackBox(cx, selectedSource)} + className={classnames("action", type, { + active: sourceLoaded, + blackboxed, + })} + key={type} + title={tooltip} + aria-label={tooltip} + > + <AccessibleImage className="blackBox" /> + </button> + ); + } + + renderToggleButton() { + if (this.props.horizontal) { + return; + } + + return ( + <PaneToggleButton + key="toggle" + collapsed={this.props.endPanelCollapsed} + horizontal={this.props.horizontal} + handleClick={(this.props.togglePaneCollapse: any)} + position="end" + /> + ); + } + + renderCommands() { + const commands = [this.blackBoxButton(), this.prettyPrintButton()].filter( + Boolean + ); + + return commands.length ? <div className="commands">{commands}</div> : null; + } + + renderSourceSummary() { + const { + cx, + mappedSource, + jumpToMappedLocation, + selectedSource, + } = this.props; + + if (!mappedSource || !selectedSource || !isOriginal(selectedSource)) { + return null; + } + + const filename = getFilename(mappedSource); + const tooltip = L10N.getFormatStr( + "sourceFooter.mappedSourceTooltip", + filename + ); + const title = L10N.getFormatStr("sourceFooter.mappedSource", filename); + const mappedSourceLocation = { + sourceId: selectedSource.id, + line: 1, + column: 1, + }; + return ( + <button + className="mapped-source" + onClick={() => jumpToMappedLocation(cx, mappedSourceLocation)} + title={tooltip} + > + <span>{title}</span> + </button> + ); + } + + onCursorChange = (event: any) => { + const { line, ch } = event.doc.getCursor(); + this.setState({ cursorPosition: { line, column: ch } }); + }; + + renderCursorPosition() { + if (!this.props.selectedSource) { + return null; + } + + const { line, column } = this.state.cursorPosition; + + const text = L10N.getFormatStr( + "sourceFooter.currentCursorPosition", + line + 1, + column + 1 + ); + const title = L10N.getFormatStr( + "sourceFooter.currentCursorPosition.tooltip", + line + 1, + column + 1 + ); + return ( + <div className="cursor-position" title={title}> + {text} + </div> + ); + } + + render() { + return ( + <div className="source-footer"> + <div className="source-footer-start">{this.renderCommands()}</div> + <div className="source-footer-end"> + {this.renderSourceSummary()} + {this.renderCursorPosition()} + {this.renderToggleButton()} + </div> + </div> + ); + } +} + +const mapStateToProps = state => { + const selectedSource = getSelectedSourceWithContent(state); + + return { + cx: getContext(state), + selectedSource, + mappedSource: getGeneratedSource(state, selectedSource), + prettySource: getPrettySource( + state, + selectedSource ? selectedSource.id : null + ), + endPanelCollapsed: getPaneCollapse(state, "end"), + canPrettyPrint: selectedSource + ? canPrettyPrintSource(state, selectedSource.id) + : false, + }; +}; + +export default connect<Props, OwnProps, _, _, _, _>(mapStateToProps, { + togglePrettyPrint: actions.togglePrettyPrint, + toggleBlackBox: actions.toggleBlackBox, + jumpToMappedLocation: actions.jumpToMappedLocation, + togglePaneCollapse: actions.togglePaneCollapse, +})(SourceFooter); diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.css b/devtools/client/debugger/src/components/Editor/HighlightCalls.css new file mode 100644 index 0000000000..b7e0402cab --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.css @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.highlight-function-calls { + background-color: rgba(202, 227, 255, 0.5); +} + +.theme-dark .highlight-function-calls { + background-color: #743884; +} + +.highlight-function-calls:hover { + cursor: default; +} diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.js b/devtools/client/debugger/src/components/Editor/HighlightCalls.js new file mode 100644 index 0000000000..1edb68100a --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.js @@ -0,0 +1,122 @@ +/* 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/>. */ + +// @flow +import { Component } from "react"; +import { connect } from "../../utils/connect"; +import { + getHighlightedCalls, + getThreadContext, + getCurrentThread, +} from "../../selectors"; +import { getSourceLocationFromMouseEvent } from "../../utils/editor"; +import actions from "../../actions"; +import "./HighlightCalls.css"; +import type { + ThreadContext, + SourceWithContent, + HighlightedCalls as HighlightedCallsType, + HighlightedCall, +} from "../../types"; + +type OwnProps = {| + editor: Object, + selectedSource: ?SourceWithContent, +|}; + +type Props = { + editor: Object, + highlightedCalls: ?HighlightedCallsType, + cx: ThreadContext, + selectedSource: ?SourceWithContent, + continueToHere: typeof actions.continueToHere, +}; + +export class HighlightCalls extends Component<Props> { + previousCalls: HighlightedCallsType | null = null; + + componentDidUpdate() { + this.unhighlightFunctionCalls(); + this.highlightFunctioCalls(); + } + + markCall = (call: HighlightedCall) => { + const { editor } = this.props; + const startLine = call.location.start.line - 1; + const endLine = call.location.end.line - 1; + const startColumn = call.location.start.column; + const endColumn = call.location.end.column; + const markedCall = editor.codeMirror.markText( + { line: startLine, ch: startColumn }, + { line: endLine, ch: endColumn }, + { className: "highlight-function-calls" } + ); + return markedCall; + }; + + onClick = (e: MouseEvent) => { + const { editor, selectedSource, cx, continueToHere } = this.props; + + if (selectedSource) { + const location = getSourceLocationFromMouseEvent( + editor, + selectedSource, + e + ); + continueToHere(cx, location); + editor.codeMirror.execCommand("singleSelection"); + editor.codeMirror.execCommand("goGroupLeft"); + } + }; + + highlightFunctioCalls() { + const { highlightedCalls } = this.props; + + if (!highlightedCalls) { + return; + } + + let markedCalls = []; + markedCalls = highlightedCalls.map(this.markCall); + + const allMarkedElements = document.getElementsByClassName( + "highlight-function-calls" + ); + + for (let i = 0; i < allMarkedElements.length; i++) { + allMarkedElements[i].addEventListener("click", this.onClick); + } + + this.previousCalls = markedCalls; + } + + unhighlightFunctionCalls() { + if (!this.previousCalls) { + return; + } + this.previousCalls.forEach(call => call.clear()); + this.previousCalls = null; + } + + render() { + return null; + } +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + return { + highlightedCalls: getHighlightedCalls(state, thread), + cx: getThreadContext(state), + }; +}; + +const { continueToHere } = actions; + +const mapDispatchToProps = { continueToHere }; + +export default connect<Props, OwnProps, _, _, _, _>( + mapStateToProps, + mapDispatchToProps +)(HighlightCalls); diff --git a/devtools/client/debugger/src/components/Editor/HighlightLine.js b/devtools/client/debugger/src/components/Editor/HighlightLine.js new file mode 100644 index 0000000000..0b66d499b1 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightLine.js @@ -0,0 +1,195 @@ +/* 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/>. */ + +// @flow +import { Component } from "react"; +import { toEditorLine, endOperation, startOperation } from "../../utils/editor"; +import { getDocument, hasDocument } from "../../utils/editor/source-documents"; + +import { connect } from "../../utils/connect"; +import { + getVisibleSelectedFrame, + getSelectedLocation, + getSelectedSourceWithContent, + getPauseCommand, + getCurrentThread, +} from "../../selectors"; + +import type { + SourceLocation, + SourceWithContent, + SourceDocuments, +} from "../../types"; +import type { Command } from "../../reducers/types"; + +type HighlightFrame = { + location: SourceLocation, +}; + +type OwnProps = {||}; +type Props = { + pauseCommand: Command, + selectedFrame: ?HighlightFrame, + selectedLocation: SourceLocation, + selectedSource: ?SourceWithContent, +}; + +function isDebugLine( + selectedFrame: ?HighlightFrame, + selectedLocation: SourceLocation +) { + if (!selectedFrame) { + return; + } + + return ( + selectedFrame.location.sourceId == selectedLocation.sourceId && + selectedFrame.location.line == selectedLocation.line + ); +} + +function isDocumentReady(selectedSource: ?SourceWithContent, selectedLocation) { + return ( + selectedLocation && + selectedSource && + selectedSource.content && + hasDocument(selectedLocation.sourceId) + ); +} + +export class HighlightLine extends Component<Props> { + isStepping: boolean = false; + previousEditorLine: ?number = null; + + shouldComponentUpdate(nextProps: Props) { + const { selectedLocation, selectedSource } = nextProps; + return this.shouldSetHighlightLine(selectedLocation, selectedSource); + } + + componentDidUpdate(prevProps: Props) { + this.completeHighlightLine(prevProps); + } + + componentDidMount() { + this.completeHighlightLine(null); + } + + shouldSetHighlightLine( + selectedLocation: SourceLocation, + selectedSource: ?SourceWithContent + ) { + const { sourceId, line } = selectedLocation; + const editorLine = toEditorLine(sourceId, line); + + if (!isDocumentReady(selectedSource, selectedLocation)) { + return false; + } + + if (this.isStepping && editorLine === this.previousEditorLine) { + return false; + } + + return true; + } + + completeHighlightLine(prevProps: Props | null) { + const { + pauseCommand, + selectedLocation, + selectedFrame, + selectedSource, + } = this.props; + if (pauseCommand) { + this.isStepping = true; + } + + startOperation(); + if (prevProps) { + this.clearHighlightLine( + prevProps.selectedLocation, + prevProps.selectedSource + ); + } + this.setHighlightLine(selectedLocation, selectedFrame, selectedSource); + endOperation(); + } + + setHighlightLine( + selectedLocation: SourceLocation, + selectedFrame: ?HighlightFrame, + selectedSource: ?SourceWithContent + ) { + const { sourceId, line } = selectedLocation; + if (!this.shouldSetHighlightLine(selectedLocation, selectedSource)) { + return; + } + + this.isStepping = false; + const editorLine = toEditorLine(sourceId, line); + this.previousEditorLine = editorLine; + + if (!line || isDebugLine(selectedFrame, selectedLocation)) { + return; + } + + const doc = getDocument(sourceId); + doc.addLineClass(editorLine, "wrapClass", "highlight-line"); + this.resetHighlightLine(doc, editorLine); + } + + resetHighlightLine(doc: SourceDocuments, editorLine: number) { + const editorWrapper: HTMLElement | null = document.querySelector( + ".editor-wrapper" + ); + + if (editorWrapper === null) { + return; + } + + const duration = parseInt( + getComputedStyle(editorWrapper).getPropertyValue( + "--highlight-line-duration" + ), + 10 + ); + + setTimeout( + () => + doc && doc.removeLineClass(editorLine, "wrapClass", "highlight-line"), + duration + ); + } + + clearHighlightLine( + selectedLocation: SourceLocation, + selectedSource: ?SourceWithContent + ) { + if (!isDocumentReady(selectedSource, selectedLocation)) { + return; + } + + const { line, sourceId } = selectedLocation; + const editorLine = toEditorLine(sourceId, line); + const doc = getDocument(sourceId); + doc.removeLineClass(editorLine, "wrapClass", "highlight-line"); + } + + render() { + return null; + } +} + +export default connect<Props, OwnProps, _, _, _, _>(state => { + const selectedLocation = getSelectedLocation(state); + + if (!selectedLocation) { + throw new Error("must have selected location"); + } + return { + pauseCommand: getPauseCommand(state, getCurrentThread(state)), + selectedFrame: getVisibleSelectedFrame(state), + selectedLocation, + selectedSource: getSelectedSourceWithContent(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..635f0c9ec2 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/HighlightLines.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import { Component } from "react"; +import { range, isEmpty } from "lodash"; +import { connect } from "../../utils/connect"; +import { getHighlightedLineRange } from "../../selectors"; + +type OwnProps = {| + editor: Object, +|}; +type Props = { + highlightedLineRange: Object, + editor: Object, +}; + +class HighlightLines extends Component<Props> { + highlightLineRange: Function; + + componentDidMount() { + this.highlightLineRange(); + } + + componentWillUpdate() { + this.clearHighlightRange(); + } + + componentDidUpdate() { + this.highlightLineRange(); + } + + componentWillUnmount() { + this.clearHighlightRange(); + } + + clearHighlightRange() { + const { highlightedLineRange, editor } = this.props; + + const { codeMirror } = editor; + + if (isEmpty(highlightedLineRange) || !codeMirror) { + return; + } + + const { start, end } = highlightedLineRange; + codeMirror.operation(() => { + range(start - 1, end).forEach(line => { + codeMirror.removeLineClass(line, "wrapClass", "highlight-lines"); + }); + }); + } + + highlightLineRange = () => { + const { highlightedLineRange, editor } = this.props; + + const { codeMirror } = editor; + + if (isEmpty(highlightedLineRange) || !codeMirror) { + return; + } + + const { start, end } = highlightedLineRange; + + codeMirror.operation(() => { + editor.alignLine(start); + + range(start - 1, end).forEach(line => { + codeMirror.addLineClass(line, "wrapClass", "highlight-lines"); + }); + }); + }; + + render() { + return null; + } +} + +export default connect<Props, OwnProps, _, _, _, _>(state => ({ + highlightedLineRange: getHighlightedLineRange(state), +}))(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..108e63f932 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreview.js @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow +import React, { PureComponent } from "react"; +// $FlowIgnore +import Reps from "devtools/client/shared/components/reps/index"; + +import actions from "../../actions"; + +const { + REPS: { + Rep, + ElementNode: { supportsObject: isElement }, + }, + MODE, +} = Reps; + +type Props = { + line: number, + value: any, + variable: string, + openElementInInspector: typeof actions.openElementInInspectorCommand, + highlightDomElement: typeof actions.highlightDomElement, + unHighlightDomElement: typeof actions.unHighlightDomElement, +}; + +// Renders single variable preview inside a codemirror line widget +class InlinePreview extends PureComponent<Props> { + showInScopes(variable: string) { + // TODO: focus on variable value in the scopes sidepanel + // we will need more info from parent comp + } + + render() { + const { + value, + variable, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + const mode = isElement(value) ? MODE.TINY : MODE.SHORT; + + return ( + <span + className="inline-preview-outer" + onClick={() => this.showInScopes(variable)} + > + <span className="inline-preview-label">{variable}:</span> + <span className="inline-preview-value"> + <Rep + object={value} + mode={mode} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + /> + </span> + </span> + ); + } +} + +export default InlinePreview; diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js new file mode 100644 index 0000000000..244b888122 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js @@ -0,0 +1,120 @@ +/* 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/>. */ + +// @flow +import React, { PureComponent } from "react"; +import ReactDOM from "react-dom"; + +import actions from "../../actions"; +import assert from "../../utils/assert"; +import { connect } from "../../utils/connect"; +import InlinePreview from "./InlinePreview"; + +import type { Preview } from "../../types"; + +type OwnProps = {| + editor: Object, + line: number, + previews: Array<Preview>, +|}; +type Props = { + editor: Object, + line: number, + previews: Array<Preview>, + openElementInInspector: typeof actions.openElementInInspectorCommand, + highlightDomElement: typeof actions.highlightDomElement, + unHighlightDomElement: typeof actions.unHighlightDomElement, +}; + +import "./InlinePreview.css"; + +// Handles rendering for each line ( row ) +// * Renders single widget for each line in codemirror +// * Renders InlinePreview for each preview inside the widget +class InlinePreviewRow extends PureComponent<Props> { + bookmark: Object; + widgetNode: Object; + + componentDidMount() { + this.updatePreviewWidget(this.props, null); + } + + componentDidUpdate(prevProps: Props) { + this.updatePreviewWidget(this.props, prevProps); + } + + componentWillUnmount() { + this.updatePreviewWidget(null, this.props); + } + + updatePreviewWidget(props: Props | null, prevProps: Props | null) { + if ( + this.bookmark && + prevProps && + (!props || + prevProps.editor !== props.editor || + prevProps.line !== props.line) + ) { + this.bookmark.clear(); + this.bookmark = null; + this.widgetNode = null; + } + + if (!props) { + return assert( + !this.bookmark, + "Inline Preview widget shouldn't be present." + ); + } + + const { + editor, + line, + previews, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = props; + + if (!this.bookmark) { + this.widgetNode = document.createElement("div"); + this.widgetNode.classList.add("inline-preview"); + } + + ReactDOM.render( + <React.Fragment> + {previews.map((preview: Preview) => ( + <InlinePreview + line={line} + key={`${line}-${preview.name}`} + variable={preview.name} + value={preview.value} + openElementInInspector={openElementInInspector} + highlightDomElement={highlightDomElement} + unHighlightDomElement={unHighlightDomElement} + /> + ))} + </React.Fragment>, + this.widgetNode + ); + + this.bookmark = editor.codeMirror.setBookmark( + { + line, + ch: Infinity, + }, + this.widgetNode + ); + } + + render() { + return null; + } +} + +export default connect<Props, OwnProps, _, _, _, _>(() => ({}), { + 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..18c3abfa98 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/InlinePreviews.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/>. */ + +// @flow +import React, { Component } from "react"; +import InlinePreviewRow from "./InlinePreviewRow"; +import { connect } from "../../utils/connect"; +import { + getSelectedFrame, + getCurrentThread, + getInlinePreviews, +} from "../../selectors"; + +import type { Frame } from "../../types"; + +type OwnProps = {| + editor: Object, + selectedSource: Object, +|}; +type Props = { + editor: Object, + +selectedFrame: ?Frame, + selectedSource: Object, + +previews: ?Object, +}; + +function hasPreviews(previews: ?Object) { + return !!previews && Object.keys(previews).length > 0; +} + +class InlinePreviews extends Component<Props> { + shouldComponentUpdate({ previews }: Props) { + return hasPreviews(previews); + } + + render() { + const { editor, selectedFrame, selectedSource, previews } = this.props; + + // Render only if currently open file is the one where debugger is paused + if ( + !selectedFrame || + selectedFrame.location.sourceId !== selectedSource.id || + !hasPreviews(previews) + ) { + return null; + } + const previewsObj: Object = previews; + + let inlinePreviewRows; + editor.codeMirror.operation(() => { + inlinePreviewRows = Object.keys(previewsObj).map((line: string) => { + const lineNum: number = parseInt(line, 10); + + return ( + <InlinePreviewRow + editor={editor} + key={line} + line={lineNum} + previews={previewsObj[line]} + /> + ); + }); + }); + + return <div>{inlinePreviewRows}</div>; + } +} + +const mapStateToProps = ( + state +): {| + selectedFrame: ?Frame, + previews: ?Object, +|} => { + 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<Props, OwnProps, _, _, _, _>(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..fed7cfac0f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview.css @@ -0,0 +1,113 @@ +/* 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; + min-height: 80px; + border: 1px solid var(--theme-splitter-color); + padding: 10px; + height: auto; + min-height: inherit; + max-height: 200px; + overflow: auto; + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.theme-dark .popover .preview { + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.popover .preview .header { + width: 100%; + line-height: 20px; + border-bottom: 1px solid #cccccc; + display: flex; + flex-direction: column; +} + +.popover .preview .header .link { + align-self: flex-end; + color: var(--theme-highlight-blue); + text-decoration: underline; +} + +.selection, +.debug-expression.selection { + background-color: var(--theme-highlight-yellow); +} + +.theme-dark .selection, +.theme-dark .debug-expression.selection { + background-color: #743884; +} + +.theme-dark .cm-s-mozilla .selection, +.theme-dark .cm-s-mozilla .debug-expression.selection { + color: #e7ebee; +} + +.popover .preview .function-signature { + padding-top: 10px; +} + +.theme-dark .popover .preview { + border-color: var(--theme-body-color); +} + +.tooltip { + position: fixed; + z-index: 100; +} + +.tooltip .preview { + background: var(--theme-toolbar-background); + max-width: inherit; + min-height: 80px; + 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..bd18cdbc0f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js @@ -0,0 +1,181 @@ +/* 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/>. */ + +// @flow + +import React, { Component } from "react"; +import { connect } from "../../../utils/connect"; +import classnames from "classnames"; + +// $FlowIgnore +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { StringRep }, +} = Reps; + +import actions from "../../../actions"; + +import { getThreadContext } from "../../../selectors"; + +import AccessibleImage from "../../shared/AccessibleImage"; + +// $FlowIgnore +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); + +import type { ThreadContext, StacktraceFrame, Exception } from "../../../types"; + +type Props = { + cx: ThreadContext, + clearPreview: typeof actions.clearPreview, + selectSourceURL: typeof actions.selectSourceURL, + exception: Exception, + mouseout: Function, +}; + +type OwnProps = {| + exception: Exception, + mouseout: Function, +|}; + +type State = { + isStacktraceExpanded: boolean, +}; + +const POPUP_SELECTOR = ".preview-popup.exception-popup"; +const ANONYMOUS_FN_NAME = "<anonymous>"; + +// The exception popup works in two modes: +// a. when the stacktrace is closed the exception popup +// gets closed when the mouse leaves the popup. +// b. when the stacktrace is opened the exception popup +// gets closed only by clicking outside the popup. +class ExceptionPopup extends Component<Props, State> { + topWindow: Object; + + constructor(props: Props) { + super(props); + this.state = { + isStacktraceExpanded: false, + }; + } + + updateTopWindow() { + // The ChromeWindow is used when the stacktrace is expanded to capture all clicks + // outside the popup so the popup can be closed only by clicking outside of it. + if (this.topWindow) { + this.topWindow.removeEventListener( + "mousedown", + this.onTopWindowClick, + true + ); + this.topWindow = null; + } + this.topWindow = DevToolsUtils.getTopWindow(window.parent); + this.topWindow.addEventListener("mousedown", this.onTopWindowClick, true); + } + + onTopWindowClick = (e: Object) => { + const { cx, clearPreview } = this.props; + + // When the stactrace is expaned the exception popup gets closed + // only by clicking ouside the popup. + if (!e.target.closest(POPUP_SELECTOR)) { + clearPreview(cx); + } + }; + + onExceptionMessageClick() { + const isStacktraceExpanded = this.state.isStacktraceExpanded; + + this.updateTopWindow(); + this.setState({ isStacktraceExpanded: !isStacktraceExpanded }); + } + + buildStackFrame(frame: StacktraceFrame) { + const { cx, selectSourceURL } = this.props; + const { filename, lineNumber } = frame; + const functionName = frame.functionName || ANONYMOUS_FN_NAME; + + return ( + <div + className="frame" + onClick={() => selectSourceURL(cx, filename, { line: lineNumber })} + > + <span className="title">{functionName}</span> + <span className="location"> + <span className="filename">{filename}</span>: + <span className="line">{lineNumber}</span> + </span> + </div> + ); + } + + renderStacktrace(stacktrace: StacktraceFrame[]) { + const isStacktraceExpanded = this.state.isStacktraceExpanded; + + if (stacktrace.length && isStacktraceExpanded) { + return ( + <div className="exception-stacktrace"> + {stacktrace.map(frame => this.buildStackFrame(frame))} + </div> + ); + } + return null; + } + + renderArrowIcon(stacktrace: StacktraceFrame[]) { + if (stacktrace.length) { + return ( + <AccessibleImage + className={classnames("arrow", { + expanded: this.state.isStacktraceExpanded, + })} + /> + ); + } + return null; + } + + render() { + const { + exception: { stacktrace, errorMessage }, + mouseout, + } = this.props; + + return ( + <div + className="preview-popup exception-popup" + dir="ltr" + onMouseLeave={() => mouseout(true, this.state.isStacktraceExpanded)} + > + <div + className="exception-message" + onClick={() => this.onExceptionMessageClick()} + > + {this.renderArrowIcon(stacktrace)} + {StringRep.rep({ + object: errorMessage, + useQuotes: false, + className: "exception-text", + })} + </div> + {this.renderStacktrace(stacktrace)} + </div> + ); + } +} + +const mapStateToProps = state => ({ + cx: getThreadContext(state), +}); + +const mapDispatchToProps = { + selectSourceURL: actions.selectSourceURL, + clearPreview: actions.clearPreview, +}; + +export default connect<Props, OwnProps, _, _, _, _>( + mapStateToProps, + mapDispatchToProps +)(ExceptionPopup); diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.css b/devtools/client/debugger/src/components/Editor/Preview/Popup.css new file mode 100644 index 0000000000..40ee7ac76e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.css @@ -0,0 +1,204 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.popover .preview-popup { + background: var(--theme-body-background); + width: 350px; + border: 1px solid var(--theme-splitter-color); + padding: 10px; + height: auto; + overflow: auto; + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.gap svg { + pointer-events: none; +} + +.gap polygon { + pointer-events: auto; +} + +.theme-dark .popover .preview-popup { + box-shadow: 1px 2px 3px var(--popup-shadow-color); +} + +.popover .preview-popup .header-container { + width: 100%; + line-height: 15px; + display: flex; + flex-direction: row; + margin-bottom: 5px; +} + +.popover .preview-popup .logo { + width: 20px; + margin-right: 5px; +} + +.popover .preview-popup .header-container h3 { + margin: 0; + margin-bottom: 5px; + font-weight: normal; + font-size: 14px; + line-height: 20px; + margin-left: 4px; +} + +.popover .preview-popup .header .link { + align-self: flex-end; + color: var(--theme-highlight-blue); + text-decoration: underline; +} + +.popover .preview-popup .object-node { + padding-inline-start: 0px; +} + +.preview-token:hover { + cursor: default; +} + +.preview-token, +.debug-expression.preview-token { + background-color: var(--theme-highlight-yellow); +} + +.theme-dark .preview-token, +.theme-dark .debug-expression.preview-token { + background-color: #743884; +} + +.theme-dark .cm-s-mozilla .preview-token, +.theme-dark .cm-s-mozilla .debug-expression.preview-token { + color: #e7ebee; +} + +.popover .preview-popup .function-signature { + padding-top: 10px; +} + +.theme-dark .popover .preview-popup { + border-color: var(--theme-body-color); +} + +.tooltip { + position: fixed; + z-index: 100; +} + +.tooltip .preview-popup { + background: var(--theme-toolbar-background); + max-width: inherit; + min-height: 80px; + border: 1px solid var(--theme-splitter-color); + box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt); + padding: 5px; + height: auto; + min-height: inherit; + max-height: 200px; + overflow: auto; +} + +.theme-dark .tooltip .preview-popup { + border-color: var(--theme-body-color); +} + +.tooltip .gap { + height: 4px; + padding-top: 0px; +} + +.add-to-expression-bar { + border: 1px solid var(--theme-splitter-color); + border-top: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + font-size: 14px; + line-height: 30px; + background: var(--theme-toolbar-background); + color: var(--theme-text-color-inactive); + padding: 0 4px; +} + +.add-to-expression-bar .prompt { + width: 1em; +} + +.add-to-expression-bar .expression-to-save-label { + width: calc(100% - 4em); +} + +.add-to-expression-bar .expression-to-save-button { + font-size: 14px; + color: var(--theme-comment); +} + +/* Exception popup */ +.exception-popup .exception-text { + color: var(--red-70); +} + +.theme-dark .exception-popup .exception-text { + color: var(--red-20); +} + +.exception-popup .exception-message { + display: flex; + align-items: center; +} + +.exception-message .arrow { + margin-inline-end: 4px; +} + +.exception-popup .exception-stacktrace { + display: grid; + grid-template-columns: auto 1fr; + grid-column-gap: 8px; + padding-inline: 2px 3px; + line-height: var(--theme-code-line-height); +} + +.exception-stacktrace .frame { + display: contents; + cursor: pointer; +} + +.exception-stacktrace .title { + grid-column: 1/2; + color: var(--grey-90); +} + +.theme-dark .exception-stacktrace .title { + color: white; +} + +.exception-stacktrace .location { + grid-column: -1/-2; + color: var(--theme-highlight-purple); + direction: rtl; + text-align: end; + white-space: nowrap; + /* Force the location to be on one line and crop at start if wider then max-width */ + overflow: hidden; + text-overflow: ellipsis; + max-width: 350px; +} + +.theme-dark .exception-stacktrace .location { + color: var(--blue-40); +} + +.exception-stacktrace .line { + color: var(--theme-highlight-blue); +} + +.theme-dark .exception-stacktrace .line { + color: hsl(210, 40%, 60%); +} diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.js b/devtools/client/debugger/src/components/Editor/Preview/Popup.js new file mode 100644 index 0000000000..a3e9f4a24d --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js @@ -0,0 +1,390 @@ +/* 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/>. */ + +// @flow + +import React, { Component } from "react"; +import { connect } from "../../../utils/connect"; + +// $FlowIgnore +import Reps from "devtools/client/shared/components/reps/index"; +const { + REPS: { Rep }, + MODE, + objectInspector, +} = Reps; + +const { ObjectInspector, utils } = objectInspector; + +const { + node: { nodeIsPrimitive, nodeIsFunction, nodeIsObject }, +} = utils; + +import ExceptionPopup from "./ExceptionPopup"; + +import actions from "../../../actions"; +import { getThreadContext } from "../../../selectors"; +import Popover from "../../shared/Popover"; +import PreviewFunction from "../../shared/PreviewFunction"; + +import "./Popup.css"; + +import type { ThreadContext, Exception } from "../../../types"; +import type { Preview } from "../../../reducers/types"; + +type OwnProps = {| + editor: any, + preview: Preview, + editorRef: ?HTMLDivElement, +|}; +type Props = { + cx: ThreadContext, + preview: Preview, + editor: any, + editorRef: ?HTMLDivElement, + addExpression: typeof actions.addExpression, + selectSourceURL: typeof actions.selectSourceURL, + openLink: typeof actions.openLink, + openElementInInspector: typeof actions.openElementInInspectorCommand, + highlightDomElement: typeof actions.highlightDomElement, + unHighlightDomElement: typeof actions.unHighlightDomElement, + clearPreview: typeof actions.clearPreview, +}; + +export class Popup extends Component<Props> { + marker: any; + pos: any; + popover: ?React$ElementRef<typeof Popover>; + isExceptionStactraceOpen: ?boolean; + + constructor(props: Props) { + super(props); + } + + componentDidMount() { + this.addHighlightToToken(); + } + + componentWillUnmount() { + this.removeHighlightFromToken(); + } + + addHighlightToToken() { + const { target } = this.props.preview; + if (target) { + target.classList.add("preview-token"); + addHighlightToTargetSiblings(target, this.props); + } + } + + removeHighlightFromToken() { + const { target } = this.props.preview; + if (target) { + target.classList.remove("preview-token"); + removeHighlightForTargetSiblings(target); + } + } + + calculateMaxHeight = () => { + const { editorRef } = this.props; + if (!editorRef) { + return "auto"; + } + + const { height, top } = editorRef.getBoundingClientRect(); + const maxHeight = height + top; + if (maxHeight < 250) { + return maxHeight; + } + + return 250; + }; + + renderFunctionPreview() { + const { + cx, + selectSourceURL, + preview: { resultGrip }, + } = this.props; + + if (!resultGrip) { + return null; + } + + const { location } = resultGrip; + + return ( + <div + className="preview-popup" + onClick={() => + location && + selectSourceURL(cx, location.url, { + line: location.line, + }) + } + > + <PreviewFunction func={resultGrip} /> + </div> + ); + } + + renderObjectPreview() { + const { + preview: { properties }, + openLink, + openElementInInspector, + highlightDomElement, + unHighlightDomElement, + } = this.props; + + if (properties.length == 0) { + return ( + <div className="preview-popup"> + <span className="label">{L10N.getStr("preview.noProperties")}</span> + </div> + ); + } + + return ( + <div + className="preview-popup" + style={{ maxHeight: this.calculateMaxHeight() }} + > + <ObjectInspector + roots={properties} + autoExpandDepth={0} + disableWrap={true} + focusable={false} + openLink={openLink} + onDOMNodeClick={grip => openElementInInspector(grip)} + onInspectIconClick={grip => openElementInInspector(grip)} + onDOMNodeMouseOver={grip => highlightDomElement(grip)} + onDOMNodeMouseOut={grip => unHighlightDomElement(grip)} + /> + </div> + ); + } + + renderSimplePreview() { + const { + openLink, + preview: { resultGrip }, + } = this.props; + return ( + <div className="preview-popup"> + {Rep({ + object: resultGrip, + mode: MODE.LONG, + openLink, + })} + </div> + ); + } + + renderExceptionPreview(exception: Exception) { + return ( + <ExceptionPopup + exception={exception} + mouseout={this.onMouseOutException} + /> + ); + } + + renderPreview() { + // We don't have to check and + // return on `false`, `""`, `0`, `undefined` etc, + // these falsy simple typed value because we want to + // do `renderSimplePreview` on these values below. + const { + preview: { root, exception }, + } = this.props; + + if (nodeIsFunction(root)) { + return this.renderFunctionPreview(); + } + + if (nodeIsObject(root)) { + return <div>{this.renderObjectPreview()}</div>; + } + + if (exception) { + return this.renderExceptionPreview(exception); + } + + return this.renderSimplePreview(); + } + + getPreviewType() { + const { + preview: { root, properties, exception }, + } = this.props; + if ( + exception || + nodeIsPrimitive(root) || + nodeIsFunction(root) || + !Array.isArray(properties) || + properties.length === 0 + ) { + return "tooltip"; + } + + return "popover"; + } + + onMouseOut = () => { + const { clearPreview, cx } = this.props; + + clearPreview(cx); + }; + + onMouseOutException = ( + shouldClearOnMouseout: ?boolean, + isExceptionStactraceOpen: ?boolean + ) => { + // onMouseOutException can be called: + // a. when the mouse leaves Popover element + // b. when the mouse leaves ExceptionPopup element + // We want to prevent closing the popup when the stacktrace + // is expanded and the mouse leaves either the Popover element + // or the ExceptionPopup element. + const { clearPreview, cx } = this.props; + + if (shouldClearOnMouseout) { + this.isExceptionStactraceOpen = isExceptionStactraceOpen; + } + + if (!this.isExceptionStactraceOpen) { + clearPreview(cx); + } + }; + + render() { + const { + preview: { cursorPos, resultGrip, exception }, + editorRef, + } = this.props; + + if ( + !exception && + (typeof resultGrip == "undefined" || resultGrip?.optimizedOut) + ) { + return null; + } + + const type = this.getPreviewType(); + return ( + <Popover + targetPosition={cursorPos} + type={type} + editorRef={editorRef} + target={this.props.preview.target} + mouseout={exception ? this.onMouseOutException : this.onMouseOut} + > + {this.renderPreview()} + </Popover> + ); + } +} + +export function addHighlightToTargetSiblings(target: Element, props: Object) { + // 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: Element) { + // Look at target's previous and next token siblings. + // If they also have the highlight class 'preview-token', + // remove that class. + let nextSibling = target.nextElementSibling; + while (nextSibling && nextSibling.className.includes("preview-token")) { + nextSibling.classList.remove("preview-token"); + nextSibling = nextSibling.nextElementSibling; + } + let previousSibling = target.previousElementSibling; + while ( + previousSibling && + previousSibling.className.includes("preview-token") + ) { + previousSibling.classList.remove("preview-token"); + previousSibling = previousSibling.previousElementSibling; + } +} + +const mapStateToProps = state => ({ + cx: getThreadContext(state), +}); + +const { + addExpression, + selectSourceURL, + openLink, + openElementInInspectorCommand, + highlightDomElement, + unHighlightDomElement, + clearPreview, +} = actions; + +const mapDispatchToProps = { + addExpression, + selectSourceURL, + openLink, + openElementInInspector: openElementInInspectorCommand, + highlightDomElement, + unHighlightDomElement, + clearPreview, +}; + +export default connect<Props, OwnProps, _, _, _, _>( + mapStateToProps, + mapDispatchToProps +)(Popup); diff --git a/devtools/client/debugger/src/components/Editor/Preview/index.js b/devtools/client/debugger/src/components/Editor/Preview/index.js new file mode 100644 index 0000000000..ebf38a1c0b --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/index.js @@ -0,0 +1,151 @@ +/* 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/>. */ + +// @flow + +import React, { PureComponent } from "react"; +import { connect } from "../../../utils/connect"; + +import Popup from "./Popup"; + +import { + getPreview, + getThreadContext, + getCurrentThread, + getHighlightedCalls, +} from "../../../selectors"; +import actions from "../../../actions"; + +import type { ThreadContext, HighlightedCalls } from "../../../types"; + +import type { Preview as PreviewType } from "../../../reducers/types"; + +type OwnProps = {| + editor: any, + editorRef: ?HTMLDivElement, +|}; +type Props = { + cx: ThreadContext, + editor: any, + editorRef: ?HTMLDivElement, + highlightedCalls: ?HighlightedCalls, + preview: ?PreviewType, + clearPreview: typeof actions.clearPreview, + addExpression: typeof actions.addExpression, + updatePreview: typeof actions.updatePreview, + setExceptionPreview: typeof actions.setExceptionPreview, +}; + +type State = { + selecting: boolean, +}; + +const EXCEPTION_MARKER = "mark-text-exception"; + +class Preview extends PureComponent<Props, State> { + target = null; + constructor(props: Props) { + super(props); + this.state = { selecting: false }; + } + + 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: ?Props) { + const { codeMirror } = this.props.editor; + const codeMirrorWrapper = codeMirror.getWrapperElement(); + codeMirror.on("tokenenter", this.onTokenEnter); + codeMirror.on("scroll", this.onScroll); + codeMirrorWrapper.addEventListener("mouseup", this.onMouseUp); + codeMirrorWrapper.addEventListener("mousedown", this.onMouseDown); + } + + onTokenEnter = ({ target, tokenPos }: any) => { + const { + cx, + editor, + updatePreview, + highlightedCalls, + setExceptionPreview, + } = this.props; + + const isTargetException = target.classList.contains(EXCEPTION_MARKER); + + if (isTargetException) { + return setExceptionPreview(cx, target, tokenPos, editor.codeMirror); + } + + if ( + cx.isPaused && + !this.state.selecting && + highlightedCalls === null && + !isTargetException + ) { + updatePreview(cx, target, tokenPos, editor.codeMirror); + } + }; + + onMouseUp = () => { + if (this.props.cx.isPaused) { + this.setState({ selecting: false }); + return true; + } + }; + + onMouseDown = () => { + if (this.props.cx.isPaused) { + this.setState({ selecting: true }); + return true; + } + }; + + onScroll = () => { + if (this.props.cx.isPaused) { + this.props.clearPreview(this.props.cx); + } + }; + + render() { + const { preview } = this.props; + if (!preview || this.state.selecting) { + return null; + } + + return ( + <Popup + preview={preview} + editor={this.props.editor} + editorRef={this.props.editorRef} + /> + ); + } +} + +const mapStateToProps = state => { + const thread = getCurrentThread(state); + return { + highlightedCalls: getHighlightedCalls(state, thread), + cx: getThreadContext(state), + preview: getPreview(state), + }; +}; + +export default connect<Props, OwnProps, _, _, _, _>(mapStateToProps, { + clearPreview: actions.clearPreview, + addExpression: actions.addExpression, + updatePreview: actions.updatePreview, + setExceptionPreview: actions.setExceptionPreview, +})(Preview); diff --git a/devtools/client/debugger/src/components/Editor/Preview/moz.build b/devtools/client/debugger/src/components/Editor/Preview/moz.build new file mode 100644 index 0000000000..362faadc42 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "ExceptionPopup.js", + "index.js", + "Popup.js", +) diff --git a/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js new file mode 100644 index 0000000000..5c000ff928 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js @@ -0,0 +1,109 @@ +/* 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/>. */ + +// @flow + +import { + addHighlightToTargetSiblings, + removeHighlightForTargetSiblings, +} from "../Popup"; + +describe("addHighlightToTargetSiblings", () => { + it("should add preview highlight class to related target siblings", async () => { + const div = document.createElement("div"); + const divChildren = ["a", "divided", "token"]; + divChildren.forEach(function(span) { + const child = document.createElement("span"); + const text = document.createTextNode(span); + child.appendChild(text); + child.classList.add("cm-property"); + div.appendChild(child); + }); + + const target = div.children[1]; + const props = { + preview: { + expression: "adividedtoken", + }, + }; + + addHighlightToTargetSiblings(target, props); + + const previous = target.previousElementSibling; + if (previous && previous.className) { + expect(previous.className.includes("preview-token")).toEqual(true); + } + + const next = target.nextElementSibling; + if (next && next.className) { + expect(next.className.includes("preview-token")).toEqual(true); + } + }); + + it("should not add preview highlight class to target's related siblings after non-element nodes", () => { + const div = document.createElement("div"); + + const elementBeforePeriod = document.createElement("span"); + elementBeforePeriod.innerHTML = "object"; + elementBeforePeriod.classList.add("cm-property"); + div.appendChild(elementBeforePeriod); + + const period = document.createTextNode("."); + div.appendChild(period); + + const target = document.createElement("span"); + target.innerHTML = "property"; + target.classList.add("cm-property"); + div.appendChild(target); + + const anotherPeriod = document.createTextNode("."); + div.appendChild(anotherPeriod); + + const elementAfterPeriod = document.createElement("span"); + elementAfterPeriod.innerHTML = "anotherProperty"; + elementAfterPeriod.classList.add("cm-property"); + div.appendChild(elementAfterPeriod); + + const props = { + preview: { + expression: "object.property.anotherproperty", + }, + }; + addHighlightToTargetSiblings(target, props); + + expect(elementBeforePeriod.className.includes("preview-token")).toEqual( + false + ); + expect(elementAfterPeriod.className.includes("preview-token")).toEqual( + false + ); + }); +}); + +describe("removeHighlightForTargetSiblings", () => { + it("should remove preview highlight class from target's related siblings", async () => { + const div = document.createElement("div"); + const divChildren = ["a", "divided", "token"]; + divChildren.forEach(function(span) { + const child = document.createElement("span"); + const text = document.createTextNode(span); + child.appendChild(text); + child.classList.add("preview-token"); + div.appendChild(child); + }); + const target = div.children[1]; + + removeHighlightForTargetSiblings(target); + + const previous = target.previousElementSibling; + if (previous && previous.className) { + expect(previous.className.includes("preview-token")).toEqual(false); + } + + const next = target.nextElementSibling; + if (next && next.className) { + expect(next.className.includes("preview-token")).toEqual(false); + } + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/SearchBar.css b/devtools/client/debugger/src/components/Editor/SearchBar.css new file mode 100644 index 0000000000..f6634abb7e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/SearchBar.css @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.search-bar { + position: relative; + display: flex; + border-top: 1px solid var(--theme-splitter-color); + height: var(--editor-searchbar-height); +} + +/* display a fake outline above the search bar's top border, and above + the source footer's top border */ +.search-bar::before { + content: ""; + position: absolute; + z-index: 10; + top: -1px; + left: 0; + right: 0; + bottom: -1px; + border: solid 1px var(--blue-50); + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease-out; +} + +.search-bar:focus-within::before { + opacity: 1; +} + +.search-bar .search-outline { + flex-grow: 1; + border-width: 0; +} + +.search-bottom-bar * { + user-select: none; +} + +.search-bottom-bar { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + align-items: center; + background-color: var(--theme-toolbar-background); + padding: 0; +} + +.search-bottom-bar .search-modifiers { + display: flex; + align-items: center; +} + +.search-bottom-bar .search-modifiers button { + padding: 2px; + margin: 0 3px; + border: none; + background: none; + width: 20px; + height: 20px; + border-radius: 2px; +} + +.search-bottom-bar .pipe-divider { + flex: none; + align-self: stretch; + width: 1px; + vertical-align: middle; + margin: 4px; + background-color: var(--theme-splitter-color); +} + +.search-bottom-bar .search-modifiers .img { + display: block; +} + +.search-bottom-bar .search-modifiers button:hover { + background-color: var(--theme-toolbar-background-hover); +} + +.search-bottom-bar .search-modifiers button.active .img { + background-color: var(--theme-icon-checked-color); +} + +.search-bottom-bar .search-type-toggles { + display: flex; + align-items: center; + max-width: 68%; +} + +.search-bottom-bar .search-type-name { + margin: 0 4px; + border: none; + background: transparent; + color: var(--theme-comment); +} + +.search-bottom-bar .search-type-toggles .search-type-btn.active { + color: var(--theme-selection-background); +} + +.theme-dark .search-bottom-bar .search-type-toggles .search-type-btn.active { + color: white; +} + +.search-bottom-bar .close-btn { + margin-inline-end: 3px; +} + +.search-bar .result-list { + max-height: 230px; +} diff --git a/devtools/client/debugger/src/components/Editor/SearchBar.js b/devtools/client/debugger/src/components/Editor/SearchBar.js new file mode 100644 index 0000000000..83d3c9a758 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/SearchBar.js @@ -0,0 +1,395 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import PropTypes from "prop-types"; +import React, { Component } from "react"; +import { connect } from "../../utils/connect"; +import { CloseButton } from "../shared/Button"; +import AccessibleImage from "../shared/AccessibleImage"; +import actions from "../../actions"; +import { + getActiveSearch, + getSelectedSource, + getSourceContent, + getFileSearchQuery, + getFileSearchModifiers, + getFileSearchResults, + getContext, +} from "../../selectors"; + +import { removeOverlay } from "../../utils/editor"; + +import { scrollList } from "../../utils/result-list"; +import classnames from "classnames"; + +import type { Source, Context } from "../../types"; +import type { Modifiers, SearchResults } from "../../reducers/file-search"; + +import SearchInput from "../shared/SearchInput"; +import { debounce } from "lodash"; +import "./SearchBar.css"; + +// $FlowIgnore +const { PluralForm } = require("devtools/shared/plural-form"); + +import type SourceEditor from "../../utils/editor/source-editor"; + +function getShortcuts() { + const searchAgainKey = L10N.getStr("sourceSearch.search.again.key3"); + const searchAgainPrevKey = L10N.getStr("sourceSearch.search.againPrev.key3"); + const searchKey = L10N.getStr("sourceSearch.search.key2"); + + return { + shiftSearchAgainShortcut: searchAgainPrevKey, + searchAgainShortcut: searchAgainKey, + searchShortcut: searchKey, + }; +} + +type State = { + query: string, + selectedResultIndex: number, + count: number, + index: number, + inputFocused: boolean, +}; + +type OwnProps = {| + editor: SourceEditor, + showClose?: boolean, + size?: string, +|}; +type Props = { + cx: Context, + editor: SourceEditor, + selectedSource: ?Source, + selectedContentLoaded: boolean, + searchOn: boolean, + searchResults: SearchResults, + modifiers: Modifiers, + query: string, + showClose?: boolean, + size?: string, + toggleFileSearchModifier: typeof actions.toggleFileSearchModifier, + setFileSearchQuery: typeof actions.setFileSearchQuery, + setActiveSearch: typeof actions.setActiveSearch, + closeFileSearch: typeof actions.closeFileSearch, + doSearch: typeof actions.doSearch, + traverseResults: typeof actions.traverseResults, +}; + +class SearchBar extends Component<Props, State> { + constructor(props: Props) { + super(props); + this.state = { + query: props.query, + selectedResultIndex: 0, + count: 0, + index: -1, + inputFocused: false, + }; + } + + componentWillUnmount() { + const { shortcuts } = this.context; + const { + searchShortcut, + searchAgainShortcut, + shiftSearchAgainShortcut, + } = getShortcuts(); + + shortcuts.off(searchShortcut); + shortcuts.off("Escape"); + shortcuts.off(searchAgainShortcut); + shortcuts.off(shiftSearchAgainShortcut); + } + + componentDidMount() { + // overwrite this.doSearch with debounced version to + // reduce frequency of queries + this.doSearch = debounce(this.doSearch, 100); + const { shortcuts } = this.context; + const { + searchShortcut, + searchAgainShortcut, + shiftSearchAgainShortcut, + } = getShortcuts(); + + shortcuts.on(searchShortcut, this.toggleSearch); + shortcuts.on("Escape", this.onEscape); + + shortcuts.on(shiftSearchAgainShortcut, e => this.traverseResults(e, true)); + + shortcuts.on(searchAgainShortcut, e => this.traverseResults(e, false)); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + if (this.refs.resultList && this.refs.resultList.refs) { + scrollList(this.refs.resultList.refs, this.state.selectedResultIndex); + } + } + + onEscape = (e: SyntheticKeyboardEvent<HTMLElement>) => { + this.closeSearch(e); + }; + + clearSearch = () => { + const { editor: ed, query } = this.props; + if (ed) { + const ctx = { ed, cm: ed.codeMirror }; + removeOverlay(ctx, query); + } + }; + + closeSearch = (e: SyntheticKeyboardEvent<HTMLElement>) => { + const { cx, closeFileSearch, editor, searchOn, query } = this.props; + this.clearSearch(); + if (editor && searchOn) { + closeFileSearch(cx, editor); + e.stopPropagation(); + e.preventDefault(); + } + this.setState({ query, inputFocused: false }); + }; + + toggleSearch = (e: SyntheticKeyboardEvent<HTMLElement>) => { + e.stopPropagation(); + e.preventDefault(); + const { editor, searchOn, 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 (!searchOn) { + setActiveSearch("file"); + } + + if (this.props.searchOn && 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 = (query: string) => { + const { cx, selectedSource, selectedContentLoaded } = this.props; + if (!selectedSource || !selectedContentLoaded) { + return; + } + + this.props.doSearch(cx, query, this.props.editor); + }; + + traverseResults = (e: SyntheticEvent<HTMLElement>, rev: boolean) => { + e.stopPropagation(); + e.preventDefault(); + const { editor } = this.props; + + if (!editor) { + return; + } + this.props.traverseResults(this.props.cx, rev, editor); + }; + + // Handlers + + onChange = (e: SyntheticInputEvent<HTMLElement>) => { + this.setState({ query: e.target.value }); + + return this.doSearch(e.target.value); + }; + + onFocus = (e: SyntheticFocusEvent<HTMLElement>) => { + this.setState({ inputFocused: true }); + }; + + onBlur = (e: SyntheticFocusEvent<HTMLElement>) => { + this.setState({ inputFocused: false }); + }; + + onKeyDown = (e: any) => { + if (e.key !== "Enter" && e.key !== "F3") { + return; + } + + this.traverseResults(e, e.shiftKey); + e.preventDefault(); + return this.doSearch(e.target.value); + }; + + onHistoryScroll = (query: string) => { + this.setState({ query }); + this.doSearch(query); + }; + + // Renderers + buildSummaryMsg() { + const { + searchResults: { matchIndex, count, index }, + query, + } = this.props; + + 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); + } + + renderSearchModifiers = () => { + const { cx, modifiers, toggleFileSearchModifier, query } = this.props; + const { doSearch } = this; + + function SearchModBtn({ modVal, className, svgName, tooltip }) { + const preppedClass = classnames(className, { + active: modifiers?.[modVal], + }); + return ( + <button + className={preppedClass} + onMouseDown={() => { + toggleFileSearchModifier(cx, modVal); + doSearch(query); + }} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + toggleFileSearchModifier(cx, modVal); + doSearch(query); + } + }} + title={tooltip} + > + <AccessibleImage className={svgName} /> + </button> + ); + } + + return ( + <div className="search-modifiers"> + <span className="pipe-divider" /> + <span className="search-type-name"> + {L10N.getStr("symbolSearch.searchModifier.modifiersLabel")} + </span> + <SearchModBtn + modVal="regexMatch" + className="regex-match-btn" + svgName="regex-match" + tooltip={L10N.getStr("symbolSearch.searchModifier.regex")} + /> + <SearchModBtn + modVal="caseSensitive" + className="case-sensitive-btn" + svgName="case-match" + tooltip={L10N.getStr("symbolSearch.searchModifier.caseSensitive")} + /> + <SearchModBtn + modVal="wholeWord" + className="whole-word-btn" + svgName="whole-word-match" + tooltip={L10N.getStr("symbolSearch.searchModifier.wholeWord")} + /> + </div> + ); + }; + + shouldShowErrorEmoji() { + const { + query, + searchResults: { count }, + } = this.props; + return !!query && !count; + } + + render() { + const { + searchResults: { count }, + searchOn, + showClose = true, + size = "big", + } = this.props; + + if (!searchOn) { + return <div />; + } + + return ( + <div className="search-bar"> + <SearchInput + query={this.state.query} + count={count} + placeholder={L10N.getStr("sourceSearch.search.placeholder2")} + summaryMsg={this.buildSummaryMsg()} + isLoading={false} + onChange={this.onChange} + onFocus={this.onFocus} + onBlur={this.onBlur} + showErrorEmoji={this.shouldShowErrorEmoji()} + onKeyDown={this.onKeyDown} + onHistoryScroll={this.onHistoryScroll} + handleNext={e => this.traverseResults(e, false)} + handlePrev={e => this.traverseResults(e, true)} + shouldFocus={this.state.inputFocused} + showClose={false} + /> + <div className="search-bottom-bar"> + {this.renderSearchModifiers()} + {showClose && ( + <React.Fragment> + <span className="pipe-divider" /> + <CloseButton handleClick={this.closeSearch} buttonClass={size} /> + </React.Fragment> + )} + </div> + </div> + ); + } +} + +SearchBar.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = (state, p: OwnProps) => { + const selectedSource = getSelectedSource(state); + + return { + cx: getContext(state), + searchOn: getActiveSearch(state) === "file", + selectedSource, + selectedContentLoaded: selectedSource + ? !!getSourceContent(state, selectedSource.id) + : false, + query: getFileSearchQuery(state), + modifiers: getFileSearchModifiers(state), + searchResults: getFileSearchResults(state), + }; +}; + +export default connect<Props, OwnProps, _, _, _, _>(mapStateToProps, { + toggleFileSearchModifier: actions.toggleFileSearchModifier, + setFileSearchQuery: actions.setFileSearchQuery, + setActiveSearch: actions.setActiveSearch, + closeFileSearch: actions.closeFileSearch, + doSearch: actions.doSearch, + traverseResults: actions.traverseResults, +})(SearchBar); 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..e3807d645f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tab.js @@ -0,0 +1,289 @@ +/* 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/>. */ + +// @flow + +import React, { PureComponent } from "react"; +import { connect } from "../../utils/connect"; + +import { showMenu, buildMenu } from "../../context-menu/menu"; + +import SourceIcon from "../shared/SourceIcon"; +import { CloseButton } from "../shared/Button"; +import { copyToTheClipboard } from "../../utils/clipboard"; + +import type { Source, Context } from "../../types"; +import type { TabsSources } from "../../reducers/types"; + +import actions from "../../actions"; + +import { + getDisplayPath, + getFileURL, + getRawSourceURL, + getSourceQueryString, + getTruncatedFileName, + isPretty, + shouldBlackbox, +} from "../../utils/source"; +import { getTabMenuItems } from "../../utils/tabs"; + +import { + getSelectedSource, + getActiveSearch, + getSourcesForTabs, + getHasSiblingOfSameName, + getContext, +} from "../../selectors"; +import type { ActiveSearchType } from "../../selectors"; + +import classnames from "classnames"; + +type OwnProps = {| + source: Source, + onDragOver: Function, + onDragStart: Function, + onDragEnd: Function, +|}; +type Props = { + cx: Context, + tabSources: TabsSources, + selectedSource: ?Source, + source: Source, + onDragOver: Function, + onDragStart: Function, + onDragEnd: Function, + activeSearch: ?ActiveSearchType, + hasSiblingOfSameName: boolean, + selectSource: typeof actions.selectSource, + closeTab: typeof actions.closeTab, + closeTabs: typeof actions.closeTabs, + copyToClipboard: typeof actions.copyToClipboard, + togglePrettyPrint: typeof actions.togglePrettyPrint, + showSource: typeof actions.showSource, + toggleBlackBox: typeof actions.toggleBlackBox, +}; + +class Tab extends PureComponent<Props> { + onTabContextMenu = ( + event: SyntheticClipboardEvent<HTMLDivElement>, + tab: string + ) => { + event.preventDefault(); + this.showContextMenu(event, tab); + }; + + showContextMenu(e: SyntheticClipboardEvent<HTMLDivElement>, tab: string) { + const { + cx, + closeTab, + closeTabs, + copyToClipboard, + tabSources, + showSource, + toggleBlackBox, + togglePrettyPrint, + selectedSource, + source, + } = this.props; + + const tabCount = tabSources.length; + const otherTabs = tabSources.filter(t => t.id !== tab); + const sourceTab = tabSources.find(t => t.id == tab); + const tabURLs = tabSources.map(t => t.url); + const otherTabURLs = otherTabs.map(t => t.url); + + if (!sourceTab || !selectedSource) { + return; + } + + const tabMenuItems = getTabMenuItems(); + const items = [ + { + item: { + ...tabMenuItems.closeTab, + click: () => closeTab(cx, sourceTab), + }, + }, + { + item: { + ...tabMenuItems.closeOtherTabs, + click: () => closeTabs(cx, otherTabURLs), + disabled: otherTabURLs.length === 0, + }, + }, + { + item: { + ...tabMenuItems.closeTabsToEnd, + click: () => { + const tabIndex = tabSources.findIndex(t => t.id == tab); + closeTabs( + cx, + tabURLs.filter((t, i) => i > tabIndex) + ); + }, + disabled: + tabCount === 1 || + tabSources.some((t, i) => t === tab && tabCount - 1 === i), + }, + }, + { + item: { + ...tabMenuItems.closeAllTabs, + click: () => closeTabs(cx, tabURLs), + }, + }, + { item: { type: "separator" } }, + { + item: { + ...tabMenuItems.copySource, + disabled: selectedSource.id !== tab, + click: () => copyToClipboard(sourceTab), + }, + }, + { + item: { + ...tabMenuItems.copySourceUri2, + disabled: !selectedSource.url, + click: () => copyToTheClipboard(getRawSourceURL(sourceTab.url)), + }, + }, + { + item: { + ...tabMenuItems.showSource, + disabled: !selectedSource.url, + click: () => showSource(cx, tab), + }, + }, + { + item: { + ...tabMenuItems.toggleBlackBox, + label: source.isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore") + : L10N.getStr("ignoreContextItem.ignore"), + disabled: !shouldBlackbox(source), + click: () => toggleBlackBox(cx, source), + }, + }, + { + item: { + ...tabMenuItems.prettyPrint, + click: () => togglePrettyPrint(cx, tab), + disabled: isPretty(sourceTab), + }, + }, + ]; + + showMenu(e, buildMenu(items)); + } + + isProjectSearchEnabled() { + return this.props.activeSearch === "project"; + } + + isSourceSearchEnabled() { + return this.props.activeSearch === "source"; + } + + render() { + const { + cx, + selectedSource, + selectSource, + closeTab, + source, + tabSources, + hasSiblingOfSameName, + onDragOver, + onDragStart, + onDragEnd, + } = this.props; + const sourceId = source.id; + const active = + selectedSource && + sourceId == selectedSource.id && + !this.isProjectSearchEnabled() && + !this.isSourceSearchEnabled(); + const isPrettyCode = isPretty(source); + + function onClickClose(e) { + e.stopPropagation(); + closeTab(cx, source); + } + + function handleTabClick(e) { + e.preventDefault(); + e.stopPropagation(); + return selectSource(cx, sourceId); + } + + const className = classnames("source-tab", { + active, + pretty: isPrettyCode, + }); + + const path = getDisplayPath(source, tabSources); + const query = hasSiblingOfSameName ? getSourceQueryString(source) : ""; + + return ( + <div + draggable + onDragOver={onDragOver} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + className={className} + key={sourceId} + onClick={handleTabClick} + // Accommodate middle click to close tab + onMouseUp={e => e.button === 1 && closeTab(cx, source)} + onContextMenu={e => this.onTabContextMenu(e, sourceId)} + title={getFileURL(source, false)} + > + <SourceIcon + source={source} + modifier={icon => + ["file", "javascript"].includes(icon) ? null : icon + } + /> + <div className="filename"> + {getTruncatedFileName(source, query)} + {path && <span>{`../${path}/..`}</span>} + </div> + <CloseButton + handleClick={onClickClose} + tooltip={L10N.getStr("sourceTabs.closeTabButtonTooltip")} + /> + </div> + ); + } +} + +const mapStateToProps = (state, { source }) => { + const selectedSource = getSelectedSource(state); + + return { + cx: getContext(state), + tabSources: getSourcesForTabs(state), + selectedSource, + activeSearch: getActiveSearch(state), + hasSiblingOfSameName: getHasSiblingOfSameName(state, source), + }; +}; + +export default connect<Props, OwnProps, _, _, _, _>( + mapStateToProps, + { + selectSource: actions.selectSource, + copyToClipboard: actions.copyToClipboard, + closeTab: actions.closeTab, + closeTabs: actions.closeTabs, + togglePrettyPrint: actions.togglePrettyPrint, + showSource: actions.showSource, + toggleBlackBox: actions.toggleBlackBox, + }, + null, + { + withRef: true, + } +)(Tab); diff --git a/devtools/client/debugger/src/components/Editor/Tabs.css b/devtools/client/debugger/src/components/Editor/Tabs.css new file mode 100644 index 0000000000..39e9b3fa0e --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tabs.css @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +.source-header { + display: flex; + width: 100%; + height: var(--editor-header-height); + border-bottom: 1px solid var(--theme-splitter-color); + background-color: var(--theme-toolbar-background); +} + +.source-header * { + user-select: none; +} + +.source-header .command-bar { + flex: initial; + flex-shrink: 0; + border-bottom: 0; + border-inline-start: 1px solid var(--theme-splitter-color); +} + +.source-tabs { + flex: auto; + align-self: flex-start; + align-items: flex-start; + /* Reserve space for the overflow button (even if not visible) */ + padding-inline-end: 28px; +} + +.source-tab { + display: inline-flex; + align-items: center; + position: relative; + min-width: 40px; + max-width: 100%; + overflow: hidden; + padding: 4px 10px; + cursor: default; + height: calc(var(--editor-header-height) - 1px); + font-size: 12px; + background-color: transparent; + vertical-align: bottom; +} + +.source-tab::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background-color: var(--tab-line-color, transparent); + transition: transform 250ms var(--animation-curve), + opacity 250ms var(--animation-curve); + opacity: 0; + transform: scaleX(0); +} + +.source-tab.active { + --tab-line-color: var(--tab-line-selected-color); + color: var(--theme-toolbar-selected-color); + border-bottom-color: transparent; +} + +.source-tab:not(.active):hover { + --tab-line-color: var(--tab-line-hover-color); + background-color: var(--theme-toolbar-hover); +} + +.source-tab:hover::before, +.source-tab.active::before { + opacity: 1; + transform: scaleX(1); +} + +.source-tab .img.prettyPrint, +.source-tab .img.blackBox { + mask-size: 14px; + background-color: currentColor; +} + +.source-tab .filename { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-inline-end: 4px; +} + +.source-tab .filename span { + opacity: 0.7; + padding-inline-start: 4px; +} + +.source-tab .close-btn { + visibility: hidden; + margin-inline-end: -6px; +} + +.source-tab.active .close-btn { + color: inherit; +} + +.source-tab.active .close-btn, +.source-tab:hover .close-btn { + visibility: visible; +} + +.source-tab.active .source-icon { + background-color: currentColor; +} + +.source-tab .close-btn:hover, +.source-tab .close-btn:focus { + color: var(--theme-selection-color); + background-color: var(--theme-selection-background); +} diff --git a/devtools/client/debugger/src/components/Editor/Tabs.js b/devtools/client/debugger/src/components/Editor/Tabs.js new file mode 100644 index 0000000000..d6b22c4871 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/Tabs.js @@ -0,0 +1,353 @@ +/* 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/>. */ + +// @flow + +import React, { PureComponent } from "react"; +import ReactDOM from "react-dom"; +import { connect } from "../../utils/connect"; + +import { + getSelectedSource, + getSourcesForTabs, + getIsPaused, + getCurrentThread, + getContext, +} from "../../selectors"; +import { isVisible } from "../../utils/ui"; + +import { getHiddenTabs } from "../../utils/tabs"; +import { getFilename, isPretty, getFileURL } from "../../utils/source"; +import actions from "../../actions"; + +import { debounce } from "lodash"; +import "./Tabs.css"; + +import Tab from "./Tab"; +import { PaneToggleButton } from "../shared/Button"; +import Dropdown from "../shared/Dropdown"; +import AccessibleImage from "../shared/AccessibleImage"; +import CommandBar from "../SecondaryPanes/CommandBar"; + +import type { Source, Context } from "../../types"; +import type { TabsSources } from "../../reducers/types"; + +type OwnProps = {| + horizontal: boolean, + startPanelCollapsed: boolean, + endPanelCollapsed: boolean, +|}; +type Props = { + cx: Context, + tabSources: TabsSources, + selectedSource: ?Source, + horizontal: boolean, + startPanelCollapsed: boolean, + endPanelCollapsed: boolean, + moveTab: typeof actions.moveTab, + moveTabBySourceId: typeof actions.moveTabBySourceId, + closeTab: typeof actions.closeTab, + togglePaneCollapse: typeof actions.togglePaneCollapse, + showSource: typeof actions.showSource, + selectSource: typeof actions.selectSource, + isPaused: boolean, +}; + +type State = { + dropdownShown: boolean, + hiddenTabs: TabsSources, +}; + +function haveTabSourcesChanged( + tabSources: TabsSources, + prevTabSources: TabsSources +): boolean { + 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<Props, State> { + onTabContextMenu: Function; + showContextMenu: Function; + updateHiddenTabs: Function; + toggleSourcesDropdown: Function; + renderDropdownSource: Function; + renderTabs: Function; + renderDropDown: Function; + renderStartPanelToggleButton: Function; + renderEndPanelToggleButton: Function; + onResize: Function; + _draggedSource: ?Source; + _draggedSourceIndex: ?number; + + constructor(props: Props) { + super(props); + this.state = { + dropdownShown: false, + hiddenTabs: [], + }; + + this.onResize = debounce(() => { + this.updateHiddenTabs(); + }); + } + + get draggedSource() { + return this._draggedSource == null + ? { url: null, id: null } + : this._draggedSource; + } + + set draggedSource(source: ?Source) { + this._draggedSource = source; + } + + get draggedSourceIndex() { + return this._draggedSourceIndex == null ? -1 : this._draggedSourceIndex; + } + + set draggedSourceIndex(index: ?number) { + this._draggedSourceIndex = index; + } + + componentDidUpdate(prevProps: Props) { + 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) + ) { + return moveTab(selectedSource.url, 0); + } + + this.setState({ hiddenTabs }); + }; + + toggleSourcesDropdown() { + this.setState(prevState => ({ + dropdownShown: !prevState.dropdownShown, + })); + } + + getIconClass(source: Source) { + if (isPretty(source)) { + return "prettyPrint"; + } + if (source.isBlackBoxed) { + return "blackBox"; + } + return "file"; + } + + renderDropdownSource = (source: Source) => { + const { cx, selectSource } = this.props; + const filename = getFilename(source); + + const onClick = () => selectSource(cx, source.id); + return ( + <li key={source.id} onClick={onClick} title={getFileURL(source, false)}> + <AccessibleImage + className={`dropdown-icon ${this.getIconClass(source)}`} + /> + <span className="dropdown-label">{filename}</span> + </li> + ); + }; + + onTabDragStart = (source: Source, index: number) => { + this.draggedSource = source; + this.draggedSourceIndex = index; + }; + + onTabDragEnd = () => { + this.draggedSource = null; + this.draggedSourceIndex = null; + }; + + onTabDragOver = (e: any, source: Source, hoveredTabIndex: number) => { + const { moveTabBySourceId } = this.props; + if (hoveredTabIndex === this.draggedSourceIndex) { + return; + } + + const tabDOM = ReactDOM.findDOMNode( + this.refs[`tab_${source.id}`].getWrappedInstance() + ); + + /* $FlowIgnore: tabDOM.nodeType will always be of Node.ELEMENT_NODE since it comes from a ref; + however; the return type of findDOMNode is null | Element | Text */ + const tabDOMRect = tabDOM.getBoundingClientRect(); + const { pageX: mouseCursorX } = e; + if ( + /* Case: the mouse cursor moves into the left half of any target tab */ + mouseCursorX - tabDOMRect.left < + tabDOMRect.width / 2 + ) { + // The current tab goes to the left of the target tab + const targetTab = + hoveredTabIndex > this.draggedSourceIndex + ? hoveredTabIndex - 1 + : hoveredTabIndex; + moveTabBySourceId(this.draggedSource.id, targetTab); + this.draggedSourceIndex = targetTab; + } else if ( + /* Case: the mouse cursor moves into the right half of any target tab */ + mouseCursorX - tabDOMRect.left >= + tabDOMRect.width / 2 + ) { + // The current tab goes to the right of the target tab + const targetTab = + hoveredTabIndex < this.draggedSourceIndex + ? hoveredTabIndex + 1 + : hoveredTabIndex; + moveTabBySourceId(this.draggedSource.id, targetTab); + this.draggedSourceIndex = targetTab; + } + }; + + renderTabs() { + const { tabSources } = this.props; + if (!tabSources) { + return; + } + + return ( + <div className="source-tabs" ref="sourceTabs"> + {tabSources.map((source, index) => { + return ( + <Tab + onDragStart={_ => this.onTabDragStart(source, index)} + onDragOver={e => { + this.onTabDragOver(e, source, index); + e.preventDefault(); + }} + onDragEnd={this.onTabDragEnd} + key={index} + source={source} + ref={`tab_${source.id}`} + /> + ); + })} + </div> + ); + } + + renderDropdown() { + const { hiddenTabs } = this.state; + if (!hiddenTabs || hiddenTabs.length == 0) { + return null; + } + + const Panel = <ul>{hiddenTabs.map(this.renderDropdownSource)}</ul>; + const icon = <AccessibleImage className="more-tabs" />; + + return <Dropdown panel={Panel} icon={icon} />; + } + + renderCommandBar() { + const { horizontal, endPanelCollapsed, isPaused } = this.props; + if (!endPanelCollapsed || !isPaused) { + return; + } + + return <CommandBar horizontal={horizontal} />; + } + + renderStartPanelToggleButton() { + return ( + <PaneToggleButton + position="start" + collapsed={this.props.startPanelCollapsed} + handleClick={(this.props.togglePaneCollapse: any)} + /> + ); + } + + renderEndPanelToggleButton() { + const { horizontal, endPanelCollapsed, togglePaneCollapse } = this.props; + if (!horizontal) { + return; + } + + return ( + <PaneToggleButton + position="end" + collapsed={endPanelCollapsed} + handleClick={(togglePaneCollapse: any)} + horizontal={horizontal} + /> + ); + } + + render() { + return ( + <div className="source-header"> + {this.renderStartPanelToggleButton()} + {this.renderTabs()} + {this.renderDropdown()} + {this.renderEndPanelToggleButton()} + {this.renderCommandBar()} + </div> + ); + } +} + +const mapStateToProps = state => ({ + cx: getContext(state), + selectedSource: getSelectedSource(state), + tabSources: getSourcesForTabs(state), + isPaused: getIsPaused(state, getCurrentThread(state)), +}); + +export default connect<Props, OwnProps, _, _, _, _>(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..5b139376f5 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/index.js @@ -0,0 +1,770 @@ +/* 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/>. */ + +// @flow + +import PropTypes from "prop-types"; +import React, { PureComponent } from "react"; +import { bindActionCreators } from "redux"; +import ReactDOM from "react-dom"; +import { connect } from "../../utils/connect"; +import classnames from "classnames"; +import { debounce } from "lodash"; + +import { getLineText } from "./../../utils/source"; +import { features } from "../../utils/prefs"; +import { getIndentation } from "../../utils/indentation"; + +import { showMenu } from "../../context-menu/menu"; +import { + createBreakpointItems, + breakpointItemActions, +} from "./menus/breakpoints"; + +import { continueToHereItem, editorItemActions } from "./menus/editor"; + +import type { BreakpointItemActions } from "./menus/breakpoints"; +import type { EditorItemActions } from "./menus/editor"; + +import { + getActiveSearch, + getSelectedLocation, + getSelectedSourceWithContent, + getConditionalPanelLocation, + getSymbols, + getIsPaused, + getCurrentThread, + getThreadContext, + getSkipPausing, + getInlinePreview, + getEditorWrapping, + getHighlightedCalls, +} from "../../selectors"; + +// Redux actions +import actions from "../../actions"; + +import SearchBar from "./SearchBar"; +import HighlightLines from "./HighlightLines"; +import Preview from "./Preview"; +import Breakpoints from "./Breakpoints"; +import ColumnBreakpoints from "./ColumnBreakpoints"; +import DebugLine from "./DebugLine"; +import HighlightLine from "./HighlightLine"; +import EmptyLines from "./EmptyLines"; +import EditorMenu from "./EditorMenu"; +import ConditionalPanel from "./ConditionalPanel"; +import InlinePreviews from "./InlinePreviews"; +import HighlightCalls from "./HighlightCalls"; +import Exceptions from "./Exceptions"; + +import { + showSourceText, + showLoading, + showErrorMessage, + getEditor, + clearEditor, + getCursorLine, + getCursorColumn, + lineAtHeight, + toSourceLine, + getDocument, + scrollToColumn, + toEditorPosition, + getSourceLocationFromMouseEvent, + hasDocument, + onMouseOver, + startOperation, + endOperation, +} from "../../utils/editor"; + +import { resizeToggleButton, resizeBreakpointGutter } from "../../utils/ui"; +import Services from "devtools-services"; +const { appinfo } = Services; + +const isMacOS = appinfo.OS === "Darwin"; + +function isSecondary(ev) { + return isMacOS && ev.ctrlKey && ev.button === 0; +} + +function isCmd(ev) { + return isMacOS ? ev.metaKey : ev.ctrlKey; +} + +import "./Editor.css"; +import "./Breakpoints.css"; +import "./InlinePreview.css"; + +import type SourceEditor from "../../utils/editor/source-editor"; +import type { SymbolDeclarations } from "../../workers/parser"; +import type { + SourceLocation, + SourceWithContent, + ThreadContext, + HighlightedCalls as highlightedCallsType, +} from "../../types"; + +const cssVars = { + searchbarHeight: "var(--editor-searchbar-height)", +}; + +type OwnProps = {| + startPanelSize: number, + endPanelSize: number, +|}; +export type Props = { + cx: ThreadContext, + selectedLocation: ?SourceLocation, + selectedSource: ?SourceWithContent, + searchOn: boolean, + startPanelSize: number, + endPanelSize: number, + conditionalPanelLocation: SourceLocation, + symbols: SymbolDeclarations, + isPaused: boolean, + skipPausing: boolean, + inlinePreviewEnabled: boolean, + editorWrappingEnabled: boolean, + highlightedCalls: ?highlightedCallsType, + + // Actions + openConditionalPanel: typeof actions.openConditionalPanel, + closeConditionalPanel: typeof actions.closeConditionalPanel, + continueToHere: typeof actions.continueToHere, + addBreakpointAtLine: typeof actions.addBreakpointAtLine, + jumpToMappedLocation: typeof actions.jumpToMappedLocation, + toggleBreakpointAtLine: typeof actions.toggleBreakpointAtLine, + traverseResults: typeof actions.traverseResults, + updateViewport: typeof actions.updateViewport, + updateCursorPosition: typeof actions.updateCursorPosition, + closeTab: typeof actions.closeTab, + breakpointActions: BreakpointItemActions, + editorActions: EditorItemActions, + toggleBlackBox: typeof actions.toggleBlackBox, + highlightCalls: typeof actions.highlightCalls, + unhighlightCalls: typeof actions.unhighlightCalls, +}; + +type State = { + editor: SourceEditor, + contextMenu: ?MouseEvent, +}; + +class Editor extends PureComponent<Props, State> { + $editorWrapper: ?HTMLDivElement; + constructor(props: Props) { + super(props); + + this.state = { + highlightedLineRange: null, + editor: (null: any), + contextMenu: null, + }; + } + + componentWillReceiveProps(nextProps: Props) { + let { editor } = this.state; + + if (!editor && nextProps.selectedSource) { + editor = this.setupEditor(); + } + + startOperation(); + this.setText(nextProps, editor); + this.setSize(nextProps, editor); + 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 + // $FlowIgnore + editor._initShortcuts = () => {}; + + const node = ReactDOM.findDOMNode(this); + if (node instanceof HTMLElement) { + editor.appendToLocalElement(node.querySelector(".editor-mount")); + } + + const { codeMirror } = editor; + const codeMirrorWrapper = codeMirror.getWrapperElement(); + + codeMirror.on("gutterClick", this.onGutterClick); + + if (features.commandClick) { + document.addEventListener("keydown", this.commandKeyDown); + document.addEventListener("keyup", this.commandKeyUp); + } + + // Set code editor wrapper to be focusable + codeMirrorWrapper.tabIndex = 0; + codeMirrorWrapper.addEventListener("keydown", e => this.onKeyDown(e)); + codeMirrorWrapper.addEventListener("click", e => this.onClick(e)); + codeMirrorWrapper.addEventListener("mouseover", onMouseOver(codeMirror)); + + const toggleFoldMarkerVisibility = e => { + if (node instanceof HTMLElement) { + node + .querySelectorAll(".CodeMirror-guttermarker-subtle") + .forEach(elem => { + elem.classList.toggle("visible"); + }); + } + }; + + const codeMirrorGutter = codeMirror.getGutterElement(); + codeMirrorGutter.addEventListener("mouseleave", toggleFoldMarkerVisibility); + codeMirrorGutter.addEventListener("mouseenter", toggleFoldMarkerVisibility); + codeMirrorWrapper.addEventListener("contextmenu", event => + this.openMenu(event) + ); + + codeMirror.on("scroll", this.onEditorScroll); + this.onEditorScroll(); + this.setState({ editor }); + return editor; + } + + componentDidMount() { + const { shortcuts } = this.context; + + shortcuts.on(L10N.getStr("toggleBreakpoint.key"), this.onToggleBreakpoint); + shortcuts.on( + L10N.getStr("toggleCondPanel.breakpoint.key"), + this.onToggleConditionalPanel + ); + shortcuts.on( + L10N.getStr("toggleCondPanel.logPoint.key"), + this.onToggleConditionalPanel + ); + shortcuts.on( + L10N.getStr("sourceTabs.closeTab.key"), + this.onCloseShortcutPress + ); + shortcuts.on("Esc", this.onEscape); + } + + onCloseShortcutPress = (e: KeyboardEvent) => { + const { cx, selectedSource } = this.props; + if (selectedSource) { + e.preventDefault(); + e.stopPropagation(); + this.props.closeTab(cx, selectedSource, "shortcut"); + } + }; + + componentWillUnmount() { + const { editor } = this.state; + if (editor) { + editor.destroy(); + editor.codeMirror.off("scroll", this.onEditorScroll); + this.setState({ editor: (null: any) }); + } + + const { shortcuts } = this.context; + shortcuts.off(L10N.getStr("sourceTabs.closeTab.key")); + shortcuts.off(L10N.getStr("toggleBreakpoint.key")); + shortcuts.off(L10N.getStr("toggleCondPanel.breakpoint.key")); + shortcuts.off(L10N.getStr("toggleCondPanel.logPoint.key")); + } + + getCurrentLine() { + const { codeMirror } = this.state.editor; + const { selectedSource } = this.props; + if (!selectedSource) { + return; + } + + const line = getCursorLine(codeMirror); + return toSourceLine(selectedSource.id, line); + } + + onToggleBreakpoint = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const line = this.getCurrentLine(); + if (typeof line !== "number") { + return; + } + + this.props.toggleBreakpointAtLine(this.props.cx, line); + }; + + onToggleConditionalPanel = (e: KeyboardEvent) => { + 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) { + return; + } + + if (typeof line !== "number") { + return; + } + + return openConditionalPanel( + { + line, + column, + sourceId: selectedSource.id, + }, + false + ); + }; + + onEditorScroll = debounce(this.props.updateViewport, 75); + + commandKeyDown = (e: KeyboardEvent) => { + const { key } = e; + if (this.props.isPaused && key === "Meta") { + const { cx, highlightCalls } = this.props; + highlightCalls(cx); + } + }; + + commandKeyUp = (e: KeyboardEvent) => { + const { key } = e; + if (key === "Meta") { + const { cx, unhighlightCalls } = this.props; + unhighlightCalls(cx); + } + }; + + onKeyDown(e: KeyboardEvent) { + 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: KeyboardEvent) => { + if (!this.state.editor) { + return; + } + + const { codeMirror } = this.state.editor; + if (codeMirror.listSelections().length > 1) { + codeMirror.execCommand("singleSelection"); + e.preventDefault(); + } + }; + + openMenu(event: MouseEvent) { + event.stopPropagation(); + event.preventDefault(); + + const { + cx, + selectedSource, + breakpointActions, + editorActions, + isPaused, + conditionalPanelLocation, + closeConditionalPanel, + } = this.props; + const { editor } = this.state; + if (!selectedSource || !editor) { + return; + } + + // only allow one conditionalPanel location. + if (conditionalPanelLocation) { + closeConditionalPanel(); + } + + const target: Element = (event.target: any); + const { id: sourceId } = selectedSource; + const line = lineAtHeight(editor, sourceId, event); + + if (typeof line != "number") { + return; + } + + const location = { line, column: undefined, sourceId }; + + if (target.classList.contains("CodeMirror-linenumber")) { + const lineText = getLineText( + sourceId, + selectedSource.content, + line + ).trim(); + + return showMenu(event, [ + ...createBreakpointItems(cx, location, breakpointActions, lineText), + { type: "separator" }, + continueToHereItem(cx, location, isPaused, editorActions), + ]); + } + + if (target.getAttribute("id") === "columnmarker") { + return; + } + + this.setState({ contextMenu: event }); + } + + clearContextMenu = () => { + this.setState({ contextMenu: null }); + }; + + onGutterClick = ( + cm: Object, + line: number, + gutter: string, + ev: MouseEvent + ) => { + const { + cx, + selectedSource, + conditionalPanelLocation, + closeConditionalPanel, + addBreakpointAtLine, + continueToHere, + toggleBlackBox, + } = this.props; + + // ignore right clicks in the gutter + if (isSecondary(ev) || ev.button === 2 || !selectedSource) { + return; + } + + // if user clicks gutter to set breakpoint on blackboxed source, un-blackbox the source. + if (selectedSource?.isBlackBoxed) { + toggleBlackBox(cx, selectedSource); + } + + if (conditionalPanelLocation) { + return closeConditionalPanel(); + } + + if (gutter === "CodeMirror-foldgutter") { + return; + } + + const sourceLine = toSourceLine(selectedSource.id, line); + if (typeof sourceLine !== "number") { + return; + } + + if (isCmd(ev)) { + return continueToHere(cx, { + line: sourceLine, + column: undefined, + sourceId: selectedSource.id, + }); + } + + return addBreakpointAtLine(cx, sourceLine, ev.altKey, ev.shiftKey); + }; + + onGutterContextMenu = (event: MouseEvent) => { + return this.openMenu(event); + }; + + onClick(e: MouseEvent) { + const { + cx, + selectedSource, + updateCursorPosition, + jumpToMappedLocation, + } = this.props; + + if (selectedSource) { + const sourceLocation = getSourceLocationFromMouseEvent( + this.state.editor, + selectedSource, + e + ); + + if (e.metaKey && e.altKey) { + jumpToMappedLocation(cx, sourceLocation); + } + + updateCursorPosition(sourceLocation); + } + } + + shouldScrollToLocation(nextProps: Props, editor: SourceEditor) { + const { selectedLocation, selectedSource } = this.props; + if ( + !editor || + !nextProps.selectedSource || + !nextProps.selectedLocation || + !nextProps.selectedLocation.line || + !nextProps.selectedSource.content + ) { + return false; + } + + const isFirstLoad = + (!selectedSource || !selectedSource.content) && + nextProps.selectedSource.content; + const locationChanged = selectedLocation !== nextProps.selectedLocation; + const symbolsChanged = nextProps.symbols != this.props.symbols; + + return isFirstLoad || locationChanged || symbolsChanged; + } + + scrollToLocation(nextProps: Props, editor: SourceEditor) { + const { selectedLocation, selectedSource } = nextProps; + + if (selectedLocation && this.shouldScrollToLocation(nextProps, editor)) { + let { line, column } = toEditorPosition(selectedLocation); + + if (selectedSource && hasDocument(selectedSource.id)) { + const doc = getDocument(selectedSource.id); + const lineText: ?string = doc.getLine(line); + column = Math.max(column, getIndentation(lineText)); + } + + scrollToColumn(editor.codeMirror, line, column); + } + } + + setSize(nextProps: Props, editor: SourceEditor) { + if (!editor) { + return; + } + + if ( + nextProps.startPanelSize !== this.props.startPanelSize || + nextProps.endPanelSize !== this.props.endPanelSize + ) { + editor.codeMirror.setSize(); + } + } + + setText(props: Props, editor: ?SourceEditor) { + const { selectedSource, symbols } = props; + + if (!editor) { + return; + } + + // check if we previously had a selected source + if (!selectedSource) { + return this.clearEditor(); + } + + if (!selectedSource.content) { + return showLoading(editor); + } + + if (selectedSource.content.state === "rejected") { + let { value } = selectedSource.content; + if (typeof value !== "string") { + value = "Unexpected source error"; + } + + return this.showErrorMessage(value); + } + + return showSourceText( + editor, + selectedSource, + selectedSource.content.value, + symbols + ); + } + + clearEditor() { + const { editor } = this.state; + if (!editor) { + return; + } + + clearEditor(editor); + } + + showErrorMessage(msg: string) { + const { editor } = this.state; + if (!editor) { + return; + } + + showErrorMessage(editor, msg); + } + + getInlineEditorStyles() { + const { searchOn } = this.props; + + if (searchOn) { + return { + height: `calc(100% - ${cssVars.searchbarHeight})`, + }; + } + + return { + height: "100%", + }; + } + + renderItems() { + const { + cx, + selectedSource, + conditionalPanelLocation, + isPaused, + inlinePreviewEnabled, + editorWrappingEnabled, + } = this.props; + const { editor, contextMenu } = this.state; + + if (!selectedSource || !editor || !getDocument(selectedSource.id)) { + return null; + } + + return ( + <div> + <HighlightCalls editor={editor} selectedSource={selectedSource} /> + <DebugLine /> + <HighlightLine /> + <EmptyLines editor={editor} /> + <Breakpoints editor={editor} cx={cx} /> + <Preview editor={editor} editorRef={this.$editorWrapper} /> + <HighlightLines editor={editor} /> + <Exceptions /> + { + <EditorMenu + editor={editor} + contextMenu={contextMenu} + clearContextMenu={this.clearContextMenu} + selectedSource={selectedSource} + editorWrappingEnabled={editorWrappingEnabled} + /> + } + {conditionalPanelLocation ? <ConditionalPanel editor={editor} /> : null} + {features.columnBreakpoints ? ( + <ColumnBreakpoints editor={editor} /> + ) : null} + {isPaused && inlinePreviewEnabled ? ( + <InlinePreviews editor={editor} selectedSource={selectedSource} /> + ) : null} + </div> + ); + } + + renderSearchBar() { + const { editor } = this.state; + + if (!this.props.selectedSource) { + return null; + } + + return <SearchBar editor={editor} />; + } + + render() { + const { selectedSource, skipPausing } = this.props; + return ( + <div + className={classnames("editor-wrapper", { + blackboxed: selectedSource?.isBlackBoxed, + "skip-pausing": skipPausing, + })} + ref={c => (this.$editorWrapper = c)} + > + <div + className="editor-mount devtools-monospace" + style={this.getInlineEditorStyles()} + /> + {this.renderSearchBar()} + {this.renderItems()} + </div> + ); + } +} + +Editor.contextTypes = { + shortcuts: PropTypes.object, +}; + +const mapStateToProps = state => { + const selectedSource = getSelectedSourceWithContent(state); + + return { + cx: getThreadContext(state), + selectedLocation: getSelectedLocation(state), + selectedSource, + searchOn: getActiveSearch(state) === "file", + conditionalPanelLocation: getConditionalPanelLocation(state), + symbols: getSymbols(state, selectedSource), + isPaused: getIsPaused(state, getCurrentThread(state)), + skipPausing: getSkipPausing(state), + inlinePreviewEnabled: getInlinePreview(state), + editorWrappingEnabled: getEditorWrapping(state), + highlightedCalls: getHighlightedCalls(state, getCurrentThread(state)), + }; +}; + +const mapDispatchToProps = dispatch => ({ + ...bindActionCreators( + { + openConditionalPanel: actions.openConditionalPanel, + closeConditionalPanel: actions.closeConditionalPanel, + continueToHere: actions.continueToHere, + toggleBreakpointAtLine: actions.toggleBreakpointAtLine, + addBreakpointAtLine: actions.addBreakpointAtLine, + jumpToMappedLocation: actions.jumpToMappedLocation, + traverseResults: actions.traverseResults, + updateViewport: actions.updateViewport, + updateCursorPosition: actions.updateCursorPosition, + closeTab: actions.closeTab, + toggleBlackBox: actions.toggleBlackBox, + highlightCalls: actions.highlightCalls, + unhighlightCalls: actions.unhighlightCalls, + }, + dispatch + ), + breakpointActions: breakpointItemActions(dispatch), + editorActions: editorItemActions(dispatch), +}); + +export default connect<Props, OwnProps, _, _, _, _>( + mapStateToProps, + mapDispatchToProps +)(Editor); diff --git a/devtools/client/debugger/src/components/Editor/menus/breakpoints.js b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js new file mode 100644 index 0000000000..2bb4ddebc6 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js @@ -0,0 +1,305 @@ +/* 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/>. */ + +// @flow + +import actions from "../../../actions"; +import { bindActionCreators } from "redux"; +import type { SourceLocation, Breakpoint, Context } from "../../../types"; +import { features } from "../../../utils/prefs"; +import { formatKeyShortcut } from "../../../utils/text"; + +export const addBreakpointItem = ( + cx: Context, + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-add-breakpoint", + label: L10N.getStr("editor.addBreakpoint"), + accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.addBreakpoint(cx, location), + accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")), +}); + +export const removeBreakpointItem = ( + cx: Context, + breakpoint: Breakpoint, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-remove-breakpoint", + label: L10N.getStr("editor.removeBreakpoint"), + accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.removeBreakpoint(cx, breakpoint), + accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")), +}); + +export const addConditionalBreakpointItem = ( + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-add-conditional-breakpoint", + label: L10N.getStr("editor.addConditionBreakpoint"), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")), + accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location), +}); + +export const editConditionalBreakpointItem = ( + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-edit-conditional-breakpoint", + label: L10N.getStr("editor.editConditionBreakpoint"), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")), + accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location), +}); + +export const conditionalBreakpointItem = ( + breakpoint: Breakpoint, + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => { + const { + options: { condition }, + } = breakpoint; + return condition + ? editConditionalBreakpointItem(location, breakpointActions) + : addConditionalBreakpointItem(location, breakpointActions); +}; + +export const addLogPointItem = ( + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-add-log-point", + label: L10N.getStr("editor.addLogPoint"), + accesskey: L10N.getStr("editor.addLogPoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location, true), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), +}); + +export const editLogPointItem = ( + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-edit-log-point", + label: L10N.getStr("editor.editLogPoint"), + accesskey: L10N.getStr("editor.editLogPoint.accesskey"), + disabled: false, + click: () => breakpointActions.openConditionalPanel(location, true), + accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")), +}); + +export const logPointItem = ( + breakpoint: Breakpoint, + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => { + const { + options: { logValue }, + } = breakpoint; + return logValue + ? editLogPointItem(location, breakpointActions) + : addLogPointItem(location, breakpointActions); +}; + +export const toggleDisabledBreakpointItem = ( + cx: Context, + breakpoint: Breakpoint, + breakpointActions: BreakpointItemActions +) => { + return { + accesskey: L10N.getStr("editor.disableBreakpoint.accesskey"), + disabled: false, + click: () => breakpointActions.toggleDisabledBreakpoint(cx, breakpoint), + ...(breakpoint.disabled + ? { + id: "node-menu-enable-breakpoint", + label: L10N.getStr("editor.enableBreakpoint"), + } + : { + id: "node-menu-disable-breakpoint", + label: L10N.getStr("editor.disableBreakpoint"), + }), + }; +}; + +export const toggleDbgStatementItem = ( + cx: Context, + location: SourceLocation, + breakpointActions: BreakpointItemActions, + breakpoint: ?Breakpoint +) => { + if (breakpoint && breakpoint.options.condition === "false") { + return { + disabled: false, + id: "node-menu-enable-dbgStatement", + label: L10N.getStr("breakpointMenuItem.enabledbg.label"), + click: () => + breakpointActions.setBreakpointOptions(cx, location, { + ...(breakpoint: any).options, + condition: null, + }), + }; + } + + return { + disabled: false, + id: "node-menu-disable-dbgStatement", + label: L10N.getStr("breakpointMenuItem.disabledbg.label"), + click: () => + breakpointActions.setBreakpointOptions(cx, location, { + condition: "false", + }), + }; +}; + +export function breakpointItems( + cx: Context, + breakpoint: Breakpoint, + selectedLocation: SourceLocation, + breakpointActions: BreakpointItemActions +) { + const items = [ + removeBreakpointItem(cx, breakpoint, breakpointActions), + toggleDisabledBreakpointItem(cx, breakpoint, breakpointActions), + ]; + + if (breakpoint.originalText.startsWith("debugger")) { + items.push( + { type: "separator" }, + toggleDbgStatementItem( + cx, + selectedLocation, + breakpointActions, + breakpoint + ) + ); + } + + items.push( + { type: "separator" }, + removeBreakpointsOnLineItem(cx, selectedLocation, breakpointActions), + breakpoint.disabled + ? enableBreakpointsOnLineItem(cx, selectedLocation, breakpointActions) + : disableBreakpointsOnLineItem(cx, selectedLocation, breakpointActions), + { type: "separator" } + ); + + items.push( + conditionalBreakpointItem(breakpoint, selectedLocation, breakpointActions) + ); + items.push(logPointItem(breakpoint, selectedLocation, breakpointActions)); + + return items; +} + +export function createBreakpointItems( + cx: Context, + location: SourceLocation, + breakpointActions: BreakpointItemActions, + lineText: ?String +) { + const items = [ + addBreakpointItem(cx, location, breakpointActions), + addConditionalBreakpointItem(location, breakpointActions), + ]; + + if (features.logPoints) { + items.push(addLogPointItem(location, breakpointActions)); + } + + if (lineText && lineText.startsWith("debugger")) { + items.push(toggleDbgStatementItem(cx, location, breakpointActions)); + } + return items; +} + +// ToDo: Only enable if there are more than one breakpoints on a line? +export const removeBreakpointsOnLineItem = ( + cx: Context, + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.removeAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.removeAllAtLine.accesskey"), + disabled: false, + click: () => + breakpointActions.removeBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export const enableBreakpointsOnLineItem = ( + cx: Context, + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.enableAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.enableAllAtLine.accesskey"), + disabled: false, + click: () => + breakpointActions.enableBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export const disableBreakpointsOnLineItem = ( + cx: Context, + location: SourceLocation, + breakpointActions: BreakpointItemActions +) => ({ + id: "node-menu-remove-breakpoints-on-line", + label: L10N.getStr("breakpointMenuItem.disableAllAtLine.label"), + accesskey: L10N.getStr("breakpointMenuItem.disableAllAtLine.accesskey"), + disabled: false, + click: () => + breakpointActions.disableBreakpointsAtLine( + cx, + location.sourceId, + location.line + ), +}); + +export type BreakpointItemActions = { + addBreakpoint: typeof actions.addBreakpoint, + removeBreakpoint: typeof actions.removeBreakpoint, + removeBreakpointsAtLine: typeof actions.removeBreakpointsAtLine, + enableBreakpointsAtLine: typeof actions.enableBreakpointsAtLine, + disableBreakpointsAtLine: typeof actions.disableBreakpointsAtLine, + toggleDisabledBreakpoint: typeof actions.toggleDisabledBreakpoint, + toggleBreakpointsAtLine: typeof actions.toggleBreakpointsAtLine, + setBreakpointOptions: typeof actions.setBreakpointOptions, + openConditionalPanel: typeof actions.openConditionalPanel, +}; + +export function breakpointItemActions(dispatch: Function) { + return bindActionCreators( + { + addBreakpoint: actions.addBreakpoint, + removeBreakpoint: actions.removeBreakpoint, + removeBreakpointsAtLine: actions.removeBreakpointsAtLine, + enableBreakpointsAtLine: actions.enableBreakpointsAtLine, + disableBreakpointsAtLine: actions.disableBreakpointsAtLine, + disableBreakpoint: actions.disableBreakpoint, + toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint, + toggleBreakpointsAtLine: actions.toggleBreakpointsAtLine, + setBreakpointOptions: actions.setBreakpointOptions, + openConditionalPanel: actions.openConditionalPanel, + }, + dispatch + ); +} diff --git a/devtools/client/debugger/src/components/Editor/menus/editor.js b/devtools/client/debugger/src/components/Editor/menus/editor.js new file mode 100644 index 0000000000..a780a3c4f2 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/editor.js @@ -0,0 +1,276 @@ +/* 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/>. */ + +// @flow + +import { bindActionCreators } from "redux"; + +import { copyToTheClipboard } from "../../../utils/clipboard"; +import { + getRawSourceURL, + getFilename, + shouldBlackbox, +} from "../../../utils/source"; + +import { downloadFile } from "../../../utils/utils"; +import { features } from "../../../utils/prefs"; + +import { isFulfilled } from "../../../utils/async-value"; +import actions from "../../../actions"; + +import type { + Source, + SourceLocation, + SourceContent, + SourceWithContent, + Context, + ThreadContext, +} from "../../../types"; + +// Menu Items +export const continueToHereItem = ( + cx: ThreadContext, + location: SourceLocation, + isPaused: boolean, + editorActions: EditorItemActions +) => ({ + accesskey: L10N.getStr("editor.continueToHere.accesskey"), + disabled: !isPaused, + click: () => editorActions.continueToHere(cx, location), + id: "node-menu-continue-to-here", + label: L10N.getStr("editor.continueToHere.label"), +}); + +const copyToClipboardItem = ( + selectionText: string, + editorActions: EditorItemActions +) => ({ + id: "node-menu-copy-to-clipboard", + label: L10N.getStr("copyToClipboard.label"), + accesskey: L10N.getStr("copyToClipboard.accesskey"), + disabled: selectionText.length === 0, + click: () => copyToTheClipboard(selectionText), +}); + +const copySourceItem = ( + selectedContent: SourceContent, + editorActions: EditorItemActions +) => ({ + id: "node-menu-copy-source", + label: L10N.getStr("copySource.label"), + accesskey: L10N.getStr("copySource.accesskey"), + disabled: false, + click: () => + selectedContent.type === "text" && + copyToTheClipboard(selectedContent.value), +}); + +const copySourceUri2Item = ( + selectedSource: Source, + editorActions: EditorItemActions +) => ({ + id: "node-menu-copy-source-url", + label: L10N.getStr("copySourceUri2"), + accesskey: L10N.getStr("copySourceUri2.accesskey"), + disabled: !selectedSource.url, + click: () => copyToTheClipboard(getRawSourceURL(selectedSource.url)), +}); + +const jumpToMappedLocationItem = ( + cx: Context, + selectedSource: Source, + location: SourceLocation, + hasMappedLocation: boolean, + editorActions: EditorItemActions +) => ({ + id: "node-menu-jump", + label: L10N.getFormatStr( + "editor.jumpToMappedLocation1", + selectedSource.isOriginal + ? L10N.getStr("generated") + : L10N.getStr("original") + ), + accesskey: L10N.getStr("editor.jumpToMappedLocation1.accesskey"), + disabled: !hasMappedLocation, + click: () => editorActions.jumpToMappedLocation(cx, location), +}); + +const showSourceMenuItem = ( + cx: Context, + selectedSource: Source, + editorActions: EditorItemActions +) => ({ + id: "node-menu-show-source", + label: L10N.getStr("sourceTabs.revealInTree"), + accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"), + disabled: !selectedSource.url, + click: () => editorActions.showSource(cx, selectedSource.id), +}); + +const blackBoxMenuItem = ( + cx: Context, + selectedSource: Source, + editorActions: EditorItemActions +) => ({ + id: "node-menu-blackbox", + label: selectedSource.isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore") + : L10N.getStr("ignoreContextItem.ignore"), + accesskey: selectedSource.isBlackBoxed + ? L10N.getStr("ignoreContextItem.unignore.accesskey") + : L10N.getStr("ignoreContextItem.ignore.accesskey"), + disabled: !shouldBlackbox(selectedSource), + click: () => editorActions.toggleBlackBox(cx, selectedSource), +}); + +const watchExpressionItem = ( + cx: ThreadContext, + selectedSource: Source, + selectionText: string, + editorActions: EditorItemActions +) => ({ + id: "node-menu-add-watch-expression", + label: L10N.getStr("expressions.label"), + accesskey: L10N.getStr("expressions.accesskey"), + click: () => editorActions.addExpression(cx, selectionText), +}); + +const evaluateInConsoleItem = ( + selectedSource: Source, + selectionText: string, + editorActions: EditorItemActions +) => ({ + id: "node-menu-evaluate-in-console", + label: L10N.getStr("evaluateInConsole.label"), + click: () => editorActions.evaluateInConsole(selectionText), +}); + +const downloadFileItem = ( + selectedSource: Source, + selectedContent: SourceContent, + editorActions: EditorItemActions +) => ({ + id: "node-menu-download-file", + label: L10N.getStr("downloadFile.label"), + accesskey: L10N.getStr("downloadFile.accesskey"), + click: () => downloadFile(selectedContent, getFilename(selectedSource)), +}); + +const inlinePreviewItem = (editorActions: EditorItemActions) => ({ + id: "node-menu-inline-preview", + label: features.inlinePreview + ? L10N.getStr("inlinePreview.hide.label") + : L10N.getStr("inlinePreview.show.label"), + click: () => editorActions.toggleInlinePreview(!features.inlinePreview), +}); + +const editorWrappingItem = ( + editorActions: EditorItemActions, + editorWrappingEnabled: boolean +) => ({ + id: "node-menu-editor-wrapping", + label: editorWrappingEnabled + ? L10N.getStr("editorWrapping.hide.label") + : L10N.getStr("editorWrapping.show.label"), + click: () => editorActions.toggleEditorWrapping(!editorWrappingEnabled), +}); + +export function editorMenuItems({ + cx, + editorActions, + selectedSource, + location, + selectionText, + hasMappedLocation, + isTextSelected, + isPaused, + editorWrappingEnabled, +}: { + cx: ThreadContext, + editorActions: EditorItemActions, + selectedSource: SourceWithContent, + location: SourceLocation, + selectionText: string, + hasMappedLocation: boolean, + isTextSelected: boolean, + isPaused: boolean, + editorWrappingEnabled: boolean, +}) { + const items = []; + + const content = + selectedSource.content && isFulfilled(selectedSource.content) + ? selectedSource.content.value + : null; + + items.push( + jumpToMappedLocationItem( + cx, + selectedSource, + location, + hasMappedLocation, + editorActions + ), + continueToHereItem(cx, location, isPaused, editorActions), + { type: "separator" }, + copyToClipboardItem(selectionText, editorActions), + ...(!selectedSource.isWasm + ? [ + ...(content ? [copySourceItem(content, editorActions)] : []), + copySourceUri2Item(selectedSource, editorActions), + ] + : []), + ...(content + ? [downloadFileItem(selectedSource, content, editorActions)] + : []), + { type: "separator" }, + showSourceMenuItem(cx, selectedSource, editorActions), + blackBoxMenuItem(cx, selectedSource, editorActions) + ); + + if (isTextSelected) { + items.push( + { type: "separator" }, + watchExpressionItem(cx, selectedSource, selectionText, editorActions), + evaluateInConsoleItem(selectedSource, selectionText, editorActions) + ); + } + + items.push( + { type: "separator" }, + inlinePreviewItem(editorActions), + editorWrappingItem(editorActions, editorWrappingEnabled) + ); + + return items; +} + +export type EditorItemActions = { + addExpression: typeof actions.addExpression, + continueToHere: typeof actions.continueToHere, + evaluateInConsole: typeof actions.evaluateInConsole, + flashLineRange: typeof actions.flashLineRange, + jumpToMappedLocation: typeof actions.jumpToMappedLocation, + showSource: typeof actions.showSource, + toggleBlackBox: typeof actions.toggleBlackBox, + toggleInlinePreview: typeof actions.toggleInlinePreview, + toggleEditorWrapping: typeof actions.toggleEditorWrapping, +}; + +export function editorItemActions(dispatch: Function) { + return bindActionCreators( + { + addExpression: actions.addExpression, + continueToHere: actions.continueToHere, + evaluateInConsole: actions.evaluateInConsole, + flashLineRange: actions.flashLineRange, + jumpToMappedLocation: actions.jumpToMappedLocation, + showSource: actions.showSource, + toggleBlackBox: actions.toggleBlackBox, + toggleInlinePreview: actions.toggleInlinePreview, + toggleEditorWrapping: actions.toggleEditorWrapping, + }, + dispatch + ); +} diff --git a/devtools/client/debugger/src/components/Editor/menus/moz.build b/devtools/client/debugger/src/components/Editor/menus/moz.build new file mode 100644 index 0000000000..18009aa2db --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [] + +CompiledModules( + "breakpoints.js", + "editor.js", + "source.js", +) diff --git a/devtools/client/debugger/src/components/Editor/menus/source.js b/devtools/client/debugger/src/components/Editor/menus/source.js new file mode 100644 index 0000000000..14cb69170c --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/menus/source.js @@ -0,0 +1,5 @@ +/* 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/>. */ + +// @flow 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..7b9437386f --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/moz.build @@ -0,0 +1,33 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "menus", + "Preview", +] + +CompiledModules( + "Breakpoint.js", + "Breakpoints.js", + "ColumnBreakpoint.js", + "ColumnBreakpoints.js", + "ConditionalPanel.js", + "DebugLine.js", + "EditorMenu.js", + "EmptyLines.js", + "Exception.js", + "Exceptions.js", + "Footer.js", + "HighlightCalls.js", + "HighlightLine.js", + "HighlightLines.js", + "index.js", + "InlinePreview.js", + "InlinePreviewRow.js", + "InlinePreviews.js", + "SearchBar.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..c1e3424c30 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import React from "react"; +import { shallow } from "enzyme"; +import Breakpoints from "../Breakpoints"; +import * as I from "immutable"; + +// $FlowIgnore +const BreakpointsComponent = Breakpoints.WrappedComponent; + +function generateDefaults(overrides): any { + const sourceId = "server1.conn1.child1/source1"; + const matchingBreakpoints = { id1: { location: { sourceId } } }; + + return { + selectedSource: { sourceId, get: () => false }, + editor: { + codeMirror: { + setGutterMarker: jest.fn(), + }, + }, + breakpoints: I.Map(matchingBreakpoints), + ...overrides, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const component = shallow(<BreakpointsComponent {...props} />); + return { component, props }; +} + +describe("Breakpoints Component", () => { + it("should render breakpoints without columns", async () => { + const sourceId = "server1.conn1.child1/source1"; + const breakpoints = [{ location: { 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, 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..953454814a --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js @@ -0,0 +1,92 @@ +/* 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/>. */ + +// @flow +import React from "react"; +import { mount } from "enzyme"; +import { ConditionalPanel } from "../ConditionalPanel"; +import * as mocks from "../../../utils/test-mockup"; + +const source = mocks.makeMockSource(); + +function generateDefaults( + overrides: Object, + log: boolean, + line: number, + column: number, + condition: ?string, + logValue: ?string +) { + 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: boolean, + line: number, + column: number, + condition: ?string, + logValue: ?string, + overrides = {} +) { + const defaults = generateDefaults( + overrides, + log, + line, + column, + condition, + logValue + ); + const props = { ...defaults, ...overrides }; + const wrapper = mount(<ConditionalPanel {...props} />); + return { wrapper, props }; +} + +describe("ConditionalPanel", () => { + it("it should render at location of selected breakpoint", () => { + const { wrapper } = render(false, 2, 2); + expect(wrapper).toMatchSnapshot(); + }); + it("it should render with condition at selected breakpoint location", () => { + const { wrapper } = render(false, 3, 3, "I'm a condition", "not a log"); + expect(wrapper).toMatchSnapshot(); + }); + it("it should render with logpoint at selected breakpoint location", () => { + const { wrapper } = render(true, 4, 4, "not a condition", "I'm a log"); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js new file mode 100644 index 0000000000..a8c842f084 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js @@ -0,0 +1,162 @@ +/* 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/>. */ + +// @flow + +import React from "react"; +import { shallow } from "enzyme"; + +import DebugLine from "../DebugLine"; + +import type { SourceWithContent } from "../../../types"; +import * as asyncValue from "../../../utils/async-value"; +import { createSourceObject } from "../../../utils/test-head"; +import { setDocument, toEditorLine } 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, + source: ({ + ...createSourceObject("foo"), + content: null, + }: SourceWithContent), + ...overrides, + }; +} + +function createLocation(line) { + return { + 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(props.source.id, doc); + + // $FlowIgnore + const component = shallow(<DebugLine.WrappedComponent {...props} />, { + lifecycleExperimental: true, + }); + return { component, props, clear, editor, doc }; +} + +describe("DebugLine Component", () => { + describe("pausing at the first location", () => { + it("should show a new debug line", async () => { + const { component, props, doc } = render({ + source: { + ...createSourceObject("foo"), + content: asyncValue.fulfilled({ + type: "text", + value: "", + contentType: undefined, + }), + }, + }); + const line = 2; + const location = createLocation(line); + + component.setProps({ ...props, location }); + + expect(doc.removeLineClass.mock.calls).toEqual([]); + expect(doc.addLineClass.mock.calls).toEqual([ + [toEditorLine("foo", line), "wrapClass", "new-debug-line"], + ]); + }); + + describe("pausing at a new location", () => { + it("should replace the first debug line", async () => { + const { props, component, clear, doc } = render({ + source: { + ...createSourceObject("foo"), + content: asyncValue.fulfilled({ + type: "text", + value: "", + contentType: undefined, + }), + }, + }); + + component.instance().debugExpression = { clear: jest.fn() }; + const firstLine = 2; + const secondLine = 2; + + component.setProps({ ...props, location: createLocation(firstLine) }); + component.setProps({ + ...props, + frame: createLocation(secondLine), + }); + + expect(doc.removeLineClass.mock.calls).toEqual([ + [toEditorLine("foo", firstLine), "wrapClass", "new-debug-line"], + ]); + + expect(doc.addLineClass.mock.calls).toEqual([ + [toEditorLine("foo", firstLine), "wrapClass", "new-debug-line"], + [toEditorLine("foo", secondLine), "wrapClass", "new-debug-line"], + ]); + + expect(doc.markText.mock.calls).toEqual([ + [ + { ch: 2, line: toEditorLine("foo", firstLine) }, + { ch: null, line: toEditorLine("foo", firstLine) }, + { className: "debug-expression to-line-end" }, + ], + [ + { ch: 2, line: toEditorLine("foo", secondLine) }, + { ch: null, line: toEditorLine("foo", secondLine) }, + { className: "debug-expression to-line-end" }, + ], + ]); + + expect(clear.mock.calls).toEqual([[]]); + }); + }); + + 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/Editor.spec.js b/devtools/client/debugger/src/components/Editor/tests/Editor.spec.js new file mode 100644 index 0000000000..38c8cbea89 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/Editor.spec.js @@ -0,0 +1,330 @@ +/* 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/>. */ + +// @flow + +import React from "react"; +import { shallow } from "enzyme"; +import Editor from "../index"; +import type { Source, SourceWithContent, SourceBase } from "../../../types"; +import { getDocument } from "../../../utils/editor/source-documents"; +import * as asyncValue from "../../../utils/async-value"; + +function generateDefaults(overrides) { + return { + toggleBreakpoint: jest.fn(), + updateViewport: jest.fn(), + toggleDisabledBreakpoint: jest.fn(), + ...overrides, + }; +} + +function createMockEditor() { + return { + codeMirror: { + doc: {}, + getOption: jest.fn(), + setOption: jest.fn(), + scrollTo: jest.fn(), + charCoords: ({ line, ch }) => ({ top: line, left: ch }), + getScrollerElement: () => ({ offsetWidth: 0, offsetHeight: 0 }), + getScrollInfo: () => ({ + top: 0, + left: 0, + clientWidth: 0, + clientHeight: 0, + }), + defaultCharWidth: () => 0, + defaultTextHeight: () => 0, + display: { gutters: { querySelector: jest.fn() } }, + }, + setText: jest.fn(), + on: jest.fn(), + off: jest.fn(), + createDocument: () => { + let val; + return { + getLine: line => "", + getValue: () => val, + setValue: newVal => (val = newVal), + }; + }, + replaceDocument: jest.fn(), + setMode: jest.fn(), + }; +} + +function createMockSourceWithContent( + overrides: $Shape< + Source & { + loadedState: "loaded" | "loading" | "unloaded", + text: string, + contentType: ?string, + error: string, + isWasm: boolean, + } + > +): SourceWithContent { + const { + loadedState = "loaded", + text = "the text", + contentType = undefined, + error = undefined, + ...otherOverrides + } = overrides; + + const source: SourceBase = ({ + id: "foo", + url: "foo", + ...otherOverrides, + }: any); + let content = null; + if (loadedState === "loaded") { + if (typeof text !== "string") { + throw new Error("Cannot create a non-text source"); + } + + content = error + ? asyncValue.rejected(error) + : asyncValue.fulfilled({ + type: "text", + value: text, + contentType: contentType || undefined, + }); + } + + return { + ...source, + content, + }; +} + +function render(overrides = {}) { + const props = generateDefaults(overrides); + const mockEditor = createMockEditor(); + + // $FlowIgnore + const component = shallow(<Editor.WrappedComponent {...props} />, { + context: { + shortcuts: { on: jest.fn() }, + }, + disableLifecycleMethods: true, + }); + + return { component, props, mockEditor }; +} + +describe("Editor", () => { + describe("When empty", () => { + it("should render", async () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + }); + + describe("When loading initial source", () => { + it("should show a loading message", async () => { + const { component, mockEditor } = render(); + await component.setState({ editor: mockEditor }); + component.setProps({ + selectedSource: { + source: { loadedState: "loading" }, + content: null, + }, + }); + + expect(mockEditor.replaceDocument.mock.calls[0][0].getValue()).toBe( + "Loading…" + ); + expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([]); + }); + }); + + describe("When loaded", () => { + it("should show text", async () => { + const { component, mockEditor, props } = render({}); + + await component.setState({ editor: mockEditor }); + await component.setProps({ + ...props, + selectedSource: createMockSourceWithContent({ + loadedState: "loaded", + }), + selectedLocation: { sourceId: "foo", line: 3, column: 1 }, + }); + + expect(mockEditor.setText.mock.calls).toEqual([["the text"]]); + expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([[1, 2]]); + }); + }); + + describe("When error", () => { + it("should show error text", async () => { + const { component, mockEditor, props } = render({}); + + await component.setState({ editor: mockEditor }); + await component.setProps({ + ...props, + selectedSource: createMockSourceWithContent({ + loadedState: "loaded", + text: undefined, + error: "error text", + }), + selectedLocation: { sourceId: "bad-foo", line: 3, column: 1 }, + }); + + expect(mockEditor.setText.mock.calls).toEqual([ + ["Error loading this URI: error text"], + ]); + }); + + it("should show wasm error", async () => { + const { component, mockEditor, props } = render({}); + + await component.setState({ editor: mockEditor }); + await component.setProps({ + ...props, + selectedSource: createMockSourceWithContent({ + loadedState: "loaded", + isWasm: true, + text: undefined, + error: "blah WebAssembly binary source is not available blah", + }), + selectedLocation: { sourceId: "bad-foo", line: 3, column: 1 }, + }); + + expect(mockEditor.setText.mock.calls).toEqual([ + ["Please refresh to debug this module"], + ]); + }); + }); + + describe("When navigating to a loading source", () => { + it("should show loading message and not scroll", async () => { + const { component, mockEditor, props } = render({}); + + await component.setState({ editor: mockEditor }); + await component.setProps({ + ...props, + selectedSource: createMockSourceWithContent({ + loadedState: "loaded", + }), + selectedLocation: { sourceId: "foo", line: 3, column: 1 }, + }); + + // navigate to a new source that is still loading + await component.setProps({ + ...props, + selectedSource: createMockSourceWithContent({ + id: "bar", + loadedState: "loading", + }), + selectedLocation: { sourceId: "bar", line: 1, column: 1 }, + }); + + expect(mockEditor.replaceDocument.mock.calls[1][0].getValue()).toBe( + "Loading…" + ); + + expect(mockEditor.setText.mock.calls).toEqual([["the text"]]); + + expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([[1, 2]]); + }); + + it("should set the mode when symbols load", async () => { + const { component, mockEditor, props } = render({}); + + await component.setState({ editor: mockEditor }); + + const selectedSource = createMockSourceWithContent({ + loadedState: "loaded", + contentType: "javascript", + }); + + await component.setProps({ ...props, selectedSource }); + + const symbols = { hasJsx: true }; + await component.setProps({ + ...props, + selectedSource, + symbols, + }); + + expect(mockEditor.setMode.mock.calls).toEqual([ + [{ name: "javascript" }], + [{ name: "jsx" }], + ]); + }); + + it("should not re-set the mode when the location changes", async () => { + const { component, mockEditor, props } = render({}); + + await component.setState({ editor: mockEditor }); + + const selectedSource = createMockSourceWithContent({ + loadedState: "loaded", + contentType: "javascript", + }); + + await component.setProps({ ...props, selectedSource }); + + // symbols are parsed + const symbols = { hasJsx: true }; + await component.setProps({ + ...props, + selectedSource, + symbols, + }); + + // selectedLocation changes e.g. pausing/stepping + mockEditor.codeMirror.doc = getDocument(selectedSource.id); + mockEditor.codeMirror.getOption = () => ({ name: "jsx" }); + const selectedLocation = { sourceId: "foo", line: 4, column: 1 }; + + await component.setProps({ + ...props, + selectedSource, + symbols, + selectedLocation, + }); + + expect(mockEditor.setMode.mock.calls).toEqual([ + [{ name: "javascript" }], + [{ name: "jsx" }], + ]); + }); + }); + + describe("When navigating to a loaded source", () => { + it("should show text and then scroll", async () => { + const { component, mockEditor, props } = render({}); + + await component.setState({ editor: mockEditor }); + await component.setProps({ + ...props, + selectedSource: createMockSourceWithContent({ + loadedState: "loading", + }), + selectedLocation: { sourceId: "foo", line: 1, column: 1 }, + }); + + // navigate to a new source that is still loading + await component.setProps({ + ...props, + selectedSource: createMockSourceWithContent({ + loadedState: "loaded", + }), + selectedLocation: { sourceId: "foo", line: 1, column: 1 }, + }); + + expect(mockEditor.replaceDocument.mock.calls[0][0].getValue()).toBe( + "Loading…" + ); + + expect(mockEditor.setText.mock.calls).toEqual([["the text"]]); + + expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([[1, 0]]); + }); + }); +}); 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..a5deea97a1 --- /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/>. */ + +// @flow + +import React from "react"; +import { shallow } from "enzyme"; + +import SourceFooter from "../Footer"; +import { createSourceObject } from "../../../utils/test-head"; +import { setDocument } from "../../../utils/editor"; + +function createMockDocument(clear, position) { + const doc = { + getCursor: jest.fn(() => position), + }; + return doc; +} + +function generateDefaults(overrides) { + return { + editor: { + codeMirror: { + doc: {}, + cursorActivity: jest.fn(), + on: jest.fn(), + }, + }, + endPanelCollapsed: false, + selectedSource: { + ...createSourceObject("foo"), + content: null, + }, + ...overrides, + }; +} + +function render(overrides = {}, position = { line: 0, column: 0 }) { + const clear = jest.fn(); + const props = generateDefaults(overrides); + + const doc = createMockDocument(clear, position); + setDocument(props.selectedSource.id, doc); + + // $FlowIgnore + const component = shallow(<SourceFooter.WrappedComponent {...props} />, { + lifecycleExperimental: true, + }); + return { component, props, clear, doc }; +} + +describe("SourceFooter Component", () => { + describe("default case", () => { + it("should render", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); + }); + + describe("move cursor", () => { + it("should render new cursor position", () => { + const { component } = render(); + component.setState({ cursorPosition: { line: 5, column: 10 } }); + + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/devtools/client/debugger/src/components/Editor/tests/SearchBar.spec.js b/devtools/client/debugger/src/components/Editor/tests/SearchBar.spec.js new file mode 100644 index 0000000000..80dfc58c54 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/SearchBar.spec.js @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +// @flow + +import React from "react"; +import { shallow } from "enzyme"; +import SearchBar from "../SearchBar"; +import "../../../workers/search"; +import "../../../utils/editor"; + +// $FlowIgnore +const SearchBarComponent = SearchBar.WrappedComponent; + +jest.mock("../../../workers/search", () => ({ + getMatches: () => Promise.resolve(["result"]), +})); + +jest.mock("../../../utils/editor", () => ({ + find: () => ({ ch: "1", line: "1" }), +})); + +function generateDefaults(): any { + return { + query: "", + searchOn: true, + symbolSearchOn: true, + editor: {}, + searchResults: {}, + selectedSymbolType: "functions", + selectedSource: { + text: " text text query text", + }, + selectedContentLoaded: true, + setFileSearchQuery: msg => msg, + symbolSearchResults: [], + modifiers: { + get: jest.fn(), + toJS: () => ({ + caseSensitive: true, + wholeWord: false, + regexMatch: false, + }), + }, + selectedResultIndex: 0, + updateSearchResults: jest.fn(), + doSearch: jest.fn(), + }; +} + +function render(overrides = {}) { + const defaults = generateDefaults(); + const props = { ...defaults, ...overrides }; + const component = shallow(<SearchBarComponent {...props} />, { + disableLifecycleMethods: true, + }); + return { component, props }; +} + +describe("SearchBar", () => { + it("should render", () => { + const { component } = render(); + expect(component).toMatchSnapshot(); + }); +}); + +describe("doSearch", () => { + it("should complete a search", async () => { + const { component, props } = render(); + component + .find("SearchInput") + .simulate("change", { target: { value: "query" } }); + + const doSearchArgs = props.doSearch.mock.calls[0][1]; + expect(doSearchArgs).toMatchSnapshot(); + }); +}); + +describe("showErrorEmoji", () => { + it("true if query + no results", () => { + const { component } = render({ + query: "test", + searchResults: { + count: 0, + }, + }); + expect(component).toMatchSnapshot(); + }); + + it("false if no query + no results", () => { + const { component } = render({ + query: "", + searchResults: { + count: 0, + }, + }); + expect(component).toMatchSnapshot(); + }); + + it("false if query + results", () => { + const { component } = render({ + query: "test", + searchResults: { + count: 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..cb4084d2ef --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Breakpoints Component should render breakpoints with columns 1`] = ` +<div> + <Breakpoint + breakpoint={ + Object { + "location": Object { + "column": 2, + "sourceId": "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..ae463ff6c6 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap @@ -0,0 +1,588 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConditionalPanel it should render at location of selected breakpoint 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "astLocation": null, + "disabled": false, + "generatedLocation": Object { + "column": 2, + "line": 2, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 2, + "line": 2, + "sourceId": "source", + }, + "options": Object { + "condition": undefined, + "logValue": undefined, + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea />, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Breakpoint condition, e.g. items.length > 0", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "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 { + "isThrow": false, + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 2, + "line": 2, + "sourceId": "source", + } + } + log={false} + openConditionalPanel={[MockFunction]} + source={ + Object { + "extensionName": null, + "id": "source", + "isBlackBoxed": false, + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "relativeUrl": "url", + "url": "url", + } + } +/> +`; + +exports[`ConditionalPanel it should render with condition at selected breakpoint location 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "astLocation": null, + "disabled": false, + "generatedLocation": Object { + "column": 3, + "line": 3, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 3, + "line": 3, + "sourceId": "source", + }, + "options": Object { + "condition": "I'm a condition", + "logValue": "not a log", + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea> + I'm a condition + </textarea>, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Breakpoint condition, e.g. items.length > 0", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "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 { + "isThrow": false, + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 3, + "line": 3, + "sourceId": "source", + } + } + log={false} + openConditionalPanel={[MockFunction]} + source={ + Object { + "extensionName": null, + "id": "source", + "isBlackBoxed": false, + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "relativeUrl": "url", + "url": "url", + } + } +/> +`; + +exports[`ConditionalPanel it should render with logpoint at selected breakpoint location 1`] = ` +<ConditionalPanel + breakpoint={ + Object { + "astLocation": null, + "disabled": false, + "generatedLocation": Object { + "column": 4, + "line": 4, + "sourceId": "source", + }, + "id": "breakpoint", + "location": Object { + "column": 4, + "line": 4, + "sourceId": "source", + }, + "options": Object { + "condition": "not a condition", + "logValue": "I'm a log", + }, + "originalText": "text", + "text": "text", + } + } + closeConditionalPanel={[MockFunction]} + editor={ + Object { + "CodeMirror": Object { + "fromTextArea": [MockFunction] { + "calls": Array [ + Array [ + <textarea> + I'm a log + </textarea>, + Object { + "cursorBlinkRate": 530, + "mode": "javascript", + "placeholder": "Log message, e.g. displayName", + "theme": "mozilla", + }, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": Object { + "focus": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "getWrapperElement": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": Object { + "addEventListener": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + }, + }, + ], + }, + "lineCount": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "on": [MockFunction] { + "calls": Array [ + Array [ + "keydown", + [Function], + ], + Array [ + "blur", + [Function], + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "setCursor": [MockFunction] { + "calls": Array [ + Array [ + undefined, + 0, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "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 { + "isThrow": false, + "value": undefined, + }, + ], + }, + }, + } + } + getDefaultValue={[MockFunction]} + location={ + Object { + "column": 4, + "line": 4, + "sourceId": "source", + } + } + log={true} + openConditionalPanel={[MockFunction]} + source={ + Object { + "extensionName": null, + "id": "source", + "isBlackBoxed": false, + "isExtension": false, + "isOriginal": false, + "isPrettyPrinted": false, + "isWasm": false, + "relativeUrl": "url", + "url": "url", + } + } +/> +`; diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Editor.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Editor.spec.js.snap new file mode 100644 index 0000000000..31aaa84b96 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Editor.spec.js.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Editor When empty should render 1`] = ` +<div + className="editor-wrapper" +> + <div + className="editor-mount devtools-monospace" + style={ + Object { + "height": "100%", + } + } + /> +</div> +`; 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..4037617851 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap @@ -0,0 +1,85 @@ +// 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> + </div> + </div> + <div + className="source-footer-end" + > + <div + className="cursor-position" + title="(Line 1, column 1)" + > + (1, 1) + </div> + <PaneToggleButton + collapsed={false} + horizontal={false} + key="toggle" + position="end" + /> + </div> +</div> +`; + +exports[`SourceFooter Component move cursor should render new cursor position 1`] = ` +<div + className="source-footer" +> + <div + className="source-footer-start" + > + <div + className="commands" + > + <button + aria-label="Ignore source" + className="action black-box" + key="black-box" + onClick={[Function]} + title="Ignore source" + > + <AccessibleImage + className="blackBox" + /> + </button> + </div> + </div> + <div + className="source-footer-end" + > + <div + className="cursor-position" + title="(Line 6, column 11)" + > + (6, 11) + </div> + <PaneToggleButton + collapsed={false} + horizontal={false} + key="toggle" + position="end" + /> + </div> +</div> +`; diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/SearchBar.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/SearchBar.spec.js.snap new file mode 100644 index 0000000000..2037682a29 --- /dev/null +++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/SearchBar.spec.js.snap @@ -0,0 +1,278 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar should render 1`] = ` +<div + className="search-bar" +> + <SearchInput + expanded={false} + handleNext={[Function]} + handlePrev={[Function]} + hasPrefix={false} + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + placeholder="Find in file…" + query="" + selectedItemId="" + shouldFocus={false} + showClose={false} + showErrorEmoji={false} + size="" + summaryMsg="" + /> + <div + className="search-bottom-bar" + > + <div + className="search-modifiers" + > + <span + className="pipe-divider" + /> + <span + className="search-type-name" + > + Modifiers: + </span> + <SearchModBtn + className="regex-match-btn" + modVal="regexMatch" + svgName="regex-match" + tooltip="Regex" + /> + <SearchModBtn + className="case-sensitive-btn" + modVal="caseSensitive" + svgName="case-match" + tooltip="Case sensitive" + /> + <SearchModBtn + className="whole-word-btn" + modVal="wholeWord" + svgName="whole-word-match" + tooltip="Whole word" + /> + </div> + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="big" + handleClick={[Function]} + /> + </div> +</div> +`; + +exports[`doSearch should complete a search 1`] = `"query"`; + +exports[`showErrorEmoji false if no query + no results 1`] = ` +<div + className="search-bar" +> + <SearchInput + count={0} + expanded={false} + handleNext={[Function]} + handlePrev={[Function]} + hasPrefix={false} + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + placeholder="Find in file…" + query="" + selectedItemId="" + shouldFocus={false} + showClose={false} + showErrorEmoji={false} + size="" + summaryMsg="" + /> + <div + className="search-bottom-bar" + > + <div + className="search-modifiers" + > + <span + className="pipe-divider" + /> + <span + className="search-type-name" + > + Modifiers: + </span> + <SearchModBtn + className="regex-match-btn" + modVal="regexMatch" + svgName="regex-match" + tooltip="Regex" + /> + <SearchModBtn + className="case-sensitive-btn" + modVal="caseSensitive" + svgName="case-match" + tooltip="Case sensitive" + /> + <SearchModBtn + className="whole-word-btn" + modVal="wholeWord" + svgName="whole-word-match" + tooltip="Whole word" + /> + </div> + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="big" + handleClick={[Function]} + /> + </div> +</div> +`; + +exports[`showErrorEmoji false if query + results 1`] = ` +<div + className="search-bar" +> + <SearchInput + count={10} + expanded={false} + handleNext={[Function]} + handlePrev={[Function]} + hasPrefix={false} + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + placeholder="Find in file…" + query="test" + selectedItemId="" + shouldFocus={false} + showClose={false} + showErrorEmoji={false} + size="" + summaryMsg="NaN of 10 results" + /> + <div + className="search-bottom-bar" + > + <div + className="search-modifiers" + > + <span + className="pipe-divider" + /> + <span + className="search-type-name" + > + Modifiers: + </span> + <SearchModBtn + className="regex-match-btn" + modVal="regexMatch" + svgName="regex-match" + tooltip="Regex" + /> + <SearchModBtn + className="case-sensitive-btn" + modVal="caseSensitive" + svgName="case-match" + tooltip="Case sensitive" + /> + <SearchModBtn + className="whole-word-btn" + modVal="wholeWord" + svgName="whole-word-match" + tooltip="Whole word" + /> + </div> + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="big" + handleClick={[Function]} + /> + </div> +</div> +`; + +exports[`showErrorEmoji true if query + no results 1`] = ` +<div + className="search-bar" +> + <SearchInput + count={0} + expanded={false} + handleNext={[Function]} + handlePrev={[Function]} + hasPrefix={false} + isLoading={false} + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + onHistoryScroll={[Function]} + onKeyDown={[Function]} + placeholder="Find in file…" + query="test" + selectedItemId="" + shouldFocus={false} + showClose={false} + showErrorEmoji={true} + size="" + summaryMsg="No results found" + /> + <div + className="search-bottom-bar" + > + <div + className="search-modifiers" + > + <span + className="pipe-divider" + /> + <span + className="search-type-name" + > + Modifiers: + </span> + <SearchModBtn + className="regex-match-btn" + modVal="regexMatch" + svgName="regex-match" + tooltip="Regex" + /> + <SearchModBtn + className="case-sensitive-btn" + modVal="caseSensitive" + svgName="case-match" + tooltip="Case sensitive" + /> + <SearchModBtn + className="whole-word-btn" + modVal="wholeWord" + svgName="whole-word-match" + tooltip="Whole word" + /> + </div> + <span + className="pipe-divider" + /> + <CloseButton + buttonClass="big" + handleClick={[Function]} + /> + </div> +</div> +`; |