summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/components/Editor
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/components/Editor')
-rw-r--r--devtools/client/debugger/src/components/Editor/BlackboxLines.js138
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoint.js183
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoints.css153
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoints.js96
-rw-r--r--devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js140
-rw-r--r--devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js75
-rw-r--r--devtools/client/debugger/src/components/Editor/ConditionalPanel.css39
-rw-r--r--devtools/client/debugger/src/components/Editor/ConditionalPanel.js274
-rw-r--r--devtools/client/debugger/src/components/Editor/DebugLine.js138
-rw-r--r--devtools/client/debugger/src/components/Editor/Editor.css220
-rw-r--r--devtools/client/debugger/src/components/Editor/EditorMenu.js111
-rw-r--r--devtools/client/debugger/src/components/Editor/EmptyLines.js88
-rw-r--r--devtools/client/debugger/src/components/Editor/Exception.js96
-rw-r--r--devtools/client/debugger/src/components/Editor/Exceptions.js67
-rw-r--r--devtools/client/debugger/src/components/Editor/Footer.css85
-rw-r--r--devtools/client/debugger/src/components/Editor/Footer.js302
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightCalls.css15
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightCalls.js110
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightLine.js183
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightLines.js74
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreview.css29
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreview.js66
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreviewRow.js101
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreviews.js83
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview.css111
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js164
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/Popup.css209
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/Popup.js382
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/index.js136
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/moz.build12
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js107
-rw-r--r--devtools/client/debugger/src/components/Editor/SearchInFileBar.css39
-rw-r--r--devtools/client/debugger/src/components/Editor/SearchInFileBar.js371
-rw-r--r--devtools/client/debugger/src/components/Editor/Tab.js282
-rw-r--r--devtools/client/debugger/src/components/Editor/Tabs.css125
-rw-r--r--devtools/client/debugger/src/components/Editor/Tabs.js332
-rw-r--r--devtools/client/debugger/src/components/Editor/index.js808
-rw-r--r--devtools/client/debugger/src/components/Editor/menus/breakpoints.js293
-rw-r--r--devtools/client/debugger/src/components/Editor/menus/editor.js403
-rw-r--r--devtools/client/debugger/src/components/Editor/menus/moz.build12
-rw-r--r--devtools/client/debugger/src/components/Editor/menus/source.js3
-rw-r--r--devtools/client/debugger/src/components/Editor/moz.build34
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js54
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js77
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js85
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/Footer.spec.js67
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap35
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap630
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap105
49 files changed, 7742 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/Editor/BlackboxLines.js b/devtools/client/debugger/src/components/Editor/BlackboxLines.js
new file mode 100644
index 0000000000..c81db9c598
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/BlackboxLines.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import { Component } from "react";
+import { toEditorLine, fromEditorLine } from "../../utils/editor";
+import { isLineBlackboxed } from "../../utils/source";
+import { isWasm } from "../../utils/wasm";
+
+// This renders blackbox line highlighting in the editor
+class BlackboxLines extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ blackboxedRangesForSelectedSource: PropTypes.array,
+ isSourceOnIgnoreList: PropTypes.bool,
+ };
+ }
+
+ componentDidMount() {
+ const { selectedSource, blackboxedRangesForSelectedSource, editor } =
+ this.props;
+
+ if (this.props.isSourceOnIgnoreList) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ // When `blackboxedRangesForSelectedSource` is defined and the array is empty,
+ // the whole source was blackboxed.
+ if (!blackboxedRangesForSelectedSource.length) {
+ this.setAllBlackboxLines(editor);
+ } else {
+ editor.codeMirror.operation(() => {
+ blackboxedRangesForSelectedSource.forEach(range => {
+ const start = toEditorLine(selectedSource.id, range.start.line);
+ const end = toEditorLine(selectedSource.id, range.end.line);
+ editor.codeMirror.eachLine(start, end, lineHandle => {
+ this.setBlackboxLine(editor, lineHandle);
+ });
+ });
+ });
+ }
+ }
+
+ componentDidUpdate() {
+ const {
+ selectedSource,
+ blackboxedRangesForSelectedSource,
+ editor,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ if (this.props.isSourceOnIgnoreList) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ // when unblackboxed
+ if (!blackboxedRangesForSelectedSource) {
+ this.clearAllBlackboxLines(editor);
+ return;
+ }
+
+ // When the whole source is blackboxed
+ if (!blackboxedRangesForSelectedSource.length) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ const sourceIsWasm = isWasm(selectedSource.id);
+
+ // TODO: Possible perf improvement. Instead of going
+ // over all the lines each time get diffs of what has
+ // changed and update those.
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ const line = fromEditorLine(
+ selectedSource.id,
+ editor.codeMirror.getLineNumber(lineHandle),
+ sourceIsWasm
+ );
+
+ if (
+ isLineBlackboxed(
+ blackboxedRangesForSelectedSource,
+ line,
+ isSourceOnIgnoreList
+ )
+ ) {
+ this.setBlackboxLine(editor, lineHandle);
+ } else {
+ this.clearBlackboxLine(editor, lineHandle);
+ }
+ });
+ });
+ }
+
+ componentWillUnmount() {
+ // Lets make sure we remove everything relating to
+ // blackboxing lines when this component is unmounted.
+ this.clearAllBlackboxLines(this.props.editor);
+ }
+
+ clearAllBlackboxLines(editor) {
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ this.clearBlackboxLine(editor, lineHandle);
+ });
+ });
+ }
+
+ setAllBlackboxLines(editor) {
+ //TODO:We might be able to handle the whole source
+ // than adding the blackboxing line by line
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ this.setBlackboxLine(editor, lineHandle);
+ });
+ });
+ }
+
+ clearBlackboxLine(editor, lineHandle) {
+ editor.codeMirror.removeLineClass(lineHandle, "wrap", "blackboxed-line");
+ }
+
+ setBlackboxLine(editor, lineHandle) {
+ editor.codeMirror.addLineClass(lineHandle, "wrap", "blackboxed-line");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default BlackboxLines;
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoint.js b/devtools/client/debugger/src/components/Editor/Breakpoint.js
new file mode 100644
index 0000000000..cce23c199f
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoint.js
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "react";
+import PropTypes from "prop-types";
+
+import { getDocument, toEditorLine } from "../../utils/editor";
+import { getSelectedLocation } from "../../utils/selected-location";
+import { features } from "../../utils/prefs";
+import { showMenu } from "../../context-menu/menu";
+import { breakpointItems } from "./menus/breakpoints";
+const classnames = require("devtools/client/shared/classnames.js");
+
+const breakpointSvg = document.createElement("div");
+breakpointSvg.innerHTML =
+ '<svg viewBox="0 0 60 15" width="60" height="15"><path d="M53.07.5H1.5c-.54 0-1 .46-1 1v12c0 .54.46 1 1 1h51.57c.58 0 1.15-.26 1.53-.7l4.7-6.3-4.7-6.3c-.38-.44-.95-.7-1.53-.7z"/></svg>';
+
+class Breakpoint extends PureComponent {
+ static get propTypes() {
+ return {
+ cx: PropTypes.object.isRequired,
+ breakpoint: PropTypes.object.isRequired,
+ breakpointActions: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ editorActions: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object,
+ blackboxedRangesForSelectedSource: PropTypes.array,
+ isSelectedSourceOnIgnoreList: PropTypes.bool.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addBreakpoint(this.props);
+ }
+
+ componentDidUpdate(prevProps) {
+ this.removeBreakpoint(prevProps);
+ this.addBreakpoint(this.props);
+ }
+
+ componentWillUnmount() {
+ this.removeBreakpoint(this.props);
+ }
+
+ makeMarker() {
+ const { breakpoint } = this.props;
+ const bp = breakpointSvg.cloneNode(true);
+
+ bp.className = classnames("editor new-breakpoint", {
+ "breakpoint-disabled": breakpoint.disabled,
+ "folding-enabled": features.codeFolding,
+ });
+ bp.onmousedown = this.onClick;
+ bp.oncontextmenu = this.onContextMenu;
+
+ return bp;
+ }
+
+ onClick = event => {
+ const { cx, breakpointActions, editorActions, breakpoint, selectedSource } =
+ this.props;
+
+ // ignore right clicks
+ if ((event.ctrlKey && event.button === 0) || event.button === 2) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+ if (event.metaKey) {
+ editorActions.continueToHere(cx, selectedLocation);
+ return;
+ }
+
+ if (event.shiftKey) {
+ breakpointActions.toggleBreakpointsAtLine(
+ cx,
+ !breakpoint.disabled,
+ selectedLocation.line
+ );
+ return;
+ }
+
+ breakpointActions.removeBreakpointsAtLine(
+ cx,
+ selectedLocation.sourceId,
+ selectedLocation.line
+ );
+ };
+
+ onContextMenu = event => {
+ const {
+ cx,
+ breakpoint,
+ selectedSource,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList,
+ } = this.props;
+ event.stopPropagation();
+ event.preventDefault();
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+
+ showMenu(
+ event,
+ breakpointItems(
+ cx,
+ breakpoint,
+ selectedLocation,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+ )
+ );
+ };
+
+ addBreakpoint(props) {
+ const { breakpoint, editor, selectedSource } = props;
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+
+ // Hidden Breakpoints are never rendered on the client
+ if (breakpoint.options.hidden) {
+ return;
+ }
+
+ if (!selectedSource) {
+ return;
+ }
+
+ const sourceId = selectedSource.id;
+ const line = toEditorLine(sourceId, selectedLocation.line);
+ const doc = getDocument(sourceId);
+
+ doc.setGutterMarker(line, "breakpoints", this.makeMarker());
+
+ editor.codeMirror.addLineClass(line, "wrap", "new-breakpoint");
+ editor.codeMirror.removeLineClass(line, "wrap", "breakpoint-disabled");
+ editor.codeMirror.removeLineClass(line, "wrap", "has-condition");
+ editor.codeMirror.removeLineClass(line, "wrap", "has-log");
+
+ if (breakpoint.disabled) {
+ editor.codeMirror.addLineClass(line, "wrap", "breakpoint-disabled");
+ }
+
+ if (breakpoint.options.logValue) {
+ editor.codeMirror.addLineClass(line, "wrap", "has-log");
+ } else if (breakpoint.options.condition) {
+ editor.codeMirror.addLineClass(line, "wrap", "has-condition");
+ }
+ }
+
+ removeBreakpoint(props) {
+ const { selectedSource, breakpoint } = props;
+ if (!selectedSource) {
+ return;
+ }
+
+ const sourceId = selectedSource.id;
+ const doc = getDocument(sourceId);
+
+ if (!doc) {
+ return;
+ }
+
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+ const line = toEditorLine(sourceId, selectedLocation.line);
+
+ doc.setGutterMarker(line, "breakpoints", null);
+ doc.removeLineClass(line, "wrap", "new-breakpoint");
+ doc.removeLineClass(line, "wrap", "breakpoint-disabled");
+ doc.removeLineClass(line, "wrap", "has-condition");
+ doc.removeLineClass(line, "wrap", "has-log");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default Breakpoint;
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.css b/devtools/client/debugger/src/components/Editor/Breakpoints.css
new file mode 100644
index 0000000000..1269f73f82
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoints.css
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.theme-light {
+ --gutter-hover-background-color: #dde1e4;
+ --breakpoint-fill: var(--blue-50);
+ --breakpoint-stroke: var(--blue-60);
+}
+
+.theme-dark {
+ --gutter-hover-background-color: #414141;
+ --breakpoint-fill: var(--blue-55);
+ --breakpoint-stroke: var(--blue-40);
+}
+
+.theme-light,
+.theme-dark {
+ --logpoint-fill: var(--theme-graphs-purple);
+ --logpoint-stroke: var(--purple-60);
+ --breakpoint-condition-fill: var(--theme-graphs-yellow);
+ --breakpoint-condition-stroke: var(--theme-graphs-orange);
+ --breakpoint-skipped-opacity: 0.15;
+ --breakpoint-inactive-opacity: 0.3;
+ --breakpoint-disabled-opacity: 0.6;
+}
+
+/* Standard gutter breakpoints */
+.editor-wrapper .breakpoints {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.new-breakpoint .CodeMirror-linenumber {
+ pointer-events: none;
+}
+
+.editor-wrapper :not(.empty-line, .new-breakpoint)
+ > .CodeMirror-gutter-wrapper
+ > .CodeMirror-linenumber:hover::after {
+ content: "";
+ position: absolute;
+ /* paint below the number */
+ z-index: -1;
+ top: 0;
+ left: 0;
+ right: -4px;
+ bottom: 0;
+ height: 15px;
+ background-color: var(--gutter-hover-background-color);
+ mask: url(chrome://devtools/content/debugger/images/breakpoint.svg)
+ no-repeat;
+ mask-size: auto 15px;
+ mask-position: right;
+}
+
+.editor.new-breakpoint svg {
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ width: 60px;
+ height: 15px;
+ position: absolute;
+ top: 0px;
+ right: -4px;
+}
+
+.editor .breakpoint {
+ position: absolute;
+ right: -2px;
+}
+
+.editor.new-breakpoint.folding-enabled svg {
+ right: -16px;
+}
+
+.new-breakpoint.has-condition .CodeMirror-gutter-wrapper svg {
+ fill: var(--breakpoint-condition-fill);
+ stroke: var(--breakpoint-condition-stroke);
+}
+
+.new-breakpoint.has-log .CodeMirror-gutter-wrapper svg {
+ fill: var(--logpoint-fill);
+ stroke: var(--logpoint-stroke);
+}
+
+.editor.new-breakpoint.breakpoint-disabled svg,
+.blackboxed-line .editor.new-breakpoint svg {
+ fill-opacity: var(--breakpoint-disabled-opacity);
+ stroke-opacity: var(--breakpoint-disabled-opacity);
+}
+
+.editor-wrapper.skip-pausing .editor.new-breakpoint svg {
+ fill-opacity: var(--breakpoint-skipped-opacity);
+}
+
+/* Columnn breakpoints */
+.column-breakpoint {
+ display: inline;
+ padding-inline-start: 1px;
+ padding-inline-end: 1px;
+}
+
+.column-breakpoint:hover {
+ background-color: transparent;
+}
+
+.column-breakpoint svg {
+ display: inline-block;
+ cursor: pointer;
+ height: 13px;
+ width: 11px;
+ vertical-align: top;
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ fill-opacity: var(--breakpoint-inactive-opacity);
+ stroke-opacity: var(--breakpoint-inactive-opacity);
+}
+
+.column-breakpoint.active svg {
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ fill-opacity: 1;
+ stroke-opacity: 1;
+}
+
+.column-breakpoint.disabled svg {
+ fill-opacity: var(--breakpoint-disabled-opacity);
+ stroke-opacity: var(--breakpoint-disabled-opacity);
+}
+
+.column-breakpoint.has-log.disabled svg {
+ fill-opacity: 0.5;
+ stroke-opacity: 0.5;
+}
+
+.column-breakpoint.has-condition svg {
+ fill: var(--breakpoint-condition-fill);
+ stroke: var(--breakpoint-condition-stroke);
+}
+
+.column-breakpoint.has-log svg {
+ fill: var(--logpoint-fill);
+ stroke: var(--logpoint-stroke);
+}
+
+.editor-wrapper.skip-pausing .column-breakpoint svg {
+ fill-opacity: var(--breakpoint-skipped-opacity);
+}
+
+.img.column-marker {
+ background-image: url(chrome://devtools/content/debugger/images/column-marker.svg);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.js b/devtools/client/debugger/src/components/Editor/Breakpoints.js
new file mode 100644
index 0000000000..36added4ee
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoints.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import React, { Component } from "react";
+import Breakpoint from "./Breakpoint";
+
+import {
+ getSelectedSource,
+ getFirstVisibleBreakpoints,
+ getBlackBoxRanges,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+import { makeBreakpointId } from "../../utils/breakpoint";
+import { connect } from "../../utils/connect";
+import { breakpointItemActions } from "./menus/breakpoints";
+import { editorItemActions } from "./menus/editor";
+
+class Breakpoints extends Component {
+ static get propTypes() {
+ return {
+ cx: PropTypes.object,
+ breakpoints: PropTypes.array,
+ editor: PropTypes.object,
+ breakpointActions: PropTypes.object,
+ editorActions: PropTypes.object,
+ selectedSource: PropTypes.object,
+ blackboxedRanges: PropTypes.object,
+ isSelectedSourceOnIgnoreList: PropTypes.bool,
+ blackboxedRangesForSelectedSource: PropTypes.array,
+ };
+ }
+ render() {
+ const {
+ cx,
+ breakpoints,
+ selectedSource,
+ editor,
+ breakpointActions,
+ editorActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList,
+ } = this.props;
+
+ if (!selectedSource || !breakpoints) {
+ return null;
+ }
+
+ return (
+ <div>
+ {breakpoints.map(bp => {
+ return (
+ <Breakpoint
+ cx={cx}
+ key={makeBreakpointId(bp.location)}
+ breakpoint={bp}
+ selectedSource={selectedSource}
+ blackboxedRangesForSelectedSource={
+ blackboxedRangesForSelectedSource
+ }
+ isSelectedSourceOnIgnoreList={isSelectedSourceOnIgnoreList}
+ editor={editor}
+ breakpointActions={breakpointActions}
+ editorActions={editorActions}
+ />
+ );
+ })}
+ </div>
+ );
+ }
+}
+
+export default connect(
+ state => {
+ const selectedSource = getSelectedSource(state);
+ const blackboxedRanges = getBlackBoxRanges(state);
+ return {
+ // Retrieves only the first breakpoint per line so that the
+ // breakpoint marker represents only the first breakpoint
+ breakpoints: getFirstVisibleBreakpoints(state),
+ selectedSource,
+ blackboxedRangesForSelectedSource:
+ selectedSource && blackboxedRanges[selectedSource.url],
+ isSelectedSourceOnIgnoreList:
+ selectedSource &&
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, selectedSource),
+ };
+ },
+ dispatch => ({
+ breakpointActions: breakpointItemActions(dispatch),
+ editorActions: editorItemActions(dispatch),
+ })
+)(Breakpoints);
diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js
new file mode 100644
index 0000000000..0577a61f5c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { showMenu } from "../../context-menu/menu";
+
+import { getDocument } from "../../utils/editor";
+import { breakpointItems, createBreakpointItems } from "./menus/breakpoints";
+import { getSelectedLocation } from "../../utils/selected-location";
+const classnames = require("devtools/client/shared/classnames.js");
+
+// eslint-disable-next-line max-len
+
+const breakpointButton = document.createElement("button");
+breakpointButton.innerHTML =
+ '<svg viewBox="0 0 11 13" width="11" height="13"><path d="M5.07.5H1.5c-.54 0-1 .46-1 1v10c0 .54.46 1 1 1h3.57c.58 0 1.15-.26 1.53-.7l3.7-5.3-3.7-5.3C6.22.76 5.65.5 5.07.5z"/></svg>';
+
+function makeBookmark({ breakpoint }, { onClick, onContextMenu }) {
+ const bp = breakpointButton.cloneNode(true);
+
+ const isActive = breakpoint && !breakpoint.disabled;
+ const isDisabled = breakpoint?.disabled;
+ const condition = breakpoint?.options.condition;
+ const logValue = breakpoint?.options.logValue;
+
+ bp.className = classnames("column-breakpoint", {
+ "has-condition": condition,
+ "has-log": logValue,
+ active: isActive,
+ disabled: isDisabled,
+ });
+
+ bp.setAttribute("title", logValue || condition || "");
+ bp.onclick = onClick;
+ bp.oncontextmenu = onContextMenu;
+
+ return bp;
+}
+
+export default class ColumnBreakpoint extends PureComponent {
+ bookmark;
+
+ static get propTypes() {
+ return {
+ breakpointActions: PropTypes.object.isRequired,
+ columnBreakpoint: PropTypes.object.isRequired,
+ cx: PropTypes.object.isRequired,
+ source: PropTypes.object.isRequired,
+ };
+ }
+
+ addColumnBreakpoint = nextProps => {
+ const { columnBreakpoint, source } = nextProps || this.props;
+
+ const sourceId = source.id;
+ const doc = getDocument(sourceId);
+ if (!doc) {
+ return;
+ }
+
+ const { line, column } = columnBreakpoint.location;
+ const widget = makeBookmark(columnBreakpoint, {
+ onClick: this.onClick,
+ onContextMenu: this.onContextMenu,
+ });
+
+ this.bookmark = doc.setBookmark({ line: line - 1, ch: column }, { widget });
+ };
+
+ clearColumnBreakpoint = () => {
+ if (this.bookmark) {
+ this.bookmark.clear();
+ this.bookmark = null;
+ }
+ };
+
+ onClick = event => {
+ event.stopPropagation();
+ event.preventDefault();
+ const { cx, columnBreakpoint, breakpointActions } = this.props;
+
+ // disable column breakpoint on shift-click.
+ if (event.shiftKey) {
+ const breakpoint = columnBreakpoint.breakpoint;
+ breakpointActions.toggleDisabledBreakpoint(cx, breakpoint);
+ return;
+ }
+
+ if (columnBreakpoint.breakpoint) {
+ breakpointActions.removeBreakpoint(cx, columnBreakpoint.breakpoint);
+ } else {
+ breakpointActions.addBreakpoint(cx, columnBreakpoint.location);
+ }
+ };
+
+ onContextMenu = event => {
+ event.stopPropagation();
+ event.preventDefault();
+ const {
+ cx,
+ columnBreakpoint: { breakpoint, location },
+ source,
+ breakpointActions,
+ } = this.props;
+
+ let items = createBreakpointItems(cx, location, breakpointActions);
+
+ if (breakpoint) {
+ const selectedLocation = getSelectedLocation(breakpoint, source);
+
+ items = breakpointItems(
+ cx,
+ breakpoint,
+ selectedLocation,
+ breakpointActions
+ );
+ }
+
+ showMenu(event, items);
+ };
+
+ componentDidMount() {
+ this.addColumnBreakpoint();
+ }
+
+ componentWillUnmount() {
+ this.clearColumnBreakpoint();
+ }
+
+ componentDidUpdate() {
+ this.clearColumnBreakpoint();
+ this.addColumnBreakpoint();
+ }
+
+ render() {
+ return null;
+ }
+}
diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js
new file mode 100644
index 0000000000..62c2ab29e3
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import ColumnBreakpoint from "./ColumnBreakpoint";
+
+import {
+ getSelectedSource,
+ visibleColumnBreakpoints,
+ getContext,
+ isSourceBlackBoxed,
+} from "../../selectors";
+import { connect } from "../../utils/connect";
+import { makeBreakpointId } from "../../utils/breakpoint";
+import { breakpointItemActions } from "./menus/breakpoints";
+
+// eslint-disable-next-line max-len
+
+class ColumnBreakpoints extends Component {
+ static get propTypes() {
+ return {
+ breakpointActions: PropTypes.object.isRequired,
+ columnBreakpoints: PropTypes.array.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object,
+ };
+ }
+
+ render() {
+ const { cx, editor, columnBreakpoints, selectedSource, breakpointActions } =
+ this.props;
+
+ if (!selectedSource || columnBreakpoints.length === 0) {
+ return null;
+ }
+
+ let breakpoints;
+ editor.codeMirror.operation(() => {
+ breakpoints = columnBreakpoints.map(breakpoint => (
+ <ColumnBreakpoint
+ cx={cx}
+ key={makeBreakpointId(breakpoint.location)}
+ columnBreakpoint={breakpoint}
+ editor={editor}
+ source={selectedSource}
+ breakpointActions={breakpointActions}
+ />
+ ));
+ });
+ return <div>{breakpoints}</div>;
+ }
+}
+
+const mapStateToProps = state => {
+ // Avoid rendering this component is there is no selected source,
+ // or if the selected source is blackboxed.
+ // Also avoid computing visible column breakpoint when this happens.
+ const selectedSource = getSelectedSource(state);
+ if (!selectedSource || isSourceBlackBoxed(state, selectedSource)) {
+ return {};
+ }
+ return {
+ cx: getContext(state),
+ selectedSource,
+ columnBreakpoints: visibleColumnBreakpoints(state),
+ };
+};
+
+export default connect(mapStateToProps, dispatch => ({
+ breakpointActions: breakpointItemActions(dispatch),
+}))(ColumnBreakpoints);
diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.css b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css
new file mode 100644
index 0000000000..4ce8dbcd8c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.conditional-breakpoint-panel {
+ cursor: initial;
+ margin: 1em 0;
+ position: relative;
+ display: flex;
+ align-items: center;
+ background: var(--theme-toolbar-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.conditional-breakpoint-panel .prompt {
+ font-size: 1.8em;
+ color: var(--theme-graphs-orange);
+ padding-left: 3px;
+ padding-right: 3px;
+ padding-bottom: 3px;
+ text-align: right;
+ width: 30px;
+ align-self: baseline;
+ margin-top: 3px;
+}
+
+.conditional-breakpoint-panel.log-point .prompt {
+ color: var(--purple-60);
+}
+
+.conditional-breakpoint-panel .CodeMirror {
+ margin: 6px 10px;
+}
+
+.conditional-breakpoint-panel .CodeMirror pre.CodeMirror-placeholder {
+ /* Match the color of the placeholder text to existing inputs in the Debugger */
+ color: var(--theme-text-color-alt);
+}
diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.js b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js
new file mode 100644
index 0000000000..e451ffa960
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js
@@ -0,0 +1,274 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import ReactDOM from "react-dom";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import "./ConditionalPanel.css";
+import { toEditorLine } from "../../utils/editor";
+import { prefs } from "../../utils/prefs";
+import actions from "../../actions";
+
+import {
+ getClosestBreakpoint,
+ getConditionalPanelLocation,
+ getLogPointStatus,
+ getContext,
+} from "../../selectors";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+function addNewLine(doc) {
+ const cursor = doc.getCursor();
+ const pos = { line: cursor.line, ch: cursor.ch };
+ doc.replaceRange("\n", pos);
+}
+
+export class ConditionalPanel extends PureComponent {
+ cbPanel;
+ input;
+ codeMirror;
+ panelNode;
+ scrollParent;
+
+ constructor() {
+ super();
+ this.cbPanel = null;
+ }
+
+ static get propTypes() {
+ return {
+ breakpoint: PropTypes.object,
+ closeConditionalPanel: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ location: PropTypes.any.isRequired,
+ log: PropTypes.bool.isRequired,
+ openConditionalPanel: PropTypes.func.isRequired,
+ setBreakpointOptions: PropTypes.func.isRequired,
+ };
+ }
+
+ keepFocusOnInput() {
+ if (this.input) {
+ this.input.focus();
+ }
+ }
+
+ saveAndClose = () => {
+ if (this.input) {
+ this.setBreakpoint(this.input.value.trim());
+ }
+
+ this.props.closeConditionalPanel();
+ };
+
+ onKey = e => {
+ if (e.key === "Enter") {
+ if (this.codeMirror && e.altKey) {
+ addNewLine(this.codeMirror.doc);
+ } else {
+ this.saveAndClose();
+ }
+ } else if (e.key === "Escape") {
+ this.props.closeConditionalPanel();
+ }
+ };
+
+ setBreakpoint(value) {
+ const { cx, log, breakpoint } = this.props;
+ // If breakpoint is `pending`, props will not contain a breakpoint.
+ // If source is a URL without location, breakpoint will contain no generatedLocation.
+ const location =
+ breakpoint && breakpoint.generatedLocation
+ ? breakpoint.generatedLocation
+ : this.props.location;
+ const options = breakpoint ? breakpoint.options : {};
+ const type = log ? "logValue" : "condition";
+ return this.props.setBreakpointOptions(cx, location, {
+ ...options,
+ [type]: value,
+ });
+ }
+
+ clearConditionalPanel() {
+ if (this.cbPanel) {
+ this.cbPanel.clear();
+ this.cbPanel = null;
+ }
+ if (this.scrollParent) {
+ this.scrollParent.removeEventListener("scroll", this.repositionOnScroll);
+ }
+ }
+
+ repositionOnScroll = () => {
+ if (this.panelNode && this.scrollParent) {
+ const { scrollLeft } = this.scrollParent;
+ this.panelNode.style.transform = `translateX(${scrollLeft}px)`;
+ }
+ };
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillMount() {
+ return this.renderToWidget(this.props);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate() {
+ return this.clearConditionalPanel();
+ }
+
+ componentDidUpdate(prevProps) {
+ this.keepFocusOnInput();
+ }
+
+ componentWillUnmount() {
+ // This is called if CodeMirror is re-initializing itself before the
+ // user closes the conditional panel. Clear the widget, and re-render it
+ // as soon as this component gets remounted
+ return this.clearConditionalPanel();
+ }
+
+ renderToWidget(props) {
+ if (this.cbPanel) {
+ this.clearConditionalPanel();
+ }
+ const { location, editor } = props;
+
+ const editorLine = toEditorLine(location.sourceId, location.line || 0);
+ this.cbPanel = editor.codeMirror.addLineWidget(
+ editorLine,
+ this.renderConditionalPanel(props),
+ {
+ coverGutter: true,
+ noHScroll: true,
+ }
+ );
+
+ if (this.input) {
+ let parent = this.input.parentNode;
+ while (parent) {
+ if (
+ parent instanceof HTMLElement &&
+ parent.classList.contains("CodeMirror-scroll")
+ ) {
+ this.scrollParent = parent;
+ break;
+ }
+ parent = parent.parentNode;
+ }
+
+ if (this.scrollParent) {
+ this.scrollParent.addEventListener("scroll", this.repositionOnScroll);
+ this.repositionOnScroll();
+ }
+ }
+ }
+
+ createEditor = input => {
+ const { log, editor, closeConditionalPanel } = this.props;
+ const codeMirror = editor.CodeMirror.fromTextArea(input, {
+ mode: "javascript",
+ theme: "mozilla",
+ placeholder: L10N.getStr(
+ log
+ ? "editor.conditionalPanel.logPoint.placeholder2"
+ : "editor.conditionalPanel.placeholder2"
+ ),
+ cursorBlinkRate: prefs.cursorBlinkRate,
+ });
+
+ codeMirror.on("keydown", (cm, e) => {
+ if (e.key === "Enter") {
+ e.codemirrorIgnore = true;
+ }
+ });
+
+ codeMirror.on("blur", (cm, e) => {
+ if (
+ e?.relatedTarget &&
+ e.relatedTarget.closest(".conditional-breakpoint-panel")
+ ) {
+ return;
+ }
+
+ closeConditionalPanel();
+ });
+
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirrorWrapper.addEventListener("keydown", e => {
+ codeMirror.save();
+ this.onKey(e);
+ });
+
+ this.input = input;
+ this.codeMirror = codeMirror;
+ codeMirror.focus();
+ codeMirror.setCursor(codeMirror.lineCount(), 0);
+ };
+
+ getDefaultValue() {
+ const { breakpoint, log } = this.props;
+ const options = breakpoint?.options || {};
+ return log ? options.logValue : options.condition;
+ }
+
+ renderConditionalPanel(props) {
+ const { log } = props;
+ const defaultValue = this.getDefaultValue();
+
+ const panel = document.createElement("div");
+ ReactDOM.render(
+ <div
+ className={classnames("conditional-breakpoint-panel", {
+ "log-point": log,
+ })}
+ onClick={() => this.keepFocusOnInput()}
+ ref={node => (this.panelNode = node)}
+ >
+ <div className="prompt">»</div>
+ <textarea
+ defaultValue={defaultValue}
+ ref={input => this.createEditor(input)}
+ />
+ </div>,
+ panel
+ );
+ return panel;
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = state => {
+ const location = getConditionalPanelLocation(state);
+
+ if (!location) {
+ throw new Error("Conditional panel location needed.");
+ }
+
+ const breakpoint = getClosestBreakpoint(state, location);
+
+ return {
+ cx: getContext(state),
+ breakpoint,
+ location,
+ log: getLogPointStatus(state),
+ };
+};
+
+const { setBreakpointOptions, openConditionalPanel, closeConditionalPanel } =
+ actions;
+
+const mapDispatchToProps = {
+ setBreakpointOptions,
+ openConditionalPanel,
+ closeConditionalPanel,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ConditionalPanel);
diff --git a/devtools/client/debugger/src/components/Editor/DebugLine.js b/devtools/client/debugger/src/components/Editor/DebugLine.js
new file mode 100644
index 0000000000..95cfc5a94d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/DebugLine.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "react";
+import PropTypes from "prop-types";
+import {
+ toEditorPosition,
+ getDocument,
+ hasDocument,
+ startOperation,
+ endOperation,
+ getTokenEnd,
+} from "../../utils/editor";
+import { isException } from "../../utils/pause";
+import { getIndentation } from "../../utils/indentation";
+import { connect } from "../../utils/connect";
+import {
+ getVisibleSelectedFrame,
+ getPauseReason,
+ getSourceTextContent,
+ getCurrentThread,
+} from "../../selectors";
+
+export class DebugLine extends PureComponent {
+ debugExpression;
+
+ static get propTypes() {
+ return {
+ location: PropTypes.object,
+ why: PropTypes.object,
+ };
+ }
+
+ componentDidMount() {
+ const { why, location } = this.props;
+ this.setDebugLine(why, location);
+ }
+
+ componentWillUnmount() {
+ const { why, location } = this.props;
+ this.clearDebugLine(why, location);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { why, location } = this.props;
+
+ startOperation();
+ this.clearDebugLine(prevProps.why, prevProps.location);
+ this.setDebugLine(why, location);
+ endOperation();
+ }
+
+ setDebugLine(why, location) {
+ if (!location) {
+ return;
+ }
+ const { sourceId } = location;
+ const doc = getDocument(sourceId);
+
+ let { line, column } = toEditorPosition(location);
+ let { markTextClass, lineClass } = this.getTextClasses(why);
+ doc.addLineClass(line, "wrap", lineClass);
+
+ const lineText = doc.getLine(line);
+ column = Math.max(column, getIndentation(lineText));
+
+ // If component updates because user clicks on
+ // another source tab, codeMirror will be null.
+ const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null;
+
+ if (columnEnd === null) {
+ markTextClass += " to-line-end";
+ }
+
+ this.debugExpression = doc.markText(
+ { ch: column, line },
+ { ch: columnEnd, line },
+ { className: markTextClass }
+ );
+ }
+
+ clearDebugLine(why, location) {
+ // Avoid clearing the line if we didn't set a debug line before,
+ // or, if the document is no longer available
+ if (!location || !hasDocument(location.sourceId)) {
+ return;
+ }
+
+ if (this.debugExpression) {
+ this.debugExpression.clear();
+ }
+
+ const { line } = toEditorPosition(location);
+ const doc = getDocument(location.sourceId);
+ const { lineClass } = this.getTextClasses(why);
+ doc.removeLineClass(line, "wrap", lineClass);
+ }
+
+ getTextClasses(why) {
+ if (why && isException(why)) {
+ return {
+ markTextClass: "debug-expression-error",
+ lineClass: "new-debug-line-error",
+ };
+ }
+
+ return { markTextClass: "debug-expression", lineClass: "new-debug-line" };
+ }
+
+ render() {
+ return null;
+ }
+}
+
+function isDocumentReady(location, sourceTextContent) {
+ return location && sourceTextContent && hasDocument(location.sourceId);
+}
+
+const mapStateToProps = state => {
+ // Avoid unecessary intermediate updates when there is no location
+ // or the source text content isn't yet fully loaded
+ const frame = getVisibleSelectedFrame(state);
+ const location = frame?.location;
+ if (!location) {
+ return {};
+ }
+ const sourceTextContent = getSourceTextContent(state, location);
+ if (!isDocumentReady(location, sourceTextContent)) {
+ return {};
+ }
+ return {
+ location,
+ why: getPauseReason(state, getCurrentThread(state)),
+ };
+};
+
+export default connect(mapStateToProps)(DebugLine);
diff --git a/devtools/client/debugger/src/components/Editor/Editor.css b/devtools/client/debugger/src/components/Editor/Editor.css
new file mode 100644
index 0000000000..7ea45c629d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Editor.css
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.editor-wrapper {
+ --debug-line-border: rgb(145, 188, 219);
+ --debug-expression-background: rgba(202, 227, 255, 0.5);
+ --debug-line-error-border: rgb(255, 0, 0);
+ --debug-expression-error-background: rgba(231, 116, 113, 0.3);
+ --line-exception-background: hsl(344, 73%, 97%);
+ --highlight-line-duration: 5000ms;
+}
+
+.theme-dark .editor-wrapper {
+ --debug-expression-background: rgba(202, 227, 255, 0.3);
+ --debug-line-border: #7786a2;
+ --line-exception-background: hsl(345, 23%, 24%);
+}
+
+.editor-wrapper .CodeMirror-linewidget {
+ margin-right: -7px;
+}
+
+.editor-wrapper {
+ min-width: 0 !important;
+}
+
+.CodeMirror.cm-s-mozilla,
+.CodeMirror-scroll,
+.CodeMirror-sizer {
+ overflow-anchor: none;
+}
+
+/* Prevents inline preview from shifting source height (#1576163) */
+.CodeMirror-linewidget {
+ padding: 0;
+ display: flow-root;
+}
+
+/**
+ * There's a known codemirror flex issue with chrome that this addresses.
+ * BUG https://github.com/firefox-devtools/debugger/issues/63
+ */
+.editor-wrapper {
+ position: absolute;
+ width: calc(100% - 1px);
+ top: var(--editor-header-height);
+ bottom: var(--editor-footer-height);
+ left: 0px;
+}
+
+html[dir="rtl"] .editor-mount {
+ direction: ltr;
+}
+
+.function-search {
+ max-height: 300px;
+ overflow: hidden;
+}
+
+.function-search .results {
+ height: auto;
+}
+
+.editor.hit-marker {
+ height: 15px;
+}
+
+.editor-wrapper .highlight-lines {
+ background: var(--theme-selection-background-hover);
+}
+
+.CodeMirror {
+ width: 100%;
+ height: 100%;
+}
+
+.editor-wrapper .editor-mount {
+ width: 100%;
+ background-color: var(--theme-body-background);
+ font-size: var(--theme-code-font-size);
+ line-height: var(--theme-code-line-height);
+}
+
+/* set the linenumber white when there is a breakpoint */
+.editor-wrapper:not(.skip-pausing)
+ .new-breakpoint
+ .CodeMirror-gutter-wrapper
+ .CodeMirror-linenumber {
+ color: white;
+}
+
+/* move the breakpoint below the other gutter elements */
+.new-breakpoint .CodeMirror-gutter-elt:nth-child(2) {
+ z-index: 0;
+}
+
+.theme-dark .editor-wrapper .CodeMirror-line .cm-comment {
+ color: var(--theme-comment);
+}
+
+.debug-expression {
+ background-color: var(--debug-expression-background);
+ border-style: solid;
+ border-color: var(--debug-expression-background);
+ border-width: 1px 0px 1px 0px;
+ position: relative;
+}
+
+.debug-expression::before {
+ content: "";
+ line-height: 1px;
+ border-top: 1px solid var(--blue-50);
+ background: transparent;
+ position: absolute;
+ top: -2px;
+ left: 0px;
+ width: 100%;
+ }
+
+.debug-expression::after {
+ content: "";
+ line-height: 1px;
+ border-bottom: 1px solid var(--blue-50);
+ position: absolute;
+ bottom: -2px;
+ left: 0px;
+ width: 100%;
+ }
+
+.to-line-end ~ .CodeMirror-widget {
+ background-color: var(--debug-expression-background);
+}
+
+.debug-expression-error {
+ background-color: var(--debug-expression-error-background);
+}
+
+.new-debug-line > .CodeMirror-line {
+ background-color: transparent !important;
+ outline: var(--debug-line-border) solid 1px;
+}
+
+/* Don't display the highlight color since the debug line
+ is already highlighted */
+.new-debug-line .CodeMirror-activeline-background {
+ display: none;
+}
+
+.new-debug-line-error > .CodeMirror-line {
+ background-color: var(--debug-expression-error-background) !important;
+ outline: var(--debug-line-error-border) solid 1px;
+}
+
+/* Don't display the highlight color since the debug line
+ is already highlighted */
+.new-debug-line-error .CodeMirror-activeline-background {
+ display: none;
+}
+.highlight-line .CodeMirror-line {
+ animation-name: fade-highlight-out;
+ animation-duration: var(--highlight-line-duration);
+ animation-timing-function: ease-out;
+ animation-direction: forwards;
+}
+
+@keyframes fade-highlight-out {
+ 0% {
+ background-color: var(--theme-contrast-background);
+ }
+ 30% {
+ background-color: var(--theme-contrast-background);
+ }
+ 100% {
+ background-color: transparent;
+ }
+}
+
+.visible {
+ visibility: visible;
+}
+
+/* Code folding */
+.editor-wrapper .CodeMirror-foldgutter-open {
+ color: var(--grey-40);
+}
+
+.editor-wrapper .CodeMirror-foldgutter-open,
+.editor-wrapper .CodeMirror-foldgutter-folded {
+ fill: var(--grey-40);
+}
+
+.editor-wrapper .CodeMirror-foldgutter-open::before,
+.editor-wrapper .CodeMirror-foldgutter-open::after {
+ border-top: none;
+}
+
+.editor-wrapper .CodeMirror-foldgutter-folded::before,
+.editor-wrapper .CodeMirror-foldgutter-folded::after {
+ border-left: none;
+}
+
+.editor-wrapper .CodeMirror-foldgutter .CodeMirror-guttermarker-subtle {
+ visibility: visible;
+}
+
+.editor-wrapper .CodeMirror-foldgutter .CodeMirror-linenumber {
+ text-align: left;
+ padding: 0 0 0 2px;
+}
+
+/* Exception line */
+.line-exception {
+ background-color: var(--line-exception-background);
+}
+
+.mark-text-exception {
+ text-decoration: var(--red-50) wavy underline;
+ text-decoration-skip-ink: none;
+}
diff --git a/devtools/client/debugger/src/components/Editor/EditorMenu.js b/devtools/client/debugger/src/components/Editor/EditorMenu.js
new file mode 100644
index 0000000000..a865fcc9bd
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/EditorMenu.js
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "react";
+import PropTypes from "prop-types";
+
+import { connect } from "../../utils/connect";
+import { showMenu } from "../../context-menu/menu";
+
+import { getSourceLocationFromMouseEvent } from "../../utils/editor";
+import { isPretty } from "../../utils/source";
+import {
+ getPrettySource,
+ getIsCurrentThreadPaused,
+ getThreadContext,
+ isSourceWithMap,
+ getBlackBoxRanges,
+ isSourceOnSourceMapIgnoreList,
+ isSourceMapIgnoreListEnabled,
+} from "../../selectors";
+
+import { editorMenuItems, editorItemActions } from "./menus/editor";
+
+class EditorMenu extends Component {
+ static get propTypes() {
+ return {
+ clearContextMenu: PropTypes.func.isRequired,
+ contextMenu: PropTypes.object,
+ isSourceOnIgnoreList: PropTypes.bool,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate(nextProps) {
+ this.props.clearContextMenu();
+ if (nextProps.contextMenu) {
+ this.showMenu(nextProps);
+ }
+ }
+
+ showMenu(props) {
+ const {
+ cx,
+ editor,
+ selectedSource,
+ blackboxedRanges,
+ editorActions,
+ hasMappedLocation,
+ isPaused,
+ editorWrappingEnabled,
+ contextMenu: event,
+ isSourceOnIgnoreList,
+ } = props;
+
+ const location = getSourceLocationFromMouseEvent(
+ editor,
+ selectedSource,
+ // Use a coercion, as contextMenu is optional
+ event
+ );
+
+ showMenu(
+ event,
+ editorMenuItems({
+ cx,
+ editorActions,
+ selectedSource,
+ blackboxedRanges,
+ hasMappedLocation,
+ location,
+ isPaused,
+ editorWrappingEnabled,
+ selectionText: editor.codeMirror.getSelection().trim(),
+ isTextSelected: editor.codeMirror.somethingSelected(),
+ editor,
+ isSourceOnIgnoreList,
+ })
+ );
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = (state, props) => {
+ // This component is a no-op when contextmenu is false
+ if (!props.contextMenu) {
+ return {};
+ }
+ return {
+ cx: getThreadContext(state),
+ blackboxedRanges: getBlackBoxRanges(state),
+ isPaused: getIsCurrentThreadPaused(state),
+ hasMappedLocation:
+ (props.selectedSource.isOriginal ||
+ isSourceWithMap(state, props.selectedSource.id) ||
+ isPretty(props.selectedSource)) &&
+ !getPrettySource(state, props.selectedSource.id),
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, props.selectedSource),
+ };
+};
+
+const mapDispatchToProps = dispatch => ({
+ editorActions: editorItemActions(dispatch),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditorMenu);
diff --git a/devtools/client/debugger/src/components/Editor/EmptyLines.js b/devtools/client/debugger/src/components/Editor/EmptyLines.js
new file mode 100644
index 0000000000..70a8c9c0a7
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/EmptyLines.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { connect } from "../../utils/connect";
+import { Component } from "react";
+import PropTypes from "prop-types";
+import { getSelectedSource, getSelectedBreakableLines } from "../../selectors";
+import { fromEditorLine } from "../../utils/editor";
+import { isWasm } from "../../utils/wasm";
+
+class EmptyLines extends Component {
+ static get propTypes() {
+ return {
+ breakableLines: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.disableEmptyLines();
+ }
+
+ componentDidUpdate() {
+ this.disableEmptyLines();
+ }
+
+ componentWillUnmount() {
+ const { editor } = this.props;
+
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ editor.codeMirror.removeLineClass(lineHandle, "wrap", "empty-line");
+ });
+ });
+ }
+
+ shouldComponentUpdate(nextProps) {
+ const { breakableLines, selectedSource } = this.props;
+ return (
+ // Breakable lines are something that evolves over time,
+ // but we either have them loaded or not. So only compare the size
+ // as sometimes we always get a blank new empty Set instance.
+ breakableLines.size != nextProps.breakableLines.size ||
+ selectedSource.id != nextProps.selectedSource.id
+ );
+ }
+
+ disableEmptyLines() {
+ const { breakableLines, selectedSource, editor } = this.props;
+
+ const { codeMirror } = editor;
+ const isSourceWasm = isWasm(selectedSource.id);
+
+ codeMirror.operation(() => {
+ const lineCount = codeMirror.lineCount();
+ for (let i = 0; i < lineCount; i++) {
+ const line = fromEditorLine(selectedSource.id, i, isSourceWasm);
+
+ if (breakableLines.has(line)) {
+ codeMirror.removeLineClass(i, "wrap", "empty-line");
+ } else {
+ codeMirror.addLineClass(i, "wrap", "empty-line");
+ }
+ }
+ });
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ if (!selectedSource) {
+ throw new Error("no selectedSource");
+ }
+ const breakableLines = getSelectedBreakableLines(state);
+
+ return {
+ selectedSource,
+ breakableLines,
+ };
+};
+
+export default connect(mapStateToProps)(EmptyLines);
diff --git a/devtools/client/debugger/src/components/Editor/Exception.js b/devtools/client/debugger/src/components/Editor/Exception.js
new file mode 100644
index 0000000000..8527cfed07
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Exception.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "react";
+import PropTypes from "prop-types";
+
+import { toEditorPosition, getTokenEnd, hasDocument } from "../../utils/editor";
+
+import { getIndentation } from "../../utils/indentation";
+import { createLocation } from "../../utils/location";
+
+export default class Exception extends PureComponent {
+ exceptionLine;
+ markText;
+
+ static get propTypes() {
+ return {
+ exception: PropTypes.object.isRequired,
+ doc: PropTypes.object.isRequired,
+ selectedSource: PropTypes.string.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addEditorExceptionLine();
+ }
+
+ componentDidUpdate() {
+ this.clearEditorExceptionLine();
+ this.addEditorExceptionLine();
+ }
+
+ componentWillUnmount() {
+ this.clearEditorExceptionLine();
+ }
+
+ setEditorExceptionLine(doc, line, column, lineText) {
+ doc.addLineClass(line, "wrap", "line-exception");
+
+ column = Math.max(column, getIndentation(lineText));
+ const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null;
+
+ const markText = doc.markText(
+ { ch: column, line },
+ { ch: columnEnd, line },
+ { className: "mark-text-exception" }
+ );
+
+ this.exceptionLine = line;
+ this.markText = markText;
+ }
+
+ addEditorExceptionLine() {
+ const { exception, doc, selectedSource } = this.props;
+ const { columnNumber, lineNumber } = exception;
+
+ if (!hasDocument(selectedSource.id)) {
+ return;
+ }
+
+ const location = createLocation({
+ column: columnNumber - 1,
+ line: lineNumber,
+ source: selectedSource,
+ });
+
+ const { line, column } = toEditorPosition(location);
+ const lineText = doc.getLine(line);
+
+ this.setEditorExceptionLine(doc, line, column, lineText);
+ }
+
+ clearEditorExceptionLine() {
+ if (this.markText) {
+ const { selectedSource } = this.props;
+
+ this.markText.clear();
+
+ if (hasDocument(selectedSource.id)) {
+ this.props.doc.removeLineClass(
+ this.exceptionLine,
+ "wrap",
+ "line-exception"
+ );
+ }
+ this.exceptionLine = null;
+ this.markText = null;
+ }
+ }
+
+ // This component is only used as a "proxy" to manipulate the editor.
+ render() {
+ return null;
+ }
+}
diff --git a/devtools/client/debugger/src/components/Editor/Exceptions.js b/devtools/client/debugger/src/components/Editor/Exceptions.js
new file mode 100644
index 0000000000..d1bac48b1b
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Exceptions.js
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+import Exception from "./Exception";
+
+import {
+ getSelectedSource,
+ getSelectedSourceExceptions,
+} from "../../selectors";
+import { getDocument } from "../../utils/editor";
+
+class Exceptions extends Component {
+ static get propTypes() {
+ return {
+ exceptions: PropTypes.array,
+ selectedSource: PropTypes.object,
+ };
+ }
+
+ render() {
+ const { exceptions, selectedSource } = this.props;
+
+ if (!selectedSource || !exceptions.length) {
+ return null;
+ }
+
+ const doc = getDocument(selectedSource.id);
+
+ return (
+ <>
+ {exceptions.map(exc => (
+ <Exception
+ exception={exc}
+ doc={doc}
+ key={`${exc.sourceActorId}:${exc.lineNumber}`}
+ selectedSource={selectedSource}
+ />
+ ))}
+ </>
+ );
+ }
+}
+
+export default connect(state => {
+ const selectedSource = getSelectedSource(state);
+
+ // Avoid calling getSelectedSourceExceptions when there is no source selected.
+ if (!selectedSource) {
+ return {};
+ }
+
+ // Avoid causing any update until we start having exceptions
+ const exceptions = getSelectedSourceExceptions(state);
+ if (!exceptions.length) {
+ return {};
+ }
+
+ return {
+ exceptions: getSelectedSourceExceptions(state),
+ selectedSource,
+ };
+})(Exceptions);
diff --git a/devtools/client/debugger/src/components/Editor/Footer.css b/devtools/client/debugger/src/components/Editor/Footer.css
new file mode 100644
index 0000000000..aee6c51d38
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Footer.css
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.source-footer {
+ background: var(--theme-body-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ position: absolute;
+ display: flex;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ opacity: 1;
+ z-index: 1;
+ width: calc(100% - 1px);
+ user-select: none;
+ height: var(--editor-footer-height);
+ box-sizing: border-box;
+}
+
+.source-footer-start {
+ display: flex;
+ align-items: center;
+ justify-self: start;
+}
+
+.source-footer-end {
+ display: flex;
+ margin-left: auto;
+}
+
+.source-footer .commands * {
+ user-select: none;
+}
+
+.source-footer .commands {
+ display: flex;
+}
+
+.source-footer .commands .action {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: opacity 200ms;
+ border: none;
+ background: transparent;
+ padding: 4px 6px;
+}
+
+.source-footer .commands button.action:hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+:root.theme-dark .source-footer .commands .action {
+ fill: var(--theme-body-color);
+}
+
+:root.theme-dark .source-footer .commands .action:hover {
+ fill: var(--theme-selection-color);
+}
+
+.source-footer .blackboxed .img.blackBox {
+ background-color: #806414;
+}
+
+.source-footer .commands button.prettyPrint:disabled {
+ opacity: 0.6;
+}
+
+.source-footer .mapped-source,
+.source-footer .cursor-position {
+ color: var(--theme-body-color);
+ padding-right: 2.5px;
+}
+
+.source-footer .mapped-source {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.source-footer .cursor-position {
+ padding: 5px;
+ white-space: nowrap;
+}
diff --git a/devtools/client/debugger/src/components/Editor/Footer.js b/devtools/client/debugger/src/components/Editor/Footer.js
new file mode 100644
index 0000000000..ea9acbc6f6
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Footer.js
@@ -0,0 +1,302 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import { createLocation } from "../../utils/location";
+import actions from "../../actions";
+import {
+ getSelectedSource,
+ getSelectedLocation,
+ getSelectedSourceTextContent,
+ getPrettySource,
+ getPaneCollapse,
+ getContext,
+ getGeneratedSource,
+ isSourceBlackBoxed,
+ canPrettyPrintSource,
+ getPrettyPrintMessage,
+ isSourceOnSourceMapIgnoreList,
+ isSourceMapIgnoreListEnabled,
+} from "../../selectors";
+
+import { isPretty, getFilename, shouldBlackbox } from "../../utils/source";
+
+import { PaneToggleButton } from "../shared/Button";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./Footer.css";
+
+class SourceFooter extends PureComponent {
+ constructor() {
+ super();
+
+ this.state = { cursorPosition: { line: 0, column: 0 } };
+ }
+
+ static get propTypes() {
+ return {
+ canPrettyPrint: PropTypes.bool.isRequired,
+ prettyPrintMessage: PropTypes.string.isRequired,
+ cx: PropTypes.object.isRequired,
+ endPanelCollapsed: PropTypes.bool.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ jumpToMappedLocation: PropTypes.func.isRequired,
+ mappedSource: PropTypes.object,
+ selectedSource: PropTypes.object,
+ isSelectedSourceBlackBoxed: PropTypes.bool.isRequired,
+ sourceLoaded: PropTypes.bool.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ togglePaneCollapse: PropTypes.func.isRequired,
+ togglePrettyPrint: PropTypes.func.isRequired,
+ isSourceOnIgnoreList: PropTypes.bool.isRequired,
+ };
+ }
+
+ componentDidUpdate() {
+ const eventDoc = document.querySelector(".editor-mount .CodeMirror");
+ // querySelector can return null
+ if (eventDoc) {
+ this.toggleCodeMirror(eventDoc, true);
+ }
+ }
+
+ componentWillUnmount() {
+ const eventDoc = document.querySelector(".editor-mount .CodeMirror");
+
+ if (eventDoc) {
+ this.toggleCodeMirror(eventDoc, false);
+ }
+ }
+
+ toggleCodeMirror(eventDoc, toggle) {
+ if (toggle === true) {
+ eventDoc.CodeMirror.on("cursorActivity", this.onCursorChange);
+ } else {
+ eventDoc.CodeMirror.off("cursorActivity", this.onCursorChange);
+ }
+ }
+
+ prettyPrintButton() {
+ const {
+ cx,
+ selectedSource,
+ canPrettyPrint,
+ prettyPrintMessage,
+ togglePrettyPrint,
+ sourceLoaded,
+ } = this.props;
+
+ if (!selectedSource) {
+ return null;
+ }
+
+ if (!sourceLoaded && selectedSource.isPrettyPrinted) {
+ return (
+ <div className="action" key="pretty-loader">
+ <AccessibleImage className="loader spin" />
+ </div>
+ );
+ }
+
+ const type = "prettyPrint";
+ return (
+ <button
+ onClick={() => {
+ if (!canPrettyPrint) {
+ return;
+ }
+ togglePrettyPrint(cx, selectedSource.id);
+ }}
+ className={classnames("action", type, {
+ active: sourceLoaded && canPrettyPrint,
+ pretty: isPretty(selectedSource),
+ })}
+ key={type}
+ title={prettyPrintMessage}
+ aria-label={prettyPrintMessage}
+ disabled={!canPrettyPrint}
+ >
+ <AccessibleImage className={type} />
+ </button>
+ );
+ }
+
+ blackBoxButton() {
+ const {
+ cx,
+ selectedSource,
+ isSelectedSourceBlackBoxed,
+ toggleBlackBox,
+ sourceLoaded,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ if (!selectedSource || !shouldBlackbox(selectedSource)) {
+ return null;
+ }
+
+ let tooltip = isSelectedSourceBlackBoxed
+ ? L10N.getStr("sourceFooter.unignore")
+ : L10N.getStr("sourceFooter.ignore");
+
+ if (isSourceOnIgnoreList) {
+ tooltip = L10N.getStr("sourceFooter.ignoreList");
+ }
+
+ const type = "black-box";
+
+ return (
+ <button
+ onClick={() => toggleBlackBox(cx, selectedSource)}
+ className={classnames("action", type, {
+ active: sourceLoaded,
+ blackboxed: isSelectedSourceBlackBoxed || isSourceOnIgnoreList,
+ })}
+ key={type}
+ title={tooltip}
+ aria-label={tooltip}
+ disabled={isSourceOnIgnoreList}
+ >
+ <AccessibleImage className="blackBox" />
+ </button>
+ );
+ }
+
+ renderToggleButton() {
+ if (this.props.horizontal) {
+ return null;
+ }
+
+ return (
+ <PaneToggleButton
+ key="toggle"
+ collapsed={this.props.endPanelCollapsed}
+ horizontal={this.props.horizontal}
+ handleClick={this.props.togglePaneCollapse}
+ position="end"
+ />
+ );
+ }
+
+ renderCommands() {
+ const commands = [this.blackBoxButton(), this.prettyPrintButton()].filter(
+ Boolean
+ );
+
+ return commands.length ? <div className="commands">{commands}</div> : null;
+ }
+
+ renderSourceSummary() {
+ const { cx, mappedSource, jumpToMappedLocation, selectedSource } =
+ this.props;
+
+ if (!mappedSource || !selectedSource || !selectedSource.isOriginal) {
+ return null;
+ }
+
+ const filename = getFilename(mappedSource);
+ const tooltip = L10N.getFormatStr(
+ "sourceFooter.mappedSourceTooltip",
+ filename
+ );
+ const title = L10N.getFormatStr("sourceFooter.mappedSource", filename);
+ const mappedSourceLocation = createLocation({
+ source: selectedSource,
+ line: 1,
+ column: 1,
+ });
+ return (
+ <button
+ className="mapped-source"
+ onClick={() => jumpToMappedLocation(cx, mappedSourceLocation)}
+ title={tooltip}
+ >
+ <span>{title}</span>
+ </button>
+ );
+ }
+
+ onCursorChange = event => {
+ const { line, ch } = event.doc.getCursor();
+ this.setState({ cursorPosition: { line, column: ch } });
+ };
+
+ renderCursorPosition() {
+ if (!this.props.selectedSource) {
+ return null;
+ }
+
+ const { line, column } = this.state.cursorPosition;
+
+ const text = L10N.getFormatStr(
+ "sourceFooter.currentCursorPosition",
+ line + 1,
+ column + 1
+ );
+ const title = L10N.getFormatStr(
+ "sourceFooter.currentCursorPosition.tooltip",
+ line + 1,
+ column + 1
+ );
+ return (
+ <div className="cursor-position" title={title}>
+ {text}
+ </div>
+ );
+ }
+
+ render() {
+ return (
+ <div className="source-footer">
+ <div className="source-footer-start">{this.renderCommands()}</div>
+ <div className="source-footer-end">
+ {this.renderSourceSummary()}
+ {this.renderCursorPosition()}
+ {this.renderToggleButton()}
+ </div>
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ const selectedLocation = getSelectedLocation(state);
+ const sourceTextContent = getSelectedSourceTextContent(state);
+
+ return {
+ cx: getContext(state),
+ selectedSource,
+ isSelectedSourceBlackBoxed: selectedSource
+ ? isSourceBlackBoxed(state, selectedSource)
+ : null,
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, selectedSource),
+ sourceLoaded: !!sourceTextContent,
+ mappedSource: getGeneratedSource(state, selectedSource),
+ prettySource: getPrettySource(
+ state,
+ selectedSource ? selectedSource.id : null
+ ),
+ endPanelCollapsed: getPaneCollapse(state, "end"),
+ canPrettyPrint: selectedLocation
+ ? canPrettyPrintSource(state, selectedLocation)
+ : false,
+ prettyPrintMessage: selectedLocation
+ ? getPrettyPrintMessage(state, selectedLocation)
+ : null,
+ };
+};
+
+export default connect(mapStateToProps, {
+ togglePrettyPrint: actions.togglePrettyPrint,
+ toggleBlackBox: actions.toggleBlackBox,
+ jumpToMappedLocation: actions.jumpToMappedLocation,
+ togglePaneCollapse: actions.togglePaneCollapse,
+})(SourceFooter);
diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.css b/devtools/client/debugger/src/components/Editor/HighlightCalls.css
new file mode 100644
index 0000000000..b7e0402cab
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.css
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.highlight-function-calls {
+ background-color: rgba(202, 227, 255, 0.5);
+}
+
+.theme-dark .highlight-function-calls {
+ background-color: #743884;
+}
+
+.highlight-function-calls:hover {
+ cursor: default;
+}
diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.js b/devtools/client/debugger/src/components/Editor/HighlightCalls.js
new file mode 100644
index 0000000000..0063f66c7a
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.js
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import {
+ getHighlightedCalls,
+ getThreadContext,
+ getCurrentThread,
+} from "../../selectors";
+import { getSourceLocationFromMouseEvent } from "../../utils/editor";
+import actions from "../../actions";
+import "./HighlightCalls.css";
+
+export class HighlightCalls extends Component {
+ previousCalls = null;
+
+ static get propTypes() {
+ return {
+ continueToHere: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ highlightedCalls: PropTypes.array,
+ selectedSource: PropTypes.object,
+ };
+ }
+
+ componentDidUpdate() {
+ this.unhighlightFunctionCalls();
+ this.highlightFunctioCalls();
+ }
+
+ markCall = call => {
+ const { editor } = this.props;
+ const startLine = call.location.start.line - 1;
+ const endLine = call.location.end.line - 1;
+ const startColumn = call.location.start.column;
+ const endColumn = call.location.end.column;
+ const markedCall = editor.codeMirror.markText(
+ { line: startLine, ch: startColumn },
+ { line: endLine, ch: endColumn },
+ { className: "highlight-function-calls" }
+ );
+ return markedCall;
+ };
+
+ onClick = e => {
+ const { editor, selectedSource, cx, continueToHere } = this.props;
+
+ if (selectedSource) {
+ const location = getSourceLocationFromMouseEvent(
+ editor,
+ selectedSource,
+ e
+ );
+ continueToHere(cx, location);
+ editor.codeMirror.execCommand("singleSelection");
+ editor.codeMirror.execCommand("goGroupLeft");
+ }
+ };
+
+ highlightFunctioCalls() {
+ const { highlightedCalls } = this.props;
+
+ if (!highlightedCalls) {
+ return;
+ }
+
+ let markedCalls = [];
+ markedCalls = highlightedCalls.map(this.markCall);
+
+ const allMarkedElements = document.getElementsByClassName(
+ "highlight-function-calls"
+ );
+
+ for (let i = 0; i < allMarkedElements.length; i++) {
+ allMarkedElements[i].addEventListener("click", this.onClick);
+ }
+
+ this.previousCalls = markedCalls;
+ }
+
+ unhighlightFunctionCalls() {
+ if (!this.previousCalls) {
+ return;
+ }
+ this.previousCalls.forEach(call => call.clear());
+ this.previousCalls = null;
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ return {
+ highlightedCalls: getHighlightedCalls(state, thread),
+ cx: getThreadContext(state),
+ };
+};
+
+const { continueToHere } = actions;
+
+const mapDispatchToProps = { continueToHere };
+
+export default connect(mapStateToProps, mapDispatchToProps)(HighlightCalls);
diff --git a/devtools/client/debugger/src/components/Editor/HighlightLine.js b/devtools/client/debugger/src/components/Editor/HighlightLine.js
new file mode 100644
index 0000000000..3df0142127
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightLine.js
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "react";
+import PropTypes from "prop-types";
+import { toEditorLine, endOperation, startOperation } from "../../utils/editor";
+import { getDocument, hasDocument } from "../../utils/editor/source-documents";
+
+import { connect } from "../../utils/connect";
+import {
+ getVisibleSelectedFrame,
+ getSelectedLocation,
+ getSelectedSourceTextContent,
+ getPauseCommand,
+ getCurrentThread,
+} from "../../selectors";
+
+function isDebugLine(selectedFrame, selectedLocation) {
+ if (!selectedFrame) {
+ return false;
+ }
+
+ return (
+ selectedFrame.location.sourceId == selectedLocation.sourceId &&
+ selectedFrame.location.line == selectedLocation.line
+ );
+}
+
+function isDocumentReady(selectedLocation, selectedSourceTextContent) {
+ return (
+ selectedLocation &&
+ selectedSourceTextContent &&
+ hasDocument(selectedLocation.sourceId)
+ );
+}
+
+export class HighlightLine extends Component {
+ isStepping = false;
+ previousEditorLine = null;
+
+ static get propTypes() {
+ return {
+ pauseCommand: PropTypes.oneOf([
+ "expression",
+ "resume",
+ "stepOver",
+ "stepIn",
+ "stepOut",
+ ]),
+ selectedFrame: PropTypes.object,
+ selectedLocation: PropTypes.object.isRequired,
+ selectedSourceTextContent: PropTypes.object.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps) {
+ const { selectedLocation, selectedSourceTextContent } = nextProps;
+ return this.shouldSetHighlightLine(
+ selectedLocation,
+ selectedSourceTextContent
+ );
+ }
+
+ componentDidUpdate(prevProps) {
+ this.completeHighlightLine(prevProps);
+ }
+
+ componentDidMount() {
+ this.completeHighlightLine(null);
+ }
+
+ shouldSetHighlightLine(selectedLocation, selectedSourceTextContent) {
+ const { sourceId, line } = selectedLocation;
+ const editorLine = toEditorLine(sourceId, line);
+
+ if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) {
+ return false;
+ }
+
+ if (this.isStepping && editorLine === this.previousEditorLine) {
+ return false;
+ }
+
+ return true;
+ }
+
+ completeHighlightLine(prevProps) {
+ const {
+ pauseCommand,
+ selectedLocation,
+ selectedFrame,
+ selectedSourceTextContent,
+ } = this.props;
+ if (pauseCommand) {
+ this.isStepping = true;
+ }
+
+ startOperation();
+ if (prevProps) {
+ this.clearHighlightLine(
+ prevProps.selectedLocation,
+ prevProps.selectedSourceTextContent
+ );
+ }
+ this.setHighlightLine(
+ selectedLocation,
+ selectedFrame,
+ selectedSourceTextContent
+ );
+ endOperation();
+ }
+
+ setHighlightLine(selectedLocation, selectedFrame, selectedSourceTextContent) {
+ const { sourceId, line } = selectedLocation;
+ if (
+ !this.shouldSetHighlightLine(selectedLocation, selectedSourceTextContent)
+ ) {
+ return;
+ }
+
+ this.isStepping = false;
+ const editorLine = toEditorLine(sourceId, line);
+ this.previousEditorLine = editorLine;
+
+ if (!line || isDebugLine(selectedFrame, selectedLocation)) {
+ return;
+ }
+
+ const doc = getDocument(sourceId);
+ doc.addLineClass(editorLine, "wrap", "highlight-line");
+ this.resetHighlightLine(doc, editorLine);
+ }
+
+ resetHighlightLine(doc, editorLine) {
+ const editorWrapper = document.querySelector(".editor-wrapper");
+
+ if (editorWrapper === null) {
+ return;
+ }
+
+ const duration = parseInt(
+ getComputedStyle(editorWrapper).getPropertyValue(
+ "--highlight-line-duration"
+ ),
+ 10
+ );
+
+ setTimeout(
+ () => doc && doc.removeLineClass(editorLine, "wrap", "highlight-line"),
+ duration
+ );
+ }
+
+ clearHighlightLine(selectedLocation, selectedSourceTextContent) {
+ if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) {
+ return;
+ }
+
+ const { line, sourceId } = selectedLocation;
+ const editorLine = toEditorLine(sourceId, line);
+ const doc = getDocument(sourceId);
+ doc.removeLineClass(editorLine, "wrap", "highlight-line");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default connect(state => {
+ const selectedLocation = getSelectedLocation(state);
+
+ if (!selectedLocation) {
+ throw new Error("must have selected location");
+ }
+ return {
+ pauseCommand: getPauseCommand(state, getCurrentThread(state)),
+ selectedFrame: getVisibleSelectedFrame(state),
+ selectedLocation,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ };
+})(HighlightLine);
diff --git a/devtools/client/debugger/src/components/Editor/HighlightLines.js b/devtools/client/debugger/src/components/Editor/HighlightLines.js
new file mode 100644
index 0000000000..bffa209e7d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightLines.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "react";
+import PropTypes from "prop-types";
+
+class HighlightLines extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ range: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.highlightLineRange();
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate() {
+ this.clearHighlightRange();
+ }
+
+ componentDidUpdate() {
+ this.highlightLineRange();
+ }
+
+ componentWillUnmount() {
+ this.clearHighlightRange();
+ }
+
+ clearHighlightRange() {
+ const { range, editor } = this.props;
+
+ const { codeMirror } = editor;
+
+ if (!range || !codeMirror) {
+ return;
+ }
+
+ const { start, end } = range;
+ codeMirror.operation(() => {
+ for (let line = start - 1; line < end; line++) {
+ codeMirror.removeLineClass(line, "wrap", "highlight-lines");
+ }
+ });
+ }
+
+ highlightLineRange = () => {
+ const { range, editor } = this.props;
+
+ const { codeMirror } = editor;
+
+ if (!range || !codeMirror) {
+ return;
+ }
+
+ const { start, end } = range;
+
+ codeMirror.operation(() => {
+ editor.alignLine(start);
+ for (let line = start - 1; line < end; line++) {
+ codeMirror.addLineClass(line, "wrap", "highlight-lines");
+ }
+ });
+ };
+
+ render() {
+ return null;
+ }
+}
+
+export default HighlightLines;
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.css b/devtools/client/debugger/src/components/Editor/InlinePreview.css
new file mode 100644
index 0000000000..13f1b5e23c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreview.css
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.inline-preview {
+ display: inline-block;
+ margin-inline-start: 8px;
+ user-select: none;
+}
+
+.inline-preview-outer {
+ background-color: var(--theme-inline-preview-background);
+ border: 1px solid var(--theme-inline-preview-border-color);
+ border-radius: 3px;
+ font-size: 10px;
+ margin-right: 5px;
+ white-space: nowrap;
+}
+
+.inline-preview-label {
+ padding: 0px 2px 0px 4px;
+ border-radius: 2px 0 0 2px;
+ color: var(--theme-inline-preview-label-color);
+ background-color: var(--theme-inline-preview-label-background);
+}
+
+.inline-preview-value {
+ padding: 2px 6px;
+}
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.js b/devtools/client/debugger/src/components/Editor/InlinePreview.js
new file mode 100644
index 0000000000..f978965134
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreview.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import Reps from "devtools/client/shared/components/reps/index";
+
+const {
+ REPS: {
+ Rep,
+ ElementNode: { supportsObject: isElement },
+ },
+ MODE,
+} = Reps;
+
+// Renders single variable preview inside a codemirror line widget
+class InlinePreview extends PureComponent {
+ static get propTypes() {
+ return {
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ value: PropTypes.any,
+ variable: PropTypes.string.isRequired,
+ };
+ }
+
+ showInScopes(variable) {
+ // TODO: focus on variable value in the scopes sidepanel
+ // we will need more info from parent comp
+ }
+
+ render() {
+ const {
+ value,
+ variable,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const mode = isElement(value) ? MODE.TINY : MODE.SHORT;
+
+ return (
+ <span
+ className="inline-preview-outer"
+ onClick={() => this.showInScopes(variable)}
+ >
+ <span className="inline-preview-label">{variable}:</span>
+ <span className="inline-preview-value">
+ <Rep
+ object={value}
+ mode={mode}
+ onDOMNodeClick={grip => openElementInInspector(grip)}
+ onInspectIconClick={grip => openElementInInspector(grip)}
+ onDOMNodeMouseOver={grip => highlightDomElement(grip)}
+ onDOMNodeMouseOut={grip => unHighlightDomElement(grip)}
+ />
+ </span>
+ </span>
+ );
+ }
+}
+
+export default InlinePreview;
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js
new file mode 100644
index 0000000000..ad2631e01e
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import ReactDOM from "react-dom";
+
+import actions from "../../actions";
+import assert from "../../utils/assert";
+import { connect } from "../../utils/connect";
+import InlinePreview from "./InlinePreview";
+
+import "./InlinePreview.css";
+
+// Handles rendering for each line ( row )
+// * Renders single widget for each line in codemirror
+// * Renders InlinePreview for each preview inside the widget
+class InlinePreviewRow extends PureComponent {
+ bookmark;
+ widgetNode;
+
+ componentDidMount() {
+ this.updatePreviewWidget(this.props, null);
+ }
+
+ componentDidUpdate(prevProps) {
+ this.updatePreviewWidget(this.props, prevProps);
+ }
+
+ componentWillUnmount() {
+ this.updatePreviewWidget(null, this.props);
+ }
+
+ updatePreviewWidget(props, prevProps) {
+ if (
+ this.bookmark &&
+ prevProps &&
+ (!props ||
+ prevProps.editor !== props.editor ||
+ prevProps.line !== props.line)
+ ) {
+ this.bookmark.clear();
+ this.bookmark = null;
+ this.widgetNode = null;
+ }
+
+ if (!props) {
+ assert(!this.bookmark, "Inline Preview widget shouldn't be present.");
+ return;
+ }
+
+ const {
+ editor,
+ line,
+ previews,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = props;
+
+ if (!this.bookmark) {
+ this.widgetNode = document.createElement("div");
+ this.widgetNode.classList.add("inline-preview");
+ }
+
+ ReactDOM.render(
+ <React.Fragment>
+ {previews.map(preview => (
+ <InlinePreview
+ line={line}
+ key={`${line}-${preview.name}`}
+ variable={preview.name}
+ value={preview.value}
+ openElementInInspector={openElementInInspector}
+ highlightDomElement={highlightDomElement}
+ unHighlightDomElement={unHighlightDomElement}
+ />
+ ))}
+ </React.Fragment>,
+ this.widgetNode
+ );
+
+ this.bookmark = editor.codeMirror.setBookmark(
+ {
+ line,
+ ch: Infinity,
+ },
+ this.widgetNode
+ );
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default connect(() => ({}), {
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+})(InlinePreviewRow);
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviews.js b/devtools/client/debugger/src/components/Editor/InlinePreviews.js
new file mode 100644
index 0000000000..8778cb373c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreviews.js
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import InlinePreviewRow from "./InlinePreviewRow";
+import { connect } from "../../utils/connect";
+import {
+ getSelectedFrame,
+ getCurrentThread,
+ getInlinePreviews,
+} from "../../selectors";
+
+function hasPreviews(previews) {
+ return !!previews && !!Object.keys(previews).length;
+}
+
+class InlinePreviews extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ previews: PropTypes.object,
+ selectedFrame: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ };
+ }
+
+ shouldComponentUpdate({ previews }) {
+ return hasPreviews(previews);
+ }
+
+ render() {
+ const { editor, selectedFrame, selectedSource, previews } = this.props;
+
+ // Render only if currently open file is the one where debugger is paused
+ if (
+ !selectedFrame ||
+ selectedFrame.location.sourceId !== selectedSource.id ||
+ !hasPreviews(previews)
+ ) {
+ return null;
+ }
+ const previewsObj = previews;
+
+ let inlinePreviewRows;
+ editor.codeMirror.operation(() => {
+ inlinePreviewRows = Object.keys(previewsObj).map(line => {
+ const lineNum = parseInt(line, 10);
+
+ return (
+ <InlinePreviewRow
+ editor={editor}
+ key={line}
+ line={lineNum}
+ previews={previewsObj[line]}
+ />
+ );
+ });
+ });
+
+ return <div>{inlinePreviewRows}</div>;
+ }
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ const selectedFrame = getSelectedFrame(state, thread);
+
+ if (!selectedFrame) {
+ return {
+ selectedFrame: null,
+ previews: null,
+ };
+ }
+
+ return {
+ selectedFrame,
+ previews: getInlinePreviews(state, thread, selectedFrame.id),
+ };
+};
+
+export default connect(mapStateToProps)(InlinePreviews);
diff --git a/devtools/client/debugger/src/components/Editor/Preview.css b/devtools/client/debugger/src/components/Editor/Preview.css
new file mode 100644
index 0000000000..35b874315e
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview.css
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.popover .preview {
+ background: var(--theme-body-background);
+ width: 350px;
+ border: 1px solid var(--theme-splitter-color);
+ padding: 10px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.theme-dark .popover .preview {
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.popover .preview .header {
+ width: 100%;
+ line-height: 20px;
+ border-bottom: 1px solid #cccccc;
+ display: flex;
+ flex-direction: column;
+}
+
+.popover .preview .header .link {
+ align-self: flex-end;
+ color: var(--theme-highlight-blue);
+ text-decoration: underline;
+}
+
+.selection,
+.debug-expression.selection {
+ background-color: var(--theme-highlight-yellow);
+}
+
+.theme-dark .selection,
+.theme-dark .debug-expression.selection {
+ background-color: #743884;
+}
+
+.theme-dark .cm-s-mozilla .selection,
+.theme-dark .cm-s-mozilla .debug-expression.selection {
+ color: #e7ebee;
+}
+
+.popover .preview .function-signature {
+ padding-top: 10px;
+}
+
+.theme-dark .popover .preview {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip {
+ position: fixed;
+ z-index: 100;
+}
+
+.tooltip .preview {
+ background: var(--theme-toolbar-background);
+ max-width: inherit;
+ border: 1px solid var(--theme-splitter-color);
+ box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt);
+ padding: 5px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+}
+
+.theme-dark .tooltip .preview {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip .gap {
+ height: 4px;
+ padding-top: 4px;
+}
+
+.add-to-expression-bar {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: none;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ font-size: 14px;
+ line-height: 30px;
+ background: var(--theme-toolbar-background);
+ color: var(--theme-text-color-inactive);
+ padding: 0 4px;
+}
+
+.add-to-expression-bar .prompt {
+ width: 1em;
+}
+
+.add-to-expression-bar .expression-to-save-label {
+ width: calc(100% - 4em);
+}
+
+.add-to-expression-bar .expression-to-save-button {
+ font-size: 14px;
+ color: var(--theme-comment);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js
new file mode 100644
index 0000000000..624a78fb8b
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../../utils/connect";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { StringRep },
+} = Reps;
+
+import actions from "../../../actions";
+
+import { getThreadContext } from "../../../selectors";
+
+import AccessibleImage from "../../shared/AccessibleImage";
+
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const classnames = require("devtools/client/shared/classnames.js");
+
+const POPUP_SELECTOR = ".preview-popup.exception-popup";
+const ANONYMOUS_FN_NAME = "<anonymous>";
+
+// The exception popup works in two modes:
+// a. when the stacktrace is closed the exception popup
+// gets closed when the mouse leaves the popup.
+// b. when the stacktrace is opened the exception popup
+// gets closed only by clicking outside the popup.
+class ExceptionPopup extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isStacktraceExpanded: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ mouseout: PropTypes.func.isRequired,
+ selectSourceURL: PropTypes.func.isRequired,
+ exception: PropTypes.object.isRequired,
+ };
+ }
+
+ updateTopWindow() {
+ // The ChromeWindow is used when the stacktrace is expanded to capture all clicks
+ // outside the popup so the popup can be closed only by clicking outside of it.
+ if (this.topWindow) {
+ this.topWindow.removeEventListener(
+ "mousedown",
+ this.onTopWindowClick,
+ true
+ );
+ this.topWindow = null;
+ }
+ this.topWindow = DevToolsUtils.getTopWindow(window.parent);
+ this.topWindow.addEventListener("mousedown", this.onTopWindowClick, true);
+ }
+
+ onTopWindowClick = e => {
+ const { cx, clearPreview } = this.props;
+
+ // When the stactrace is expaned the exception popup gets closed
+ // only by clicking ouside the popup.
+ if (!e.target.closest(POPUP_SELECTOR)) {
+ clearPreview(cx);
+ }
+ };
+
+ onExceptionMessageClick() {
+ const isStacktraceExpanded = this.state.isStacktraceExpanded;
+
+ this.updateTopWindow();
+ this.setState({ isStacktraceExpanded: !isStacktraceExpanded });
+ }
+
+ buildStackFrame(frame) {
+ const { cx, selectSourceURL } = this.props;
+ const { filename, lineNumber } = frame;
+ const functionName = frame.functionName || ANONYMOUS_FN_NAME;
+
+ return (
+ <div
+ className="frame"
+ onClick={() => selectSourceURL(cx, filename, { line: lineNumber })}
+ >
+ <span className="title">{functionName}</span>
+ <span className="location">
+ <span className="filename">{filename}</span>:
+ <span className="line">{lineNumber}</span>
+ </span>
+ </div>
+ );
+ }
+
+ renderStacktrace(stacktrace) {
+ const isStacktraceExpanded = this.state.isStacktraceExpanded;
+
+ if (stacktrace.length && isStacktraceExpanded) {
+ return (
+ <div className="exception-stacktrace">
+ {stacktrace.map(frame => this.buildStackFrame(frame))}
+ </div>
+ );
+ }
+ return null;
+ }
+
+ renderArrowIcon(stacktrace) {
+ if (stacktrace.length) {
+ return (
+ <AccessibleImage
+ className={classnames("arrow", {
+ expanded: this.state.isStacktraceExpanded,
+ })}
+ />
+ );
+ }
+ return null;
+ }
+
+ render() {
+ const {
+ exception: { stacktrace, errorMessage },
+ mouseout,
+ } = this.props;
+
+ return (
+ <div
+ className="preview-popup exception-popup"
+ dir="ltr"
+ onMouseLeave={() => mouseout(true, this.state.isStacktraceExpanded)}
+ >
+ <div
+ className="exception-message"
+ onClick={() => this.onExceptionMessageClick()}
+ >
+ {this.renderArrowIcon(stacktrace)}
+ {StringRep.rep({
+ object: errorMessage,
+ useQuotes: false,
+ className: "exception-text",
+ })}
+ </div>
+ {this.renderStacktrace(stacktrace)}
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+});
+
+const mapDispatchToProps = {
+ selectSourceURL: actions.selectSourceURL,
+ clearPreview: actions.clearPreview,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ExceptionPopup);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.css b/devtools/client/debugger/src/components/Editor/Preview/Popup.css
new file mode 100644
index 0000000000..3e578becf1
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.css
@@ -0,0 +1,209 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.popover .preview-popup {
+ background: var(--theme-body-background);
+ width: 350px;
+ border: 1px solid var(--theme-splitter-color);
+ padding: 10px;
+ height: auto;
+ overflow: auto;
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.preview-popup .tree {
+ /* Setting a fixed line height to avoid issues in custom formatters changing
+ * the line height like the CLJS DevTools */
+ line-height: 15px;
+}
+
+.gap svg {
+ pointer-events: none;
+}
+
+.gap polygon {
+ pointer-events: auto;
+}
+
+.theme-dark .popover .preview-popup {
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.popover .preview-popup .header-container {
+ width: 100%;
+ line-height: 15px;
+ display: flex;
+ flex-direction: row;
+ margin-bottom: 5px;
+}
+
+.popover .preview-popup .logo {
+ width: 20px;
+ margin-right: 5px;
+}
+
+.popover .preview-popup .header-container h3 {
+ margin: 0;
+ margin-bottom: 5px;
+ font-weight: normal;
+ font-size: 14px;
+ line-height: 20px;
+ margin-left: 4px;
+}
+
+.popover .preview-popup .header .link {
+ align-self: flex-end;
+ color: var(--theme-highlight-blue);
+ text-decoration: underline;
+}
+
+.popover .preview-popup .object-node {
+ padding-inline-start: 0px;
+}
+
+.preview-token:hover {
+ cursor: default;
+}
+
+.preview-token,
+.debug-expression.preview-token {
+ background-color: var(--theme-highlight-yellow);
+}
+
+.theme-dark .preview-token,
+.theme-dark .debug-expression.preview-token {
+ background-color: #743884;
+}
+
+.theme-dark .cm-s-mozilla .preview-token,
+.theme-dark .cm-s-mozilla .debug-expression.preview-token {
+ color: #e7ebee;
+}
+
+.popover .preview-popup .function-signature {
+ padding-top: 10px;
+}
+
+.theme-dark .popover .preview-popup {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip {
+ position: fixed;
+ z-index: 100;
+}
+
+.tooltip .preview-popup {
+ background: var(--theme-toolbar-background);
+ max-width: inherit;
+ border: 1px solid var(--theme-splitter-color);
+ box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt);
+ padding: 5px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+}
+
+.theme-dark .tooltip .preview-popup {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip .gap {
+ height: 4px;
+ padding-top: 0px;
+}
+
+.add-to-expression-bar {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: none;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ font-size: 14px;
+ line-height: 30px;
+ background: var(--theme-toolbar-background);
+ color: var(--theme-text-color-inactive);
+ padding: 0 4px;
+}
+
+.add-to-expression-bar .prompt {
+ width: 1em;
+}
+
+.add-to-expression-bar .expression-to-save-label {
+ width: calc(100% - 4em);
+}
+
+.add-to-expression-bar .expression-to-save-button {
+ font-size: 14px;
+ color: var(--theme-comment);
+}
+
+/* Exception popup */
+.exception-popup .exception-text {
+ color: var(--red-70);
+}
+
+.theme-dark .exception-popup .exception-text {
+ color: var(--red-20);
+}
+
+.exception-popup .exception-message {
+ display: flex;
+ align-items: center;
+}
+
+.exception-message .arrow {
+ margin-inline-end: 4px;
+}
+
+.exception-popup .exception-stacktrace {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-column-gap: 8px;
+ padding-inline: 2px 3px;
+ line-height: var(--theme-code-line-height);
+}
+
+.exception-stacktrace .frame {
+ display: contents;
+ cursor: pointer;
+}
+
+.exception-stacktrace .title {
+ grid-column: 1/2;
+ color: var(--grey-90);
+}
+
+.theme-dark .exception-stacktrace .title {
+ color: white;
+}
+
+.exception-stacktrace .location {
+ grid-column: -1/-2;
+ color: var(--theme-highlight-purple);
+ direction: rtl;
+ text-align: end;
+ white-space: nowrap;
+ /* Force the location to be on one line and crop at start if wider then max-width */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 350px;
+}
+
+.theme-dark .exception-stacktrace .location {
+ color: var(--blue-40);
+}
+
+.exception-stacktrace .line {
+ color: var(--theme-highlight-blue);
+}
+
+.theme-dark .exception-stacktrace .line {
+ color: hsl(210, 40%, 60%);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.js b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
new file mode 100644
index 0000000000..3097d3c945
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
@@ -0,0 +1,382 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../../utils/connect";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { Rep },
+ MODE,
+ objectInspector,
+} = Reps;
+
+const { ObjectInspector, utils } = objectInspector;
+
+const {
+ node: { nodeIsPrimitive, nodeIsFunction, nodeIsObject },
+} = utils;
+
+import ExceptionPopup from "./ExceptionPopup";
+
+import actions from "../../../actions";
+import { getThreadContext } from "../../../selectors";
+import Popover from "../../shared/Popover";
+import PreviewFunction from "../../shared/PreviewFunction";
+
+import "./Popup.css";
+
+export class Popup extends Component {
+ constructor(props) {
+ super(props);
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ openLink: PropTypes.func.isRequired,
+ preview: PropTypes.object.isRequired,
+ selectSourceURL: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addHighlightToToken();
+ }
+
+ componentWillUnmount() {
+ this.removeHighlightFromToken();
+ }
+
+ addHighlightToToken() {
+ const { target } = this.props.preview;
+ if (target) {
+ target.classList.add("preview-token");
+ addHighlightToTargetSiblings(target, this.props);
+ }
+ }
+
+ removeHighlightFromToken() {
+ const { target } = this.props.preview;
+ if (target) {
+ target.classList.remove("preview-token");
+ removeHighlightForTargetSiblings(target);
+ }
+ }
+
+ calculateMaxHeight = () => {
+ const { editorRef } = this.props;
+ if (!editorRef) {
+ return "auto";
+ }
+
+ const { height, top } = editorRef.getBoundingClientRect();
+ const maxHeight = height + top;
+ if (maxHeight < 250) {
+ return maxHeight;
+ }
+
+ return 250;
+ };
+
+ createElement(element) {
+ return document.createElement(element);
+ }
+
+ renderFunctionPreview() {
+ const {
+ cx,
+ selectSourceURL,
+ preview: { resultGrip },
+ } = this.props;
+
+ if (!resultGrip) {
+ return null;
+ }
+
+ const { location } = resultGrip;
+
+ return (
+ <div
+ className="preview-popup"
+ onClick={() =>
+ location &&
+ selectSourceURL(cx, location.url, {
+ line: location.line,
+ })
+ }
+ >
+ <PreviewFunction func={resultGrip} />
+ </div>
+ );
+ }
+
+ renderObjectPreview() {
+ const {
+ preview: { root, properties },
+ openLink,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const usesCustomFormatter =
+ root?.contents?.value?.useCustomFormatter ?? false;
+
+ if (!properties.length) {
+ return (
+ <div className="preview-popup">
+ <span className="label">{L10N.getStr("preview.noProperties")}</span>
+ </div>
+ );
+ }
+
+ const roots = usesCustomFormatter ? [root] : properties;
+
+ return (
+ <div
+ className="preview-popup"
+ style={{ maxHeight: this.calculateMaxHeight() }}
+ >
+ <ObjectInspector
+ roots={roots}
+ autoExpandDepth={0}
+ autoReleaseObjectActors={false}
+ mode={usesCustomFormatter ? MODE.LONG : null}
+ disableWrap={true}
+ focusable={false}
+ openLink={openLink}
+ createElement={this.createElement}
+ onDOMNodeClick={grip => openElementInInspector(grip)}
+ onInspectIconClick={grip => openElementInInspector(grip)}
+ onDOMNodeMouseOver={grip => highlightDomElement(grip)}
+ onDOMNodeMouseOut={grip => unHighlightDomElement(grip)}
+ mayUseCustomFormatter={true}
+ />
+ </div>
+ );
+ }
+
+ renderSimplePreview() {
+ const {
+ openLink,
+ preview: { resultGrip },
+ } = this.props;
+ return (
+ <div className="preview-popup">
+ {Rep({
+ object: resultGrip,
+ mode: MODE.LONG,
+ openLink,
+ })}
+ </div>
+ );
+ }
+
+ renderExceptionPreview(exception) {
+ return (
+ <ExceptionPopup
+ exception={exception}
+ mouseout={this.onMouseOutException}
+ />
+ );
+ }
+
+ renderPreview() {
+ // We don't have to check and
+ // return on `false`, `""`, `0`, `undefined` etc,
+ // these falsy simple typed value because we want to
+ // do `renderSimplePreview` on these values below.
+ const {
+ preview: { root, exception },
+ } = this.props;
+
+ if (nodeIsFunction(root)) {
+ return this.renderFunctionPreview();
+ }
+
+ if (nodeIsObject(root)) {
+ return <div>{this.renderObjectPreview()}</div>;
+ }
+
+ if (exception) {
+ return this.renderExceptionPreview(exception);
+ }
+
+ return this.renderSimplePreview();
+ }
+
+ getPreviewType() {
+ const {
+ preview: { root, properties, exception },
+ } = this.props;
+ if (
+ exception ||
+ nodeIsPrimitive(root) ||
+ nodeIsFunction(root) ||
+ !Array.isArray(properties) ||
+ properties.length === 0
+ ) {
+ return "tooltip";
+ }
+
+ return "popover";
+ }
+
+ onMouseOut = () => {
+ const { clearPreview, cx } = this.props;
+
+ clearPreview(cx);
+ };
+
+ onMouseOutException = (shouldClearOnMouseout, isExceptionStactraceOpen) => {
+ // onMouseOutException can be called:
+ // a. when the mouse leaves Popover element
+ // b. when the mouse leaves ExceptionPopup element
+ // We want to prevent closing the popup when the stacktrace
+ // is expanded and the mouse leaves either the Popover element
+ // or the ExceptionPopup element.
+ const { clearPreview, cx } = this.props;
+
+ if (shouldClearOnMouseout) {
+ this.isExceptionStactraceOpen = isExceptionStactraceOpen;
+ }
+
+ if (!this.isExceptionStactraceOpen) {
+ clearPreview(cx);
+ }
+ };
+
+ render() {
+ const {
+ preview: { cursorPos, resultGrip, exception },
+ editorRef,
+ } = this.props;
+
+ if (
+ !exception &&
+ (typeof resultGrip == "undefined" || resultGrip?.optimizedOut)
+ ) {
+ return null;
+ }
+
+ const type = this.getPreviewType();
+ return (
+ <Popover
+ targetPosition={cursorPos}
+ type={type}
+ editorRef={editorRef}
+ target={this.props.preview.target}
+ mouseout={exception ? this.onMouseOutException : this.onMouseOut}
+ >
+ {this.renderPreview()}
+ </Popover>
+ );
+ }
+}
+
+export function addHighlightToTargetSiblings(target, props) {
+ // This function searches for related tokens that should also be highlighted when previewed.
+ // Here is the process:
+ // It conducts a search on the target's next siblings and then another search for the previous siblings.
+ // If a sibling is not an element node (nodeType === 1), the highlight is not added and the search is short-circuited.
+ // If the element sibling is the same token type as the target, and is also found in the preview expression, the highlight class is added.
+
+ const tokenType = target.classList.item(0);
+ const previewExpression = props.preview.expression;
+
+ if (
+ tokenType &&
+ previewExpression &&
+ target.innerHTML !== previewExpression
+ ) {
+ let nextSibling = target.nextSibling;
+ let nextElementSibling = target.nextElementSibling;
+
+ // Note: Declaring previous/next ELEMENT siblings as well because
+ // properties like innerHTML can't be checked on nextSibling
+ // without creating a flow error even if the node is an element type.
+ while (
+ nextSibling &&
+ nextElementSibling &&
+ nextSibling.nodeType === 1 &&
+ nextElementSibling.className.includes(tokenType) &&
+ previewExpression.includes(nextElementSibling.innerHTML)
+ ) {
+ // All checks passed, add highlight and continue the search.
+ nextElementSibling.classList.add("preview-token");
+
+ nextSibling = nextSibling.nextSibling;
+ nextElementSibling = nextElementSibling.nextElementSibling;
+ }
+
+ let previousSibling = target.previousSibling;
+ let previousElementSibling = target.previousElementSibling;
+
+ while (
+ previousSibling &&
+ previousElementSibling &&
+ previousSibling.nodeType === 1 &&
+ previousElementSibling.className.includes(tokenType) &&
+ previewExpression.includes(previousElementSibling.innerHTML)
+ ) {
+ // All checks passed, add highlight and continue the search.
+ previousElementSibling.classList.add("preview-token");
+
+ previousSibling = previousSibling.previousSibling;
+ previousElementSibling = previousElementSibling.previousElementSibling;
+ }
+ }
+}
+
+export function removeHighlightForTargetSiblings(target) {
+ // Look at target's previous and next token siblings.
+ // If they also have the highlight class 'preview-token',
+ // remove that class.
+ let nextSibling = target.nextElementSibling;
+ while (nextSibling && nextSibling.className.includes("preview-token")) {
+ nextSibling.classList.remove("preview-token");
+ nextSibling = nextSibling.nextElementSibling;
+ }
+ let previousSibling = target.previousElementSibling;
+ while (
+ previousSibling &&
+ previousSibling.className.includes("preview-token")
+ ) {
+ previousSibling.classList.remove("preview-token");
+ previousSibling = previousSibling.previousElementSibling;
+ }
+}
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+});
+
+const {
+ addExpression,
+ selectSourceURL,
+ openLink,
+ openElementInInspectorCommand,
+ highlightDomElement,
+ unHighlightDomElement,
+ clearPreview,
+} = actions;
+
+const mapDispatchToProps = {
+ addExpression,
+ selectSourceURL,
+ openLink,
+ openElementInInspector: openElementInInspectorCommand,
+ highlightDomElement,
+ unHighlightDomElement,
+ clearPreview,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(Popup);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/index.js b/devtools/client/debugger/src/components/Editor/Preview/index.js
new file mode 100644
index 0000000000..0e2c70c557
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/index.js
@@ -0,0 +1,136 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import React, { PureComponent } from "react";
+import { connect } from "../../../utils/connect";
+
+import Popup from "./Popup";
+
+import {
+ getPreview,
+ getThreadContext,
+ getCurrentThread,
+ getHighlightedCalls,
+ getIsCurrentThreadPaused,
+} from "../../../selectors";
+import actions from "../../../actions";
+
+const EXCEPTION_MARKER = "mark-text-exception";
+
+class Preview extends PureComponent {
+ target = null;
+ constructor(props) {
+ super(props);
+ this.state = { selecting: false };
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ highlightedCalls: PropTypes.array,
+ isPaused: PropTypes.bool.isRequired,
+ preview: PropTypes.object,
+ setExceptionPreview: PropTypes.func.isRequired,
+ updatePreview: PropTypes.func.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.updateListeners();
+ }
+
+ componentWillUnmount() {
+ const { codeMirror } = this.props.editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirror.off("tokenenter", this.onTokenEnter);
+ codeMirror.off("scroll", this.onScroll);
+ codeMirrorWrapper.removeEventListener("mouseup", this.onMouseUp);
+ codeMirrorWrapper.removeEventListener("mousedown", this.onMouseDown);
+ }
+
+ updateListeners(prevProps) {
+ const { codeMirror } = this.props.editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+ codeMirror.on("tokenenter", this.onTokenEnter);
+ codeMirror.on("scroll", this.onScroll);
+ codeMirrorWrapper.addEventListener("mouseup", this.onMouseUp);
+ codeMirrorWrapper.addEventListener("mousedown", this.onMouseDown);
+ }
+
+ onTokenEnter = ({ target, tokenPos }) => {
+ const { cx, editor, updatePreview, highlightedCalls, setExceptionPreview } =
+ this.props;
+
+ const isTargetException = target.classList.contains(EXCEPTION_MARKER);
+
+ if (isTargetException) {
+ setExceptionPreview(cx, target, tokenPos, editor.codeMirror);
+ return;
+ }
+
+ if (
+ this.props.isPaused &&
+ !this.state.selecting &&
+ highlightedCalls === null &&
+ !isTargetException
+ ) {
+ updatePreview(cx, target, tokenPos, editor.codeMirror);
+ }
+ };
+
+ onMouseUp = () => {
+ if (this.props.isPaused) {
+ this.setState({ selecting: false });
+ }
+ };
+
+ onMouseDown = () => {
+ if (this.props.isPaused) {
+ this.setState({ selecting: true });
+ }
+ };
+
+ onScroll = () => {
+ if (this.props.isPaused) {
+ this.props.clearPreview(this.props.cx);
+ }
+ };
+
+ render() {
+ const { preview } = this.props;
+ if (!preview || this.state.selecting) {
+ return null;
+ }
+
+ return (
+ <Popup
+ preview={preview}
+ editor={this.props.editor}
+ editorRef={this.props.editorRef}
+ />
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ return {
+ highlightedCalls: getHighlightedCalls(state, thread),
+ cx: getThreadContext(state),
+ preview: getPreview(state),
+ isPaused: getIsCurrentThreadPaused(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ clearPreview: actions.clearPreview,
+ addExpression: actions.addExpression,
+ updatePreview: actions.updatePreview,
+ setExceptionPreview: actions.setExceptionPreview,
+})(Preview);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/moz.build b/devtools/client/debugger/src/components/Editor/Preview/moz.build
new file mode 100644
index 0000000000..362faadc42
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "ExceptionPopup.js",
+ "index.js",
+ "Popup.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js
new file mode 100644
index 0000000000..8c58fe9c63
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ addHighlightToTargetSiblings,
+ removeHighlightForTargetSiblings,
+} from "../Popup";
+
+describe("addHighlightToTargetSiblings", () => {
+ it("should add preview highlight class to related target siblings", async () => {
+ const div = document.createElement("div");
+ const divChildren = ["a", "divided", "token"];
+ divChildren.forEach(function (span) {
+ const child = document.createElement("span");
+ const text = document.createTextNode(span);
+ child.appendChild(text);
+ child.classList.add("cm-property");
+ div.appendChild(child);
+ });
+
+ const target = div.children[1];
+ const props = {
+ preview: {
+ expression: "adividedtoken",
+ },
+ };
+
+ addHighlightToTargetSiblings(target, props);
+
+ const previous = target.previousElementSibling;
+ if (previous && previous.className) {
+ expect(previous.className.includes("preview-token")).toEqual(true);
+ }
+
+ const next = target.nextElementSibling;
+ if (next && next.className) {
+ expect(next.className.includes("preview-token")).toEqual(true);
+ }
+ });
+
+ it("should not add preview highlight class to target's related siblings after non-element nodes", () => {
+ const div = document.createElement("div");
+
+ const elementBeforePeriod = document.createElement("span");
+ elementBeforePeriod.innerHTML = "object";
+ elementBeforePeriod.classList.add("cm-property");
+ div.appendChild(elementBeforePeriod);
+
+ const period = document.createTextNode(".");
+ div.appendChild(period);
+
+ const target = document.createElement("span");
+ target.innerHTML = "property";
+ target.classList.add("cm-property");
+ div.appendChild(target);
+
+ const anotherPeriod = document.createTextNode(".");
+ div.appendChild(anotherPeriod);
+
+ const elementAfterPeriod = document.createElement("span");
+ elementAfterPeriod.innerHTML = "anotherProperty";
+ elementAfterPeriod.classList.add("cm-property");
+ div.appendChild(elementAfterPeriod);
+
+ const props = {
+ preview: {
+ expression: "object.property.anotherproperty",
+ },
+ };
+ addHighlightToTargetSiblings(target, props);
+
+ expect(elementBeforePeriod.className.includes("preview-token")).toEqual(
+ false
+ );
+ expect(elementAfterPeriod.className.includes("preview-token")).toEqual(
+ false
+ );
+ });
+});
+
+describe("removeHighlightForTargetSiblings", () => {
+ it("should remove preview highlight class from target's related siblings", async () => {
+ const div = document.createElement("div");
+ const divChildren = ["a", "divided", "token"];
+ divChildren.forEach(function (span) {
+ const child = document.createElement("span");
+ const text = document.createTextNode(span);
+ child.appendChild(text);
+ child.classList.add("preview-token");
+ div.appendChild(child);
+ });
+ const target = div.children[1];
+
+ removeHighlightForTargetSiblings(target);
+
+ const previous = target.previousElementSibling;
+ if (previous && previous.className) {
+ expect(previous.className.includes("preview-token")).toEqual(false);
+ }
+
+ const next = target.nextElementSibling;
+ if (next && next.className) {
+ expect(next.className.includes("preview-token")).toEqual(false);
+ }
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.css b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css
new file mode 100644
index 0000000000..0f75783c00
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.search-bar {
+ position: relative;
+ display: flex;
+ border-top: 1px solid var(--theme-splitter-color);
+ height: var(--editor-searchbar-height);
+}
+
+/* display a fake outline above the search bar's top border, and above
+ the source footer's top border */
+.search-bar::before {
+ content: "";
+ position: absolute;
+ z-index: 10;
+ top: -1px;
+ left: 0;
+ right: 0;
+ bottom: -1px;
+ border: solid 1px var(--blue-50);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 150ms ease-out;
+}
+
+.search-bar:focus-within::before {
+ opacity: 1;
+}
+
+.search-bar .search-outline {
+ flex-grow: 1;
+ border-width: 0;
+}
+
+.search-bar .result-list {
+ max-height: 230px;
+}
diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.js b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js
new file mode 100644
index 0000000000..80a6d28fb0
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js
@@ -0,0 +1,371 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import React, { Component } from "react";
+import { connect } from "../../utils/connect";
+import actions from "../../actions";
+import {
+ getActiveSearch,
+ getSelectedSource,
+ getContext,
+ getSelectedSourceTextContent,
+ getSearchOptions,
+} from "../../selectors";
+
+import { searchKeys } from "../../constants";
+import { scrollList } from "../../utils/result-list";
+
+import SearchInput from "../shared/SearchInput";
+import "./SearchInFileBar.css";
+
+const { PluralForm } = require("devtools/shared/plural-form");
+const { debounce } = require("devtools/shared/debounce");
+import { renderWasmText } from "../../utils/wasm";
+import {
+ clearSearch,
+ find,
+ findNext,
+ findPrev,
+ removeOverlay,
+} from "../../utils/editor";
+import { isFulfilled } from "../../utils/async-value";
+
+function getSearchShortcut() {
+ return L10N.getStr("sourceSearch.search.key2");
+}
+
+class SearchInFileBar extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ query: "",
+ selectedResultIndex: 0,
+ results: {
+ matches: [],
+ matchIndex: -1,
+ count: 0,
+ index: -1,
+ },
+ inputFocused: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ closeFileSearch: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object,
+ modifiers: PropTypes.object.isRequired,
+ searchInFileEnabled: PropTypes.bool.isRequired,
+ selectedSourceTextContent: PropTypes.bool.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ setActiveSearch: PropTypes.func.isRequired,
+ querySearchWorker: PropTypes.func.isRequired,
+ };
+ }
+
+ componentWillUnmount() {
+ const { shortcuts } = this.context;
+
+ shortcuts.off(getSearchShortcut(), this.toggleSearch);
+ shortcuts.off("Escape", this.onEscape);
+
+ this.doSearch.cancel();
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { query } = this.state;
+ // If a new source is selected update the file search results
+ if (
+ this.props.selectedSource &&
+ nextProps.selectedSource !== this.props.selectedSource &&
+ this.props.searchInFileEnabled &&
+ query
+ ) {
+ this.doSearch(query, false);
+ }
+ }
+
+ componentDidMount() {
+ // overwrite this.doSearch with debounced version to
+ // reduce frequency of queries
+ this.doSearch = debounce(this.doSearch, 100);
+ const { shortcuts } = this.context;
+
+ shortcuts.on(getSearchShortcut(), this.toggleSearch);
+ shortcuts.on("Escape", this.onEscape);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (this.refs.resultList && this.refs.resultList.refs) {
+ scrollList(this.refs.resultList.refs, this.state.selectedResultIndex);
+ }
+ }
+
+ onEscape = e => {
+ this.closeSearch(e);
+ };
+
+ clearSearch = () => {
+ const { editor: ed } = this.props;
+ if (ed) {
+ const ctx = { ed, cm: ed.codeMirror };
+ removeOverlay(ctx, this.state.query);
+ }
+ };
+
+ closeSearch = e => {
+ const { cx, closeFileSearch, editor, searchInFileEnabled } = this.props;
+ this.clearSearch();
+ if (editor && searchInFileEnabled) {
+ closeFileSearch(cx, editor);
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ this.setState({ inputFocused: false });
+ };
+
+ toggleSearch = e => {
+ e.stopPropagation();
+ e.preventDefault();
+ const { editor, searchInFileEnabled, setActiveSearch } = this.props;
+
+ // Set inputFocused to false, so that search query is highlighted whenever search shortcut is used, even if the input already has focus.
+ this.setState({ inputFocused: false });
+
+ if (!searchInFileEnabled) {
+ setActiveSearch("file");
+ }
+
+ if (searchInFileEnabled && editor) {
+ const query = editor.codeMirror.getSelection() || this.state.query;
+
+ if (query !== "") {
+ this.setState({ query, inputFocused: true });
+ this.doSearch(query);
+ } else {
+ this.setState({ query: "", inputFocused: true });
+ }
+ }
+ };
+
+ doSearch = async (query, focusFirstResult = true) => {
+ const { editor, modifiers, selectedSourceTextContent } = this.props;
+ if (
+ !editor ||
+ !selectedSourceTextContent ||
+ !isFulfilled(selectedSourceTextContent) ||
+ !modifiers
+ ) {
+ return;
+ }
+ const selectedContent = selectedSourceTextContent.value;
+
+ const ctx = { ed: editor, cm: editor.codeMirror };
+
+ if (!query) {
+ clearSearch(ctx.cm, query);
+ return;
+ }
+
+ let text;
+ if (selectedContent.type === "wasm") {
+ text = renderWasmText(this.props.selectedSource.id, selectedContent).join(
+ "\n"
+ );
+ } else {
+ text = selectedContent.value;
+ }
+
+ const matches = await this.props.querySearchWorker(query, text, modifiers);
+
+ const res = find(ctx, query, true, modifiers, focusFirstResult);
+ if (!res) {
+ return;
+ }
+
+ const { ch, line } = res;
+
+ const matchIndex = matches.findIndex(
+ elm => elm.line === line && elm.ch === ch
+ );
+ this.setState({
+ results: {
+ matches,
+ matchIndex,
+ count: matches.length,
+ index: ch,
+ },
+ });
+ };
+
+ traverseResults = (e, reverse = false) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const { editor } = this.props;
+
+ if (!editor) {
+ return;
+ }
+
+ const ctx = { ed: editor, cm: editor.codeMirror };
+
+ const { modifiers } = this.props;
+ const { query } = this.state;
+ const { matches } = this.state.results;
+
+ if (query === "" && !this.props.searchInFileEnabled) {
+ this.props.setActiveSearch("file");
+ }
+
+ if (modifiers) {
+ const findArgs = [ctx, query, true, modifiers];
+ const results = reverse ? findPrev(...findArgs) : findNext(...findArgs);
+
+ if (!results) {
+ return;
+ }
+ const { ch, line } = results;
+ const matchIndex = matches.findIndex(
+ elm => elm.line === line && elm.ch === ch
+ );
+ this.setState({
+ results: {
+ matches,
+ matchIndex,
+ count: matches.length,
+ index: ch,
+ },
+ });
+ }
+ };
+
+ // Handlers
+
+ onChange = e => {
+ this.setState({ query: e.target.value });
+
+ return this.doSearch(e.target.value);
+ };
+
+ onFocus = e => {
+ this.setState({ inputFocused: true });
+ };
+
+ onBlur = e => {
+ this.setState({ inputFocused: false });
+ };
+
+ onKeyDown = e => {
+ if (e.key !== "Enter" && e.key !== "F3") {
+ return;
+ }
+
+ this.traverseResults(e, e.shiftKey);
+ e.preventDefault();
+ this.doSearch(e.target.value);
+ };
+
+ onHistoryScroll = query => {
+ this.setState({ query });
+ this.doSearch(query);
+ };
+
+ // Renderers
+ buildSummaryMsg() {
+ const {
+ query,
+ results: { matchIndex, count, index },
+ } = this.state;
+
+ if (query.trim() == "") {
+ return "";
+ }
+
+ if (count == 0) {
+ return L10N.getStr("editor.noResultsFound");
+ }
+
+ if (index == -1) {
+ const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary1");
+ return PluralForm.get(count, resultsSummaryString).replace("#1", count);
+ }
+
+ const searchResultsString = L10N.getStr("editor.searchResults1");
+ return PluralForm.get(count, searchResultsString)
+ .replace("#1", count)
+ .replace("%d", matchIndex + 1);
+ }
+
+ shouldShowErrorEmoji() {
+ const {
+ query,
+ results: { count },
+ } = this.state;
+ return !!query && !count;
+ }
+
+ render() {
+ const { searchInFileEnabled } = this.props;
+ const {
+ results: { count },
+ } = this.state;
+
+ if (!searchInFileEnabled) {
+ return <div />;
+ }
+
+ return (
+ <div className="search-bar">
+ <SearchInput
+ query={this.state.query}
+ count={count}
+ placeholder={L10N.getStr("sourceSearch.search.placeholder2")}
+ summaryMsg={this.buildSummaryMsg()}
+ isLoading={false}
+ onChange={this.onChange}
+ onFocus={this.onFocus}
+ onBlur={this.onBlur}
+ showErrorEmoji={this.shouldShowErrorEmoji()}
+ onKeyDown={this.onKeyDown}
+ onHistoryScroll={this.onHistoryScroll}
+ handleNext={e => this.traverseResults(e, false)}
+ handlePrev={e => this.traverseResults(e, true)}
+ shouldFocus={this.state.inputFocused}
+ showClose={true}
+ showExcludePatterns={false}
+ handleClose={this.closeSearch}
+ showSearchModifiers={true}
+ searchKey={searchKeys.FILE_SEARCH}
+ onToggleSearchModifier={() => this.doSearch(this.state.query)}
+ />
+ </div>
+ );
+ }
+}
+
+SearchInFileBar.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = (state, p) => {
+ const selectedSource = getSelectedSource(state);
+
+ return {
+ cx: getContext(state),
+ searchInFileEnabled: getActiveSearch(state) === "file",
+ selectedSource,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ modifiers: getSearchOptions(state, "file-search"),
+ };
+};
+
+export default connect(mapStateToProps, {
+ setFileSearchQuery: actions.setFileSearchQuery,
+ setActiveSearch: actions.setActiveSearch,
+ closeFileSearch: actions.closeFileSearch,
+ querySearchWorker: actions.querySearchWorker,
+})(SearchInFileBar);
diff --git a/devtools/client/debugger/src/components/Editor/Tab.js b/devtools/client/debugger/src/components/Editor/Tab.js
new file mode 100644
index 0000000000..2f296f9346
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tab.js
@@ -0,0 +1,282 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+import { showMenu, buildMenu } from "../../context-menu/menu";
+
+import SourceIcon from "../shared/SourceIcon";
+import { CloseButton } from "../shared/Button";
+import { copyToTheClipboard } from "../../utils/clipboard";
+
+import actions from "../../actions";
+
+import {
+ getDisplayPath,
+ getFileURL,
+ getRawSourceURL,
+ getSourceQueryString,
+ getTruncatedFileName,
+ isPretty,
+ shouldBlackbox,
+} from "../../utils/source";
+import { getTabMenuItems } from "../../utils/tabs";
+import { createLocation } from "../../utils/location";
+
+import {
+ getSelectedLocation,
+ getActiveSearch,
+ getSourcesForTabs,
+ isSourceBlackBoxed,
+ getContext,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+class Tab extends PureComponent {
+ static get propTypes() {
+ return {
+ activeSearch: PropTypes.string,
+ closeTab: PropTypes.func.isRequired,
+ closeTabs: PropTypes.func.isRequired,
+ copyToClipboard: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ onDragEnd: PropTypes.func.isRequired,
+ onDragOver: PropTypes.func.isRequired,
+ onDragStart: PropTypes.func.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ selectedLocation: PropTypes.object,
+ showSource: PropTypes.func.isRequired,
+ source: PropTypes.object.isRequired,
+ sourceActor: PropTypes.object.isRequired,
+ tabSources: PropTypes.array.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ togglePrettyPrint: PropTypes.func.isRequired,
+ isBlackBoxed: PropTypes.bool.isRequired,
+ isSourceOnIgnoreList: PropTypes.bool.isRequired,
+ };
+ }
+
+ onTabContextMenu = (event, tab) => {
+ event.preventDefault();
+ this.showContextMenu(event, tab);
+ };
+
+ showContextMenu(e, tab) {
+ const {
+ cx,
+ closeTab,
+ closeTabs,
+ copyToClipboard,
+ tabSources,
+ showSource,
+ toggleBlackBox,
+ togglePrettyPrint,
+ selectedLocation,
+ source,
+ isBlackBoxed,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ const tabCount = tabSources.length;
+ const otherTabs = tabSources.filter(t => t.id !== tab);
+ const sourceTab = tabSources.find(t => t.id == tab);
+ const tabURLs = tabSources.map(t => t.url);
+ const otherTabURLs = otherTabs.map(t => t.url);
+
+ if (!sourceTab || !selectedLocation || !selectedLocation.sourceId) {
+ return;
+ }
+
+ const tabMenuItems = getTabMenuItems();
+ const items = [
+ {
+ item: {
+ ...tabMenuItems.closeTab,
+ click: () => closeTab(cx, sourceTab),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.closeOtherTabs,
+ click: () => closeTabs(cx, otherTabURLs),
+ disabled: otherTabURLs.length === 0,
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.closeTabsToEnd,
+ click: () => {
+ const tabIndex = tabSources.findIndex(t => t.id == tab);
+ closeTabs(
+ cx,
+ tabURLs.filter((t, i) => i > tabIndex)
+ );
+ },
+ disabled:
+ tabCount === 1 ||
+ tabSources.some((t, i) => t === tab && tabCount - 1 === i),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.closeAllTabs,
+ click: () => closeTabs(cx, tabURLs),
+ },
+ },
+ { item: { type: "separator" } },
+ {
+ item: {
+ ...tabMenuItems.copySource,
+ disabled: selectedLocation.sourceId !== tab,
+ click: () => copyToClipboard(sourceTab),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.copySourceUri2,
+ disabled: !selectedLocation.sourceUrl,
+ click: () => copyToTheClipboard(getRawSourceURL(sourceTab.url)),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.showSource,
+ disabled: !selectedLocation.sourceUrl,
+ click: () => showSource(cx, tab),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.toggleBlackBox,
+ label: isBlackBoxed
+ ? L10N.getStr("ignoreContextItem.unignore")
+ : L10N.getStr("ignoreContextItem.ignore"),
+ disabled: isSourceOnIgnoreList || !shouldBlackbox(source),
+ click: () => toggleBlackBox(cx, source),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.prettyPrint,
+ click: () => togglePrettyPrint(cx, tab),
+ disabled: isPretty(sourceTab),
+ },
+ },
+ ];
+
+ showMenu(e, buildMenu(items));
+ }
+
+ isSourceSearchEnabled() {
+ return this.props.activeSearch === "source";
+ }
+
+ render() {
+ const {
+ cx,
+ selectedLocation,
+ selectSource,
+ closeTab,
+ source,
+ sourceActor,
+ tabSources,
+ onDragOver,
+ onDragStart,
+ onDragEnd,
+ } = this.props;
+ const sourceId = source.id;
+ const active =
+ selectedLocation &&
+ sourceId == selectedLocation.sourceId &&
+ !this.isSourceSearchEnabled();
+ const isPrettyCode = isPretty(source);
+
+ function onClickClose(e) {
+ e.stopPropagation();
+ closeTab(cx, source);
+ }
+
+ function handleTabClick(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return selectSource(cx, source, sourceActor);
+ }
+
+ const className = classnames("source-tab", {
+ active,
+ pretty: isPrettyCode,
+ blackboxed: this.props.isBlackBoxed,
+ });
+
+ const path = getDisplayPath(source, tabSources);
+ const query = getSourceQueryString(source);
+
+ return (
+ <div
+ draggable
+ onDragOver={onDragOver}
+ onDragStart={onDragStart}
+ onDragEnd={onDragEnd}
+ className={className}
+ key={sourceId}
+ onClick={handleTabClick}
+ // Accommodate middle click to close tab
+ onMouseUp={e => e.button === 1 && closeTab(cx, source)}
+ onContextMenu={e => this.onTabContextMenu(e, sourceId)}
+ title={getFileURL(source, false)}
+ >
+ <SourceIcon
+ location={createLocation({ source, sourceActor })}
+ forTab={true}
+ modifier={icon =>
+ ["file", "javascript"].includes(icon) ? null : icon
+ }
+ />
+ <div className="filename">
+ {getTruncatedFileName(source, query)}
+ {path && <span>{`../${path}/..`}</span>}
+ </div>
+ <CloseButton
+ handleClick={onClickClose}
+ tooltip={L10N.getStr("sourceTabs.closeTabButtonTooltip")}
+ />
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = (state, { source }) => {
+ return {
+ cx: getContext(state),
+ tabSources: getSourcesForTabs(state),
+ selectedLocation: getSelectedLocation(state),
+ isBlackBoxed: isSourceBlackBoxed(state, source),
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, source),
+ activeSearch: getActiveSearch(state),
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ {
+ selectSource: actions.selectSource,
+ copyToClipboard: actions.copyToClipboard,
+ closeTab: actions.closeTab,
+ closeTabs: actions.closeTabs,
+ togglePrettyPrint: actions.togglePrettyPrint,
+ showSource: actions.showSource,
+ toggleBlackBox: actions.toggleBlackBox,
+ },
+ null,
+ {
+ withRef: true,
+ }
+)(Tab);
diff --git a/devtools/client/debugger/src/components/Editor/Tabs.css b/devtools/client/debugger/src/components/Editor/Tabs.css
new file mode 100644
index 0000000000..565d8588f1
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tabs.css
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.source-header {
+ display: flex;
+ width: 100%;
+ height: var(--editor-header-height);
+ border-bottom: 1px solid var(--theme-splitter-color);
+ background-color: var(--theme-toolbar-background);
+}
+
+.source-header * {
+ user-select: none;
+}
+
+.source-header .command-bar {
+ flex: initial;
+ flex-shrink: 0;
+ border-bottom: 0;
+ border-inline-start: 1px solid var(--theme-splitter-color);
+}
+
+.source-tabs {
+ flex: auto;
+ align-self: flex-start;
+ align-items: flex-start;
+ /* Reserve space for the overflow button (even if not visible) */
+ padding-inline-end: 28px;
+}
+
+.source-tab {
+ display: inline-flex;
+ align-items: center;
+ position: relative;
+ min-width: 40px;
+ max-width: 100%;
+ overflow: hidden;
+ padding: 4px 10px;
+ cursor: default;
+ height: calc(var(--editor-header-height) - 1px);
+ font-size: 12px;
+ background-color: transparent;
+ vertical-align: bottom;
+}
+
+.source-tab::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background-color: var(--tab-line-color, transparent);
+ transition: transform 250ms var(--animation-curve),
+ opacity 250ms var(--animation-curve);
+ opacity: 0;
+ transform: scaleX(0);
+}
+
+.source-tab.active {
+ --tab-line-color: var(--tab-line-selected-color);
+ color: var(--theme-toolbar-selected-color);
+ border-bottom-color: transparent;
+}
+
+.source-tab:not(.active):hover {
+ --tab-line-color: var(--tab-line-hover-color);
+ background-color: var(--theme-toolbar-hover);
+}
+
+.source-tab:hover::before,
+.source-tab.active::before {
+ opacity: 1;
+ transform: scaleX(1);
+}
+
+.source-tab .img:is(.prettyPrint,.blackBox) {
+ mask-size: 14px;
+}
+
+.source-tab .img.prettyPrint {
+ background-color: currentColor;
+}
+
+.source-tab .img.source-icon.blackBox {
+ background-color: #806414;
+}
+
+.source-tab .filename {
+ display: block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-inline-end: 4px;
+}
+
+.source-tab .filename span {
+ opacity: 0.7;
+ padding-inline-start: 4px;
+}
+
+.source-tab .close-btn {
+ visibility: hidden;
+ margin-inline-end: -6px;
+}
+
+.source-tab.active .close-btn {
+ color: inherit;
+}
+
+.source-tab.active .close-btn,
+.source-tab:hover .close-btn {
+ visibility: visible;
+}
+
+.source-tab.active .source-icon {
+ background-color: currentColor;
+}
+
+.source-tab .close-btn:hover,
+.source-tab .close-btn:focus {
+ color: var(--theme-selection-color);
+ background-color: var(--theme-selection-background);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Tabs.js b/devtools/client/debugger/src/components/Editor/Tabs.js
new file mode 100644
index 0000000000..3f38f216a0
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tabs.js
@@ -0,0 +1,332 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import ReactDOM from "react-dom";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+import {
+ getSourceTabs,
+ getSelectedSource,
+ getSourcesForTabs,
+ getIsPaused,
+ getCurrentThread,
+ getContext,
+ getBlackBoxRanges,
+} from "../../selectors";
+import { isVisible } from "../../utils/ui";
+
+import { getHiddenTabs } from "../../utils/tabs";
+import { getFilename, isPretty, getFileURL } from "../../utils/source";
+import actions from "../../actions";
+
+import "./Tabs.css";
+
+import Tab from "./Tab";
+import { PaneToggleButton } from "../shared/Button";
+import Dropdown from "../shared/Dropdown";
+import AccessibleImage from "../shared/AccessibleImage";
+import CommandBar from "../SecondaryPanes/CommandBar";
+
+const { debounce } = require("devtools/shared/debounce");
+
+function haveTabSourcesChanged(tabSources, prevTabSources) {
+ if (tabSources.length !== prevTabSources.length) {
+ return true;
+ }
+
+ for (let i = 0; i < tabSources.length; ++i) {
+ if (tabSources[i].id !== prevTabSources[i].id) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+class Tabs extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ dropdownShown: false,
+ hiddenTabs: [],
+ };
+
+ this.onResize = debounce(() => {
+ this.updateHiddenTabs();
+ });
+ }
+
+ static get propTypes() {
+ return {
+ cx: PropTypes.object.isRequired,
+ endPanelCollapsed: PropTypes.bool.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ moveTab: PropTypes.func.isRequired,
+ moveTabBySourceId: PropTypes.func.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object,
+ blackBoxRanges: PropTypes.object.isRequired,
+ startPanelCollapsed: PropTypes.bool.isRequired,
+ tabSources: PropTypes.array.isRequired,
+ tabs: PropTypes.array.isRequired,
+ togglePaneCollapse: PropTypes.func.isRequired,
+ };
+ }
+
+ get draggedSource() {
+ return this._draggedSource == null
+ ? { url: null, id: null }
+ : this._draggedSource;
+ }
+
+ set draggedSource(source) {
+ this._draggedSource = source;
+ }
+
+ get draggedSourceIndex() {
+ return this._draggedSourceIndex == null ? -1 : this._draggedSourceIndex;
+ }
+
+ set draggedSourceIndex(index) {
+ this._draggedSourceIndex = index;
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ this.props.selectedSource !== prevProps.selectedSource ||
+ haveTabSourcesChanged(this.props.tabSources, prevProps.tabSources)
+ ) {
+ this.updateHiddenTabs();
+ }
+ }
+
+ componentDidMount() {
+ window.requestIdleCallback(this.updateHiddenTabs);
+ window.addEventListener("resize", this.onResize);
+ window.document
+ .querySelector(".editor-pane")
+ .addEventListener("resizeend", this.onResize);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener("resize", this.onResize);
+ window.document
+ .querySelector(".editor-pane")
+ .removeEventListener("resizeend", this.onResize);
+ }
+
+ /*
+ * Updates the hiddenSourceTabs state, by
+ * finding the source tabs which are wrapped and are not on the top row.
+ */
+ updateHiddenTabs = () => {
+ if (!this.refs.sourceTabs) {
+ return;
+ }
+ const { selectedSource, tabSources, moveTab } = this.props;
+ const sourceTabEls = this.refs.sourceTabs.children;
+ const hiddenTabs = getHiddenTabs(tabSources, sourceTabEls);
+
+ if (
+ selectedSource &&
+ isVisible() &&
+ hiddenTabs.find(tab => tab.id == selectedSource.id)
+ ) {
+ moveTab(selectedSource.url, 0);
+ return;
+ }
+
+ this.setState({ hiddenTabs });
+ };
+
+ toggleSourcesDropdown() {
+ this.setState(prevState => ({
+ dropdownShown: !prevState.dropdownShown,
+ }));
+ }
+
+ getIconClass(source) {
+ if (isPretty(source)) {
+ return "prettyPrint";
+ }
+ if (this.props.blackBoxRanges[source.url]) {
+ return "blackBox";
+ }
+ return "file";
+ }
+
+ renderDropdownSource = source => {
+ const { cx, selectSource } = this.props;
+ const filename = getFilename(source);
+
+ const onClick = () => selectSource(cx, source);
+ return (
+ <li key={source.id} onClick={onClick} title={getFileURL(source, false)}>
+ <AccessibleImage
+ className={`dropdown-icon ${this.getIconClass(source)}`}
+ />
+ <span className="dropdown-label">{filename}</span>
+ </li>
+ );
+ };
+
+ onTabDragStart = (source, index) => {
+ this.draggedSource = source;
+ this.draggedSourceIndex = index;
+ };
+
+ onTabDragEnd = () => {
+ this.draggedSource = null;
+ this.draggedSourceIndex = null;
+ };
+
+ onTabDragOver = (e, source, hoveredTabIndex) => {
+ const { moveTabBySourceId } = this.props;
+ if (hoveredTabIndex === this.draggedSourceIndex) {
+ return;
+ }
+
+ const tabDOM = ReactDOM.findDOMNode(
+ this.refs[`tab_${source.id}`].getWrappedInstance()
+ );
+
+ const tabDOMRect = tabDOM.getBoundingClientRect();
+ const { pageX: mouseCursorX } = e;
+ if (
+ /* Case: the mouse cursor moves into the left half of any target tab */
+ mouseCursorX - tabDOMRect.left <
+ tabDOMRect.width / 2
+ ) {
+ // The current tab goes to the left of the target tab
+ const targetTab =
+ hoveredTabIndex > this.draggedSourceIndex
+ ? hoveredTabIndex - 1
+ : hoveredTabIndex;
+ moveTabBySourceId(this.draggedSource.id, targetTab);
+ this.draggedSourceIndex = targetTab;
+ } else if (
+ /* Case: the mouse cursor moves into the right half of any target tab */
+ mouseCursorX - tabDOMRect.left >=
+ tabDOMRect.width / 2
+ ) {
+ // The current tab goes to the right of the target tab
+ const targetTab =
+ hoveredTabIndex < this.draggedSourceIndex
+ ? hoveredTabIndex + 1
+ : hoveredTabIndex;
+ moveTabBySourceId(this.draggedSource.id, targetTab);
+ this.draggedSourceIndex = targetTab;
+ }
+ };
+
+ renderTabs() {
+ const { tabs } = this.props;
+ if (!tabs) {
+ return null;
+ }
+
+ return (
+ <div className="source-tabs" ref="sourceTabs">
+ {tabs.map(({ source, sourceActor }, index) => {
+ return (
+ <Tab
+ onDragStart={_ => this.onTabDragStart(source, index)}
+ onDragOver={e => {
+ this.onTabDragOver(e, source, index);
+ e.preventDefault();
+ }}
+ onDragEnd={this.onTabDragEnd}
+ key={index}
+ source={source}
+ sourceActor={sourceActor}
+ ref={`tab_${source.id}`}
+ />
+ );
+ })}
+ </div>
+ );
+ }
+
+ renderDropdown() {
+ const { hiddenTabs } = this.state;
+ if (!hiddenTabs || !hiddenTabs.length) {
+ return null;
+ }
+
+ const Panel = <ul>{hiddenTabs.map(this.renderDropdownSource)}</ul>;
+ const icon = <AccessibleImage className="more-tabs" />;
+
+ return <Dropdown panel={Panel} icon={icon} />;
+ }
+
+ renderCommandBar() {
+ const { horizontal, endPanelCollapsed, isPaused } = this.props;
+ if (!endPanelCollapsed || !isPaused) {
+ return null;
+ }
+
+ return <CommandBar horizontal={horizontal} />;
+ }
+
+ renderStartPanelToggleButton() {
+ return (
+ <PaneToggleButton
+ position="start"
+ collapsed={this.props.startPanelCollapsed}
+ handleClick={this.props.togglePaneCollapse}
+ />
+ );
+ }
+
+ renderEndPanelToggleButton() {
+ const { horizontal, endPanelCollapsed, togglePaneCollapse } = this.props;
+ if (!horizontal) {
+ return null;
+ }
+
+ return (
+ <PaneToggleButton
+ position="end"
+ collapsed={endPanelCollapsed}
+ handleClick={togglePaneCollapse}
+ horizontal={horizontal}
+ />
+ );
+ }
+
+ render() {
+ return (
+ <div className="source-header">
+ {this.renderStartPanelToggleButton()}
+ {this.renderTabs()}
+ {this.renderDropdown()}
+ {this.renderEndPanelToggleButton()}
+ {this.renderCommandBar()}
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ cx: getContext(state),
+ selectedSource: getSelectedSource(state),
+ tabSources: getSourcesForTabs(state),
+ tabs: getSourceTabs(state),
+ blackBoxRanges: getBlackBoxRanges(state),
+ isPaused: getIsPaused(state, getCurrentThread(state)),
+ };
+};
+
+export default connect(mapStateToProps, {
+ selectSource: actions.selectSource,
+ moveTab: actions.moveTab,
+ moveTabBySourceId: actions.moveTabBySourceId,
+ closeTab: actions.closeTab,
+ togglePaneCollapse: actions.togglePaneCollapse,
+ showSource: actions.showSource,
+})(Tabs);
diff --git a/devtools/client/debugger/src/components/Editor/index.js b/devtools/client/debugger/src/components/Editor/index.js
new file mode 100644
index 0000000000..fcaa129944
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/index.js
@@ -0,0 +1,808 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import React, { PureComponent } from "react";
+import { bindActionCreators } from "redux";
+import ReactDOM from "react-dom";
+import { connect } from "../../utils/connect";
+
+import { getLineText, isLineBlackboxed } from "./../../utils/source";
+import { createLocation } from "./../../utils/location";
+import { features } from "../../utils/prefs";
+import { getIndentation } from "../../utils/indentation";
+
+import { showMenu } from "../../context-menu/menu";
+import {
+ createBreakpointItems,
+ breakpointItemActions,
+} from "./menus/breakpoints";
+
+import {
+ continueToHereItem,
+ editorItemActions,
+ blackBoxLineMenuItem,
+} from "./menus/editor";
+
+import {
+ getActiveSearch,
+ getSelectedLocation,
+ getSelectedSource,
+ getSelectedSourceTextContent,
+ getSelectedBreakableLines,
+ getConditionalPanelLocation,
+ getSymbols,
+ getIsCurrentThreadPaused,
+ getCurrentThread,
+ getThreadContext,
+ getSkipPausing,
+ getInlinePreview,
+ getEditorWrapping,
+ getHighlightedCalls,
+ getBlackBoxRanges,
+ isSourceBlackBoxed,
+ getHighlightedLineRangeForSelectedSource,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+
+// Redux actions
+import actions from "../../actions";
+
+import SearchInFileBar from "./SearchInFileBar";
+import HighlightLines from "./HighlightLines";
+import Preview from "./Preview";
+import Breakpoints from "./Breakpoints";
+import ColumnBreakpoints from "./ColumnBreakpoints";
+import DebugLine from "./DebugLine";
+import HighlightLine from "./HighlightLine";
+import EmptyLines from "./EmptyLines";
+import EditorMenu from "./EditorMenu";
+import ConditionalPanel from "./ConditionalPanel";
+import InlinePreviews from "./InlinePreviews";
+import HighlightCalls from "./HighlightCalls";
+import Exceptions from "./Exceptions";
+import BlackboxLines from "./BlackboxLines";
+
+import {
+ showSourceText,
+ showLoading,
+ showErrorMessage,
+ getEditor,
+ clearEditor,
+ getCursorLine,
+ getCursorColumn,
+ lineAtHeight,
+ toSourceLine,
+ getDocument,
+ scrollToColumn,
+ toEditorPosition,
+ getSourceLocationFromMouseEvent,
+ hasDocument,
+ onMouseOver,
+ startOperation,
+ endOperation,
+} from "../../utils/editor";
+
+import { resizeToggleButton, resizeBreakpointGutter } from "../../utils/ui";
+
+const { debounce } = require("devtools/shared/debounce");
+const classnames = require("devtools/client/shared/classnames.js");
+
+const { appinfo } = Services;
+const isMacOS = appinfo.OS === "Darwin";
+
+function isSecondary(ev) {
+ return isMacOS && ev.ctrlKey && ev.button === 0;
+}
+
+function isCmd(ev) {
+ return isMacOS ? ev.metaKey : ev.ctrlKey;
+}
+
+import "./Editor.css";
+import "./Breakpoints.css";
+import "./InlinePreview.css";
+
+const cssVars = {
+ searchbarHeight: "var(--editor-searchbar-height)",
+};
+
+class Editor extends PureComponent {
+ static get propTypes() {
+ return {
+ selectedSource: PropTypes.object,
+ selectedSourceTextContent: PropTypes.object,
+ selectedSourceIsBlackBoxed: PropTypes.bool,
+ cx: PropTypes.object.isRequired,
+ closeTab: PropTypes.func.isRequired,
+ toggleBreakpointAtLine: PropTypes.func.isRequired,
+ conditionalPanelLocation: PropTypes.object,
+ closeConditionalPanel: PropTypes.func.isRequired,
+ openConditionalPanel: PropTypes.func.isRequired,
+ updateViewport: PropTypes.func.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ highlightCalls: PropTypes.func.isRequired,
+ unhighlightCalls: PropTypes.func.isRequired,
+ breakpointActions: PropTypes.object.isRequired,
+ editorActions: PropTypes.object.isRequired,
+ addBreakpointAtLine: PropTypes.func.isRequired,
+ continueToHere: PropTypes.func.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ updateCursorPosition: PropTypes.func.isRequired,
+ jumpToMappedLocation: PropTypes.func.isRequired,
+ selectedLocation: PropTypes.object,
+ symbols: PropTypes.object,
+ startPanelSize: PropTypes.number.isRequired,
+ endPanelSize: PropTypes.number.isRequired,
+ searchInFileEnabled: PropTypes.bool.isRequired,
+ inlinePreviewEnabled: PropTypes.bool.isRequired,
+ editorWrappingEnabled: PropTypes.bool.isRequired,
+ skipPausing: PropTypes.bool.isRequired,
+ blackboxedRanges: PropTypes.object.isRequired,
+ breakableLines: PropTypes.object.isRequired,
+ highlightedLineRange: PropTypes.object,
+ isSourceOnIgnoreList: PropTypes.bool,
+ };
+ }
+
+ $editorWrapper;
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ editor: null,
+ contextMenu: null,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ let { editor } = this.state;
+
+ if (!editor && nextProps.selectedSource) {
+ editor = this.setupEditor();
+ }
+
+ const shouldUpdateText =
+ nextProps.selectedSource !== this.props.selectedSource ||
+ nextProps.selectedSourceTextContent !==
+ this.props.selectedSourceTextContent ||
+ nextProps.symbols !== this.props.symbols;
+
+ const shouldUpdateSize =
+ nextProps.startPanelSize !== this.props.startPanelSize ||
+ nextProps.endPanelSize !== this.props.endPanelSize;
+
+ const shouldScroll =
+ nextProps.selectedLocation &&
+ this.shouldScrollToLocation(nextProps, editor);
+
+ if (shouldUpdateText || shouldUpdateSize || shouldScroll) {
+ startOperation();
+ if (shouldUpdateText) {
+ this.setText(nextProps, editor);
+ }
+ if (shouldUpdateSize) {
+ editor.codeMirror.setSize();
+ }
+ if (shouldScroll) {
+ this.scrollToLocation(nextProps, editor);
+ }
+ endOperation();
+ }
+
+ if (this.props.selectedSource != nextProps.selectedSource) {
+ this.props.updateViewport();
+ resizeBreakpointGutter(editor.codeMirror);
+ resizeToggleButton(editor.codeMirror);
+ }
+ }
+
+ setupEditor() {
+ const editor = getEditor();
+
+ // disables the default search shortcuts
+ editor._initShortcuts = () => {};
+
+ const node = ReactDOM.findDOMNode(this);
+ if (node instanceof HTMLElement) {
+ editor.appendToLocalElement(node.querySelector(".editor-mount"));
+ }
+
+ const { codeMirror } = editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirror.on("gutterClick", this.onGutterClick);
+
+ if (features.commandClick) {
+ document.addEventListener("keydown", this.commandKeyDown);
+ document.addEventListener("keyup", this.commandKeyUp);
+ }
+
+ // Set code editor wrapper to be focusable
+ codeMirrorWrapper.tabIndex = 0;
+ codeMirrorWrapper.addEventListener("keydown", e => this.onKeyDown(e));
+ codeMirrorWrapper.addEventListener("click", e => this.onClick(e));
+ codeMirrorWrapper.addEventListener("mouseover", onMouseOver(codeMirror));
+
+ const toggleFoldMarkerVisibility = e => {
+ if (node instanceof HTMLElement) {
+ node
+ .querySelectorAll(".CodeMirror-guttermarker-subtle")
+ .forEach(elem => {
+ elem.classList.toggle("visible");
+ });
+ }
+ };
+
+ const codeMirrorGutter = codeMirror.getGutterElement();
+ codeMirrorGutter.addEventListener("mouseleave", toggleFoldMarkerVisibility);
+ codeMirrorGutter.addEventListener("mouseenter", toggleFoldMarkerVisibility);
+ codeMirrorWrapper.addEventListener("contextmenu", event =>
+ this.openMenu(event)
+ );
+
+ codeMirror.on("scroll", this.onEditorScroll);
+ this.onEditorScroll();
+ this.setState({ editor });
+ return editor;
+ }
+
+ componentDidMount() {
+ const { shortcuts } = this.context;
+
+ shortcuts.on(L10N.getStr("toggleBreakpoint.key"), this.onToggleBreakpoint);
+ shortcuts.on(
+ L10N.getStr("toggleCondPanel.breakpoint.key"),
+ this.onToggleConditionalPanel
+ );
+ shortcuts.on(
+ L10N.getStr("toggleCondPanel.logPoint.key"),
+ this.onToggleConditionalPanel
+ );
+ shortcuts.on(
+ L10N.getStr("sourceTabs.closeTab.key"),
+ this.onCloseShortcutPress
+ );
+ shortcuts.on("Esc", this.onEscape);
+ }
+
+ onCloseShortcutPress = e => {
+ const { cx, selectedSource } = this.props;
+ if (selectedSource) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.closeTab(cx, selectedSource, "shortcut");
+ }
+ };
+
+ componentWillUnmount() {
+ const { editor } = this.state;
+ if (editor) {
+ editor.destroy();
+ editor.codeMirror.off("scroll", this.onEditorScroll);
+ this.setState({ editor: null });
+ }
+
+ const { shortcuts } = this.context;
+ shortcuts.off(L10N.getStr("sourceTabs.closeTab.key"));
+ shortcuts.off(L10N.getStr("toggleBreakpoint.key"));
+ shortcuts.off(L10N.getStr("toggleCondPanel.breakpoint.key"));
+ shortcuts.off(L10N.getStr("toggleCondPanel.logPoint.key"));
+ }
+
+ getCurrentLine() {
+ const { codeMirror } = this.state.editor;
+ const { selectedSource } = this.props;
+ if (!selectedSource) {
+ return null;
+ }
+
+ const line = getCursorLine(codeMirror);
+ return toSourceLine(selectedSource.id, line);
+ }
+
+ onToggleBreakpoint = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const line = this.getCurrentLine();
+ if (typeof line !== "number") {
+ return;
+ }
+
+ this.props.toggleBreakpointAtLine(this.props.cx, line);
+ };
+
+ onToggleConditionalPanel = e => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const {
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ openConditionalPanel,
+ selectedSource,
+ } = this.props;
+
+ const line = this.getCurrentLine();
+
+ const { codeMirror } = this.state.editor;
+ // add one to column for correct position in editor.
+ const column = getCursorColumn(codeMirror) + 1;
+
+ if (conditionalPanelLocation) {
+ return closeConditionalPanel();
+ }
+
+ if (!selectedSource || typeof line !== "number") {
+ return null;
+ }
+
+ return openConditionalPanel(
+ createLocation({
+ line,
+ column,
+ source: selectedSource,
+ }),
+ false
+ );
+ };
+
+ onEditorScroll = debounce(this.props.updateViewport, 75);
+
+ commandKeyDown = e => {
+ const { key } = e;
+ if (this.props.isPaused && key === "Meta") {
+ const { cx, highlightCalls } = this.props;
+ highlightCalls(cx);
+ }
+ };
+
+ commandKeyUp = e => {
+ const { key } = e;
+ if (key === "Meta") {
+ const { cx, unhighlightCalls } = this.props;
+ unhighlightCalls(cx);
+ }
+ };
+
+ onKeyDown(e) {
+ const { codeMirror } = this.state.editor;
+ const { key, target } = e;
+ const codeWrapper = codeMirror.getWrapperElement();
+ const textArea = codeWrapper.querySelector("textArea");
+
+ if (key === "Escape" && target == textArea) {
+ e.stopPropagation();
+ e.preventDefault();
+ codeWrapper.focus();
+ } else if (key === "Enter" && target == codeWrapper) {
+ e.preventDefault();
+ // Focus into editor's text area
+ textArea.focus();
+ }
+ }
+
+ /*
+ * The default Esc command is overridden in the CodeMirror keymap to allow
+ * the Esc keypress event to be catched by the toolbox and trigger the
+ * split console. Restore it here, but preventDefault if and only if there
+ * is a multiselection.
+ */
+ onEscape = e => {
+ if (!this.state.editor) {
+ return;
+ }
+
+ const { codeMirror } = this.state.editor;
+ if (codeMirror.listSelections().length > 1) {
+ codeMirror.execCommand("singleSelection");
+ e.preventDefault();
+ }
+ };
+
+ openMenu(event) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const {
+ cx,
+ selectedSource,
+ selectedSourceTextContent,
+ breakpointActions,
+ editorActions,
+ isPaused,
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ isSourceOnIgnoreList,
+ blackboxedRanges,
+ } = this.props;
+ const { editor } = this.state;
+ if (!selectedSource || !editor) {
+ return;
+ }
+
+ // only allow one conditionalPanel location.
+ if (conditionalPanelLocation) {
+ closeConditionalPanel();
+ }
+
+ const target = event.target;
+ const { id: sourceId } = selectedSource;
+ const line = lineAtHeight(editor, sourceId, event);
+
+ if (typeof line != "number") {
+ return;
+ }
+
+ const location = createLocation({
+ line,
+ column: undefined,
+ source: selectedSource,
+ });
+
+ if (target.classList.contains("CodeMirror-linenumber")) {
+ const lineText = getLineText(
+ sourceId,
+ selectedSourceTextContent,
+ line
+ ).trim();
+
+ showMenu(event, [
+ ...createBreakpointItems(cx, location, breakpointActions, lineText),
+ { type: "separator" },
+ continueToHereItem(cx, location, isPaused, editorActions),
+ { type: "separator" },
+ blackBoxLineMenuItem(
+ cx,
+ selectedSource,
+ editorActions,
+ editor,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ line
+ ),
+ ]);
+ return;
+ }
+
+ if (target.getAttribute("id") === "columnmarker") {
+ return;
+ }
+
+ this.setState({ contextMenu: event });
+ }
+
+ clearContextMenu = () => {
+ this.setState({ contextMenu: null });
+ };
+
+ onGutterClick = (cm, line, gutter, ev) => {
+ const {
+ cx,
+ selectedSource,
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ addBreakpointAtLine,
+ continueToHere,
+ breakableLines,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ // ignore right clicks in the gutter
+ if (isSecondary(ev) || ev.button === 2 || !selectedSource) {
+ return;
+ }
+
+ if (conditionalPanelLocation) {
+ closeConditionalPanel();
+ return;
+ }
+
+ if (gutter === "CodeMirror-foldgutter") {
+ return;
+ }
+
+ const sourceLine = toSourceLine(selectedSource.id, line);
+ if (typeof sourceLine !== "number") {
+ return;
+ }
+
+ // ignore clicks on a non-breakable line
+ if (!breakableLines.has(sourceLine)) {
+ return;
+ }
+
+ if (isCmd(ev)) {
+ continueToHere(
+ cx,
+ createLocation({
+ line: sourceLine,
+ column: undefined,
+ source: selectedSource,
+ })
+ );
+ return;
+ }
+
+ addBreakpointAtLine(
+ cx,
+ sourceLine,
+ ev.altKey,
+ ev.shiftKey ||
+ isLineBlackboxed(
+ blackboxedRanges[selectedSource.url],
+ sourceLine,
+ isSourceOnIgnoreList
+ )
+ );
+ };
+
+ onGutterContextMenu = event => {
+ this.openMenu(event);
+ };
+
+ onClick(e) {
+ const { cx, selectedSource, updateCursorPosition, jumpToMappedLocation } =
+ this.props;
+
+ if (selectedSource) {
+ const sourceLocation = getSourceLocationFromMouseEvent(
+ this.state.editor,
+ selectedSource,
+ e
+ );
+
+ if (e.metaKey && e.altKey) {
+ jumpToMappedLocation(cx, sourceLocation);
+ }
+
+ updateCursorPosition(sourceLocation);
+ }
+ }
+
+ shouldScrollToLocation(nextProps, editor) {
+ const { selectedLocation, selectedSource, selectedSourceTextContent } =
+ this.props;
+ if (
+ !editor ||
+ !nextProps.selectedSource ||
+ !nextProps.selectedLocation ||
+ !nextProps.selectedLocation.line ||
+ !nextProps.selectedSourceTextContent
+ ) {
+ return false;
+ }
+
+ const isFirstLoad =
+ (!selectedSource || !selectedSourceTextContent) &&
+ nextProps.selectedSourceTextContent;
+ const locationChanged = selectedLocation !== nextProps.selectedLocation;
+ const symbolsChanged = nextProps.symbols != this.props.symbols;
+
+ return isFirstLoad || locationChanged || symbolsChanged;
+ }
+
+ scrollToLocation(nextProps, editor) {
+ const { selectedLocation, selectedSource } = nextProps;
+
+ let { line, column } = toEditorPosition(selectedLocation);
+
+ if (selectedSource && hasDocument(selectedSource.id)) {
+ const doc = getDocument(selectedSource.id);
+ const lineText = doc.getLine(line);
+ column = Math.max(column, getIndentation(lineText));
+ }
+
+ scrollToColumn(editor.codeMirror, line, column);
+ }
+
+ setText(props, editor) {
+ const { selectedSource, selectedSourceTextContent, symbols } = props;
+
+ if (!editor) {
+ return;
+ }
+
+ // check if we previously had a selected source
+ if (!selectedSource) {
+ this.clearEditor();
+ return;
+ }
+
+ if (!selectedSourceTextContent?.value) {
+ showLoading(editor);
+ return;
+ }
+
+ if (selectedSourceTextContent.state === "rejected") {
+ let { value } = selectedSourceTextContent;
+ if (typeof value !== "string") {
+ value = "Unexpected source error";
+ }
+
+ this.showErrorMessage(value);
+ return;
+ }
+
+ showSourceText(editor, selectedSource, selectedSourceTextContent, symbols);
+ }
+
+ clearEditor() {
+ const { editor } = this.state;
+ if (!editor) {
+ return;
+ }
+
+ clearEditor(editor);
+ }
+
+ showErrorMessage(msg) {
+ const { editor } = this.state;
+ if (!editor) {
+ return;
+ }
+
+ showErrorMessage(editor, msg);
+ }
+
+ getInlineEditorStyles() {
+ const { searchInFileEnabled } = this.props;
+
+ if (searchInFileEnabled) {
+ return {
+ height: `calc(100% - ${cssVars.searchbarHeight})`,
+ };
+ }
+
+ return {
+ height: "100%",
+ };
+ }
+
+ renderItems() {
+ const {
+ cx,
+ selectedSource,
+ conditionalPanelLocation,
+ isPaused,
+ inlinePreviewEnabled,
+ editorWrappingEnabled,
+ highlightedLineRange,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ selectedSourceIsBlackBoxed,
+ } = this.props;
+ const { editor, contextMenu } = this.state;
+
+ if (!selectedSource || !editor || !getDocument(selectedSource.id)) {
+ return null;
+ }
+
+ return (
+ <div>
+ <HighlightCalls editor={editor} selectedSource={selectedSource} />
+ <DebugLine />
+ <HighlightLine />
+ <EmptyLines editor={editor} />
+ <Breakpoints editor={editor} cx={cx} />
+ <Preview editor={editor} editorRef={this.$editorWrapper} />
+ {highlightedLineRange ? (
+ <HighlightLines editor={editor} range={highlightedLineRange} />
+ ) : null}
+ {isSourceOnIgnoreList || selectedSourceIsBlackBoxed ? (
+ <BlackboxLines
+ editor={editor}
+ selectedSource={selectedSource}
+ isSourceOnIgnoreList={isSourceOnIgnoreList}
+ blackboxedRangesForSelectedSource={
+ blackboxedRanges[selectedSource.url]
+ }
+ />
+ ) : null}
+ <Exceptions />
+ <EditorMenu
+ editor={editor}
+ contextMenu={contextMenu}
+ clearContextMenu={this.clearContextMenu}
+ selectedSource={selectedSource}
+ editorWrappingEnabled={editorWrappingEnabled}
+ />
+ {conditionalPanelLocation ? <ConditionalPanel editor={editor} /> : null}
+ <ColumnBreakpoints editor={editor} />
+ {isPaused && inlinePreviewEnabled ? (
+ <InlinePreviews editor={editor} selectedSource={selectedSource} />
+ ) : null}
+ </div>
+ );
+ }
+
+ renderSearchInFileBar() {
+ if (!this.props.selectedSource) {
+ return null;
+ }
+
+ return <SearchInFileBar editor={this.state.editor} />;
+ }
+
+ render() {
+ const { selectedSourceIsBlackBoxed, skipPausing } = this.props;
+ return (
+ <div
+ className={classnames("editor-wrapper", {
+ blackboxed: selectedSourceIsBlackBoxed,
+ "skip-pausing": skipPausing,
+ })}
+ ref={c => (this.$editorWrapper = c)}
+ >
+ <div
+ className="editor-mount devtools-monospace"
+ style={this.getInlineEditorStyles()}
+ />
+ {this.renderSearchInFileBar()}
+ {this.renderItems()}
+ </div>
+ );
+ }
+}
+
+Editor.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ const selectedLocation = getSelectedLocation(state);
+
+ return {
+ cx: getThreadContext(state),
+ selectedLocation,
+ selectedSource,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ selectedSourceIsBlackBoxed: selectedSource
+ ? isSourceBlackBoxed(state, selectedSource)
+ : null,
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, selectedSource),
+ searchInFileEnabled: getActiveSearch(state) === "file",
+ conditionalPanelLocation: getConditionalPanelLocation(state),
+ symbols: getSymbols(state, selectedLocation),
+ isPaused: getIsCurrentThreadPaused(state),
+ skipPausing: getSkipPausing(state),
+ inlinePreviewEnabled: getInlinePreview(state),
+ editorWrappingEnabled: getEditorWrapping(state),
+ highlightedCalls: getHighlightedCalls(state, getCurrentThread(state)),
+ blackboxedRanges: getBlackBoxRanges(state),
+ breakableLines: getSelectedBreakableLines(state),
+ highlightedLineRange: getHighlightedLineRangeForSelectedSource(state),
+ };
+};
+
+const mapDispatchToProps = dispatch => ({
+ ...bindActionCreators(
+ {
+ openConditionalPanel: actions.openConditionalPanel,
+ closeConditionalPanel: actions.closeConditionalPanel,
+ continueToHere: actions.continueToHere,
+ toggleBreakpointAtLine: actions.toggleBreakpointAtLine,
+ addBreakpointAtLine: actions.addBreakpointAtLine,
+ jumpToMappedLocation: actions.jumpToMappedLocation,
+ updateViewport: actions.updateViewport,
+ updateCursorPosition: actions.updateCursorPosition,
+ closeTab: actions.closeTab,
+ toggleBlackBox: actions.toggleBlackBox,
+ highlightCalls: actions.highlightCalls,
+ unhighlightCalls: actions.unhighlightCalls,
+ },
+ dispatch
+ ),
+ breakpointActions: breakpointItemActions(dispatch),
+ editorActions: editorItemActions(dispatch),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Editor);
diff --git a/devtools/client/debugger/src/components/Editor/menus/breakpoints.js b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js
new file mode 100644
index 0000000000..b130d8a9b7
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js
@@ -0,0 +1,293 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import actions from "../../../actions";
+import { bindActionCreators } from "redux";
+import { features } from "../../../utils/prefs";
+import { formatKeyShortcut } from "../../../utils/text";
+import { isLineBlackboxed } from "../../../utils/source";
+
+export const addBreakpointItem = (cx, location, breakpointActions) => ({
+ id: "node-menu-add-breakpoint",
+ label: L10N.getStr("editor.addBreakpoint"),
+ accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.addBreakpoint(cx, location),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")),
+});
+
+export const removeBreakpointItem = (cx, breakpoint, breakpointActions) => ({
+ id: "node-menu-remove-breakpoint",
+ label: L10N.getStr("editor.removeBreakpoint"),
+ accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.removeBreakpoint(cx, breakpoint),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")),
+});
+
+export const addConditionalBreakpointItem = (location, breakpointActions) => ({
+ id: "node-menu-add-conditional-breakpoint",
+ label: L10N.getStr("editor.addConditionBreakpoint"),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")),
+ accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.openConditionalPanel(location),
+});
+
+export const editConditionalBreakpointItem = (location, breakpointActions) => ({
+ id: "node-menu-edit-conditional-breakpoint",
+ label: L10N.getStr("editor.editConditionBreakpoint"),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")),
+ accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.openConditionalPanel(location),
+});
+
+export const conditionalBreakpointItem = (
+ breakpoint,
+ location,
+ breakpointActions
+) => {
+ const {
+ options: { condition },
+ } = breakpoint;
+ return condition
+ ? editConditionalBreakpointItem(location, breakpointActions)
+ : addConditionalBreakpointItem(location, breakpointActions);
+};
+
+export const addLogPointItem = (location, breakpointActions) => ({
+ id: "node-menu-add-log-point",
+ label: L10N.getStr("editor.addLogPoint"),
+ accesskey: L10N.getStr("editor.addLogPoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.openConditionalPanel(location, true),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")),
+});
+
+export const editLogPointItem = (location, breakpointActions) => ({
+ id: "node-menu-edit-log-point",
+ label: L10N.getStr("editor.editLogPoint"),
+ accesskey: L10N.getStr("editor.editLogPoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.openConditionalPanel(location, true),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")),
+});
+
+export const logPointItem = (breakpoint, location, breakpointActions) => {
+ const {
+ options: { logValue },
+ } = breakpoint;
+ return logValue
+ ? editLogPointItem(location, breakpointActions)
+ : addLogPointItem(location, breakpointActions);
+};
+
+export const toggleDisabledBreakpointItem = (
+ cx,
+ breakpoint,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+) => {
+ return {
+ accesskey: L10N.getStr("editor.disableBreakpoint.accesskey"),
+ disabled: isLineBlackboxed(
+ blackboxedRangesForSelectedSource,
+ breakpoint.location.line,
+ isSelectedSourceOnIgnoreList
+ ),
+ click: () => breakpointActions.toggleDisabledBreakpoint(cx, breakpoint),
+ ...(breakpoint.disabled
+ ? {
+ id: "node-menu-enable-breakpoint",
+ label: L10N.getStr("editor.enableBreakpoint"),
+ }
+ : {
+ id: "node-menu-disable-breakpoint",
+ label: L10N.getStr("editor.disableBreakpoint"),
+ }),
+ };
+};
+
+export const toggleDbgStatementItem = (
+ cx,
+ location,
+ breakpointActions,
+ breakpoint
+) => {
+ if (breakpoint && breakpoint.options.condition === "false") {
+ return {
+ disabled: false,
+ id: "node-menu-enable-dbgStatement",
+ label: L10N.getStr("breakpointMenuItem.enabledbg.label"),
+ click: () =>
+ breakpointActions.setBreakpointOptions(cx, location, {
+ ...breakpoint.options,
+ condition: null,
+ }),
+ };
+ }
+
+ return {
+ disabled: false,
+ id: "node-menu-disable-dbgStatement",
+ label: L10N.getStr("breakpointMenuItem.disabledbg.label"),
+ click: () =>
+ breakpointActions.setBreakpointOptions(cx, location, {
+ condition: "false",
+ }),
+ };
+};
+
+export function breakpointItems(
+ cx,
+ breakpoint,
+ selectedLocation,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+) {
+ const items = [
+ removeBreakpointItem(cx, breakpoint, breakpointActions),
+ toggleDisabledBreakpointItem(
+ cx,
+ breakpoint,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+ ),
+ ];
+
+ if (breakpoint.originalText.startsWith("debugger")) {
+ items.push(
+ { type: "separator" },
+ toggleDbgStatementItem(
+ cx,
+ selectedLocation,
+ breakpointActions,
+ breakpoint
+ )
+ );
+ }
+
+ items.push(
+ { type: "separator" },
+ removeBreakpointsOnLineItem(cx, selectedLocation, breakpointActions),
+ breakpoint.disabled
+ ? enableBreakpointsOnLineItem(
+ cx,
+ selectedLocation,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+ )
+ : disableBreakpointsOnLineItem(cx, selectedLocation, breakpointActions),
+ { type: "separator" }
+ );
+
+ items.push(
+ conditionalBreakpointItem(breakpoint, selectedLocation, breakpointActions)
+ );
+ items.push(logPointItem(breakpoint, selectedLocation, breakpointActions));
+
+ return items;
+}
+
+export function createBreakpointItems(
+ cx,
+ location,
+ breakpointActions,
+ lineText
+) {
+ const items = [
+ addBreakpointItem(cx, location, breakpointActions),
+ addConditionalBreakpointItem(location, breakpointActions),
+ ];
+
+ if (features.logPoints) {
+ items.push(addLogPointItem(location, breakpointActions));
+ }
+
+ if (lineText && lineText.startsWith("debugger")) {
+ items.push(toggleDbgStatementItem(cx, location, breakpointActions));
+ }
+ return items;
+}
+
+// ToDo: Only enable if there are more than one breakpoints on a line?
+export const removeBreakpointsOnLineItem = (
+ cx,
+ location,
+ breakpointActions
+) => ({
+ id: "node-menu-remove-breakpoints-on-line",
+ label: L10N.getStr("breakpointMenuItem.removeAllAtLine.label"),
+ accesskey: L10N.getStr("breakpointMenuItem.removeAllAtLine.accesskey"),
+ disabled: false,
+ click: () =>
+ breakpointActions.removeBreakpointsAtLine(
+ cx,
+ location.sourceId,
+ location.line
+ ),
+});
+
+export const enableBreakpointsOnLineItem = (
+ cx,
+ location,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+) => ({
+ id: "node-menu-remove-breakpoints-on-line",
+ label: L10N.getStr("breakpointMenuItem.enableAllAtLine.label"),
+ accesskey: L10N.getStr("breakpointMenuItem.enableAllAtLine.accesskey"),
+ disabled: isLineBlackboxed(
+ blackboxedRangesForSelectedSource,
+ location.line,
+ isSelectedSourceOnIgnoreList
+ ),
+ click: () =>
+ breakpointActions.enableBreakpointsAtLine(
+ cx,
+ location.sourceId,
+ location.line
+ ),
+});
+
+export const disableBreakpointsOnLineItem = (
+ cx,
+ location,
+ breakpointActions
+) => ({
+ id: "node-menu-remove-breakpoints-on-line",
+ label: L10N.getStr("breakpointMenuItem.disableAllAtLine.label"),
+ accesskey: L10N.getStr("breakpointMenuItem.disableAllAtLine.accesskey"),
+ disabled: false,
+ click: () =>
+ breakpointActions.disableBreakpointsAtLine(
+ cx,
+ location.sourceId,
+ location.line
+ ),
+});
+
+export function breakpointItemActions(dispatch) {
+ return bindActionCreators(
+ {
+ addBreakpoint: actions.addBreakpoint,
+ removeBreakpoint: actions.removeBreakpoint,
+ removeBreakpointsAtLine: actions.removeBreakpointsAtLine,
+ enableBreakpointsAtLine: actions.enableBreakpointsAtLine,
+ disableBreakpointsAtLine: actions.disableBreakpointsAtLine,
+ disableBreakpoint: actions.disableBreakpoint,
+ toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint,
+ toggleBreakpointsAtLine: actions.toggleBreakpointsAtLine,
+ setBreakpointOptions: actions.setBreakpointOptions,
+ openConditionalPanel: actions.openConditionalPanel,
+ },
+ dispatch
+ );
+}
diff --git a/devtools/client/debugger/src/components/Editor/menus/editor.js b/devtools/client/debugger/src/components/Editor/menus/editor.js
new file mode 100644
index 0000000000..5ed3c96f6f
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/menus/editor.js
@@ -0,0 +1,403 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { bindActionCreators } from "redux";
+
+import { copyToTheClipboard } from "../../../utils/clipboard";
+import {
+ getRawSourceURL,
+ getFilename,
+ shouldBlackbox,
+ findBlackBoxRange,
+} from "../../../utils/source";
+import { toSourceLine } from "../../../utils/editor";
+import { downloadFile } from "../../../utils/utils";
+import { features } from "../../../utils/prefs";
+
+import { isFulfilled } from "../../../utils/async-value";
+import actions from "../../../actions";
+
+// Menu Items
+export const continueToHereItem = (cx, location, isPaused, editorActions) => ({
+ accesskey: L10N.getStr("editor.continueToHere.accesskey"),
+ disabled: !isPaused,
+ click: () => editorActions.continueToHere(cx, location),
+ id: "node-menu-continue-to-here",
+ label: L10N.getStr("editor.continueToHere.label"),
+});
+
+const copyToClipboardItem = (selectionText, editorActions) => ({
+ id: "node-menu-copy-to-clipboard",
+ label: L10N.getStr("copyToClipboard.label"),
+ accesskey: L10N.getStr("copyToClipboard.accesskey"),
+ disabled: selectionText.length === 0,
+ click: () => copyToTheClipboard(selectionText),
+});
+
+const copySourceItem = (selectedContent, editorActions) => ({
+ id: "node-menu-copy-source",
+ label: L10N.getStr("copySource.label"),
+ accesskey: L10N.getStr("copySource.accesskey"),
+ disabled: false,
+ click: () =>
+ selectedContent.type === "text" &&
+ copyToTheClipboard(selectedContent.value),
+});
+
+const copySourceUri2Item = (selectedSource, editorActions) => ({
+ id: "node-menu-copy-source-url",
+ label: L10N.getStr("copySourceUri2"),
+ accesskey: L10N.getStr("copySourceUri2.accesskey"),
+ disabled: !selectedSource.url,
+ click: () => copyToTheClipboard(getRawSourceURL(selectedSource.url)),
+});
+
+const jumpToMappedLocationItem = (
+ cx,
+ selectedSource,
+ location,
+ hasMappedLocation,
+ editorActions
+) => ({
+ id: "node-menu-jump",
+ label: L10N.getFormatStr(
+ "editor.jumpToMappedLocation1",
+ selectedSource.isOriginal
+ ? L10N.getStr("generated")
+ : L10N.getStr("original")
+ ),
+ accesskey: L10N.getStr("editor.jumpToMappedLocation1.accesskey"),
+ disabled: !hasMappedLocation,
+ click: () => editorActions.jumpToMappedLocation(cx, location),
+});
+
+const showSourceMenuItem = (cx, selectedSource, editorActions) => ({
+ id: "node-menu-show-source",
+ label: L10N.getStr("sourceTabs.revealInTree"),
+ accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"),
+ disabled: !selectedSource.url,
+ click: () => editorActions.showSource(cx, selectedSource.id),
+});
+
+const blackBoxMenuItem = (
+ cx,
+ selectedSource,
+ blackboxedRanges,
+ editorActions,
+ isSourceOnIgnoreList
+) => {
+ const isBlackBoxed = !!blackboxedRanges[selectedSource.url];
+ return {
+ id: "node-menu-blackbox",
+ label: isBlackBoxed
+ ? L10N.getStr("ignoreContextItem.unignore")
+ : L10N.getStr("ignoreContextItem.ignore"),
+ accesskey: isBlackBoxed
+ ? L10N.getStr("ignoreContextItem.unignore.accesskey")
+ : L10N.getStr("ignoreContextItem.ignore.accesskey"),
+ disabled: isSourceOnIgnoreList || !shouldBlackbox(selectedSource),
+ click: () => editorActions.toggleBlackBox(cx, selectedSource),
+ };
+};
+
+export const blackBoxLineMenuItem = (
+ cx,
+ selectedSource,
+ editorActions,
+ editor,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ // the clickedLine is passed when the context menu
+ // is opened from the gutter, it is not available when the
+ // the context menu is opened from the editor.
+ clickedLine = null
+) => {
+ const { codeMirror } = editor;
+ const from = codeMirror.getCursor("from");
+ const to = codeMirror.getCursor("to");
+
+ const startLine = clickedLine ?? toSourceLine(selectedSource.id, from.line);
+ const endLine = clickedLine ?? toSourceLine(selectedSource.id, to.line);
+
+ const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, {
+ start: startLine,
+ end: endLine,
+ });
+
+ const selectedLineIsBlackBoxed = !!blackboxRange;
+
+ const isSingleLine = selectedLineIsBlackBoxed
+ ? blackboxRange.start.line == blackboxRange.end.line
+ : startLine == endLine;
+
+ const isSourceFullyBlackboxed =
+ blackboxedRanges[selectedSource.url] &&
+ !blackboxedRanges[selectedSource.url].length;
+
+ // The ignore/unignore line context menu item should be disabled when
+ // 1) The source is on the sourcemap ignore list
+ // 2) The whole source is blackboxed or
+ // 3) Multiple lines are blackboxed or
+ // 4) Multiple lines are selected in the editor
+ const shouldDisable =
+ isSourceOnIgnoreList || isSourceFullyBlackboxed || !isSingleLine;
+
+ return {
+ id: "node-menu-blackbox-line",
+ label: !selectedLineIsBlackBoxed
+ ? L10N.getStr("ignoreContextItem.ignoreLine")
+ : L10N.getStr("ignoreContextItem.unignoreLine"),
+ accesskey: !selectedLineIsBlackBoxed
+ ? L10N.getStr("ignoreContextItem.ignoreLine.accesskey")
+ : L10N.getStr("ignoreContextItem.unignoreLine.accesskey"),
+ disabled: shouldDisable,
+ click: () => {
+ const selectionRange = {
+ start: {
+ line: startLine,
+ column: clickedLine == null ? from.ch : 0,
+ },
+ end: {
+ line: endLine,
+ column: clickedLine == null ? to.ch : 0,
+ },
+ };
+
+ editorActions.toggleBlackBox(
+ cx,
+ selectedSource,
+ !selectedLineIsBlackBoxed,
+ selectedLineIsBlackBoxed ? [blackboxRange] : [selectionRange]
+ );
+ },
+ };
+};
+
+const blackBoxLinesMenuItem = (
+ cx,
+ selectedSource,
+ editorActions,
+ editor,
+ blackboxedRanges,
+ isSourceOnIgnoreList
+) => {
+ const { codeMirror } = editor;
+ const from = codeMirror.getCursor("from");
+ const to = codeMirror.getCursor("to");
+
+ const startLine = toSourceLine(selectedSource.id, from.line);
+ const endLine = toSourceLine(selectedSource.id, to.line);
+
+ const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, {
+ start: startLine,
+ end: endLine,
+ });
+
+ const selectedLinesAreBlackBoxed = !!blackboxRange;
+
+ return {
+ id: "node-menu-blackbox-lines",
+ label: !selectedLinesAreBlackBoxed
+ ? L10N.getStr("ignoreContextItem.ignoreLines")
+ : L10N.getStr("ignoreContextItem.unignoreLines"),
+ accesskey: !selectedLinesAreBlackBoxed
+ ? L10N.getStr("ignoreContextItem.ignoreLines.accesskey")
+ : L10N.getStr("ignoreContextItem.unignoreLines.accesskey"),
+ disabled: isSourceOnIgnoreList,
+ click: () => {
+ const selectionRange = {
+ start: {
+ line: startLine,
+ column: from.ch,
+ },
+ end: {
+ line: endLine,
+ column: to.ch,
+ },
+ };
+
+ editorActions.toggleBlackBox(
+ cx,
+ selectedSource,
+ !selectedLinesAreBlackBoxed,
+ selectedLinesAreBlackBoxed ? [blackboxRange] : [selectionRange]
+ );
+ },
+ };
+};
+
+const watchExpressionItem = (
+ cx,
+ selectedSource,
+ selectionText,
+ editorActions
+) => ({
+ id: "node-menu-add-watch-expression",
+ label: L10N.getStr("expressions.label"),
+ accesskey: L10N.getStr("expressions.accesskey"),
+ click: () => editorActions.addExpression(cx, selectionText),
+});
+
+const evaluateInConsoleItem = (
+ selectedSource,
+ selectionText,
+ editorActions
+) => ({
+ id: "node-menu-evaluate-in-console",
+ label: L10N.getStr("evaluateInConsole.label"),
+ click: () => editorActions.evaluateInConsole(selectionText),
+});
+
+const downloadFileItem = (selectedSource, selectedContent, editorActions) => ({
+ id: "node-menu-download-file",
+ label: L10N.getStr("downloadFile.label"),
+ accesskey: L10N.getStr("downloadFile.accesskey"),
+ click: () => downloadFile(selectedContent, getFilename(selectedSource)),
+});
+
+const inlinePreviewItem = editorActions => ({
+ id: "node-menu-inline-preview",
+ label: features.inlinePreview
+ ? L10N.getStr("inlinePreview.hide.label")
+ : L10N.getStr("inlinePreview.show.label"),
+ click: () => editorActions.toggleInlinePreview(!features.inlinePreview),
+});
+
+const editorWrappingItem = (editorActions, editorWrappingEnabled) => ({
+ id: "node-menu-editor-wrapping",
+ label: editorWrappingEnabled
+ ? L10N.getStr("editorWrapping.hide.label")
+ : L10N.getStr("editorWrapping.show.label"),
+ click: () => editorActions.toggleEditorWrapping(!editorWrappingEnabled),
+});
+
+export function editorMenuItems({
+ cx,
+ editorActions,
+ selectedSource,
+ blackboxedRanges,
+ location,
+ selectionText,
+ hasMappedLocation,
+ isTextSelected,
+ isPaused,
+ editorWrappingEnabled,
+ editor,
+ isSourceOnIgnoreList,
+}) {
+ const items = [];
+
+ const content =
+ selectedSource.content && isFulfilled(selectedSource.content)
+ ? selectedSource.content.value
+ : null;
+
+ items.push(
+ jumpToMappedLocationItem(
+ cx,
+ selectedSource,
+ location,
+ hasMappedLocation,
+ editorActions
+ ),
+ continueToHereItem(cx, location, isPaused, editorActions),
+ { type: "separator" },
+ copyToClipboardItem(selectionText, editorActions),
+ ...(!selectedSource.isWasm
+ ? [
+ ...(content ? [copySourceItem(content, editorActions)] : []),
+ copySourceUri2Item(selectedSource, editorActions),
+ ]
+ : []),
+ ...(content
+ ? [downloadFileItem(selectedSource, content, editorActions)]
+ : []),
+ { type: "separator" },
+ showSourceMenuItem(cx, selectedSource, editorActions),
+ { type: "separator" },
+ blackBoxMenuItem(
+ cx,
+ selectedSource,
+ blackboxedRanges,
+ editorActions,
+ isSourceOnIgnoreList
+ )
+ );
+
+ const startLine = toSourceLine(
+ selectedSource.id,
+ editor.codeMirror.getCursor("from").line
+ );
+ const endLine = toSourceLine(
+ selectedSource.id,
+ editor.codeMirror.getCursor("to").line
+ );
+
+ // Find any blackbox ranges that exist for the selected lines
+ const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, {
+ start: startLine,
+ end: endLine,
+ });
+
+ const isMultiLineSelection = blackboxRange
+ ? blackboxRange.start.line !== blackboxRange.end.line
+ : startLine !== endLine;
+
+ // When the range is defined and is an empty array,
+ // the whole source is blackboxed
+ const theWholeSourceIsBlackBoxed =
+ blackboxedRanges[selectedSource.url] &&
+ !blackboxedRanges[selectedSource.url].length;
+
+ if (!theWholeSourceIsBlackBoxed) {
+ const blackBoxSourceLinesMenuItem = isMultiLineSelection
+ ? blackBoxLinesMenuItem
+ : blackBoxLineMenuItem;
+
+ items.push(
+ blackBoxSourceLinesMenuItem(
+ cx,
+ selectedSource,
+ editorActions,
+ editor,
+ blackboxedRanges,
+ isSourceOnIgnoreList
+ )
+ );
+ }
+
+ if (isTextSelected) {
+ items.push(
+ { type: "separator" },
+ watchExpressionItem(cx, selectedSource, selectionText, editorActions),
+ evaluateInConsoleItem(selectedSource, selectionText, editorActions)
+ );
+ }
+
+ items.push(
+ { type: "separator" },
+ inlinePreviewItem(editorActions),
+ editorWrappingItem(editorActions, editorWrappingEnabled)
+ );
+
+ return items;
+}
+
+export function editorItemActions(dispatch) {
+ return bindActionCreators(
+ {
+ addExpression: actions.addExpression,
+ continueToHere: actions.continueToHere,
+ evaluateInConsole: actions.evaluateInConsole,
+ flashLineRange: actions.flashLineRange,
+ jumpToMappedLocation: actions.jumpToMappedLocation,
+ showSource: actions.showSource,
+ toggleBlackBox: actions.toggleBlackBox,
+ toggleBlackBoxLines: actions.toggleBlackBoxLines,
+ toggleInlinePreview: actions.toggleInlinePreview,
+ toggleEditorWrapping: actions.toggleEditorWrapping,
+ },
+ dispatch
+ );
+}
diff --git a/devtools/client/debugger/src/components/Editor/menus/moz.build b/devtools/client/debugger/src/components/Editor/menus/moz.build
new file mode 100644
index 0000000000..18009aa2db
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/menus/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "breakpoints.js",
+ "editor.js",
+ "source.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/menus/source.js b/devtools/client/debugger/src/components/Editor/menus/source.js
new file mode 100644
index 0000000000..0ba8834e6f
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/menus/source.js
@@ -0,0 +1,3 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
diff --git a/devtools/client/debugger/src/components/Editor/moz.build b/devtools/client/debugger/src/components/Editor/moz.build
new file mode 100644
index 0000000000..b31918f2e0
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/moz.build
@@ -0,0 +1,34 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "menus",
+ "Preview",
+]
+
+CompiledModules(
+ "BlackboxLines.js",
+ "Breakpoint.js",
+ "Breakpoints.js",
+ "ColumnBreakpoint.js",
+ "ColumnBreakpoints.js",
+ "ConditionalPanel.js",
+ "DebugLine.js",
+ "EditorMenu.js",
+ "EmptyLines.js",
+ "Exception.js",
+ "Exceptions.js",
+ "Footer.js",
+ "HighlightCalls.js",
+ "HighlightLine.js",
+ "HighlightLines.js",
+ "index.js",
+ "InlinePreview.js",
+ "InlinePreviewRow.js",
+ "InlinePreviews.js",
+ "SearchInFileBar.js",
+ "Tab.js",
+ "Tabs.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js
new file mode 100644
index 0000000000..915b812dff
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import Breakpoints from "../Breakpoints";
+
+const BreakpointsComponent = Breakpoints.WrappedComponent;
+
+function generateDefaults(overrides) {
+ const sourceId = "server1.conn1.child1/source1";
+ const matchingBreakpoints = [{ location: { source: { id: sourceId } } }];
+
+ return {
+ selectedSource: { sourceId, get: () => false },
+ editor: {
+ codeMirror: {
+ setGutterMarker: jest.fn(),
+ },
+ },
+ blackboxedRanges: {},
+ cx: {},
+ breakpointActions: {},
+ editorActions: {},
+ breakpoints: matchingBreakpoints,
+ ...overrides,
+ };
+}
+
+function render(overrides = {}) {
+ const props = generateDefaults(overrides);
+ const component = shallow(<BreakpointsComponent {...props} />);
+ return { component, props };
+}
+
+describe("Breakpoints Component", () => {
+ it("should render breakpoints without columns", async () => {
+ const sourceId = "server1.conn1.child1/source1";
+ const breakpoints = [{ location: { source: { id: sourceId } } }];
+
+ const { component, props } = render({ breakpoints });
+ expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length);
+ });
+
+ it("should render breakpoints with columns", async () => {
+ const sourceId = "server1.conn1.child1/source1";
+ const breakpoints = [{ location: { column: 2, source: { id: sourceId } } }];
+
+ const { component, props } = render({ breakpoints });
+ expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length);
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js
new file mode 100644
index 0000000000..05e4dcb727
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js
@@ -0,0 +1,77 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { mount } from "enzyme";
+import { ConditionalPanel } from "../ConditionalPanel";
+import * as mocks from "../../../utils/test-mockup";
+
+const source = mocks.makeMockSource();
+
+function generateDefaults(overrides, log, line, column, condition, logValue) {
+ const breakpoint = mocks.makeMockBreakpoint(source, line, column);
+ breakpoint.options.condition = condition;
+ breakpoint.options.logValue = logValue;
+
+ return {
+ editor: {
+ CodeMirror: {
+ fromTextArea: jest.fn(() => {
+ return {
+ on: jest.fn(),
+ getWrapperElement: jest.fn(() => {
+ return {
+ addEventListener: jest.fn(),
+ };
+ }),
+ focus: jest.fn(),
+ setCursor: jest.fn(),
+ lineCount: jest.fn(),
+ };
+ }),
+ },
+ codeMirror: {
+ addLineWidget: jest.fn(),
+ },
+ },
+ location: breakpoint.location,
+ source,
+ breakpoint,
+ log,
+ getDefaultValue: jest.fn(),
+ openConditionalPanel: jest.fn(),
+ closeConditionalPanel: jest.fn(),
+ ...overrides,
+ };
+}
+
+function render(log, line, column, condition, logValue, overrides = {}) {
+ const defaults = generateDefaults(
+ overrides,
+ log,
+ line,
+ column,
+ condition,
+ logValue
+ );
+ const props = { ...defaults, ...overrides };
+ const wrapper = mount(<ConditionalPanel {...props} />);
+ return { wrapper, props };
+}
+
+describe("ConditionalPanel", () => {
+ it("it should render at location of selected breakpoint", () => {
+ const { wrapper } = render(false, 2, 2);
+ expect(wrapper).toMatchSnapshot();
+ });
+ it("it should render with condition at selected breakpoint location", () => {
+ const { wrapper } = render(false, 3, 3, "I'm a condition", "not a log");
+ expect(wrapper).toMatchSnapshot();
+ });
+ it("it should render with logpoint at selected breakpoint location", () => {
+ const { wrapper } = render(true, 4, 4, "not a condition", "I'm a log");
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js
new file mode 100644
index 0000000000..a7fcb53a2d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js
@@ -0,0 +1,85 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import DebugLine from "../DebugLine";
+
+import { setDocument } from "../../../utils/editor";
+
+function createMockDocument(clear) {
+ const doc = {
+ addLineClass: jest.fn(),
+ removeLineClass: jest.fn(),
+ markText: jest.fn(() => ({ clear })),
+ getLine: line => "",
+ };
+
+ return doc;
+}
+
+function generateDefaults(editor, overrides) {
+ return {
+ editor,
+ pauseInfo: {
+ why: { type: "breakpoint" },
+ },
+ frame: null,
+ sourceTextContent: null,
+ ...overrides,
+ };
+}
+
+function createLocation(line) {
+ return {
+ source: {
+ id: "foo",
+ },
+ sourceId: "foo",
+ line,
+ column: 2,
+ };
+}
+
+function render(overrides = {}) {
+ const clear = jest.fn();
+ const editor = { codeMirror: {} };
+ const props = generateDefaults(editor, overrides);
+
+ const doc = createMockDocument(clear);
+ setDocument("foo", doc);
+
+ const component = shallow(<DebugLine.WrappedComponent {...props} />, {
+ lifecycleExperimental: true,
+ });
+ return { component, props, clear, editor, doc };
+}
+
+describe("DebugLine Component", () => {
+ describe("pausing at the first location", () => {
+ describe("when there is no selected frame", () => {
+ it("should not set the debug line", () => {
+ const { component, props, doc } = render({ frame: null });
+ const line = 2;
+ const location = createLocation(line);
+
+ component.setProps({ ...props, location });
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when there is a different source", () => {
+ it("should not set the debug line", async () => {
+ const { component, doc } = render();
+ const newSelectedFrame = { location: { sourceId: "bar" } };
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+
+ component.setProps({ frame: newSelectedFrame });
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js
new file mode 100644
index 0000000000..b58ba45cb3
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js
@@ -0,0 +1,67 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import SourceFooter from "../Footer";
+import { createSourceObject } from "../../../utils/test-head";
+import { setDocument } from "../../../utils/editor";
+
+function createMockDocument(clear, position) {
+ const doc = {
+ getCursor: jest.fn(() => position),
+ };
+ return doc;
+}
+
+function generateDefaults(overrides) {
+ return {
+ editor: {
+ codeMirror: {
+ doc: {},
+ cursorActivity: jest.fn(),
+ on: jest.fn(),
+ },
+ },
+ endPanelCollapsed: false,
+ selectedSource: {
+ ...createSourceObject("foo"),
+ content: null,
+ },
+ ...overrides,
+ };
+}
+
+function render(overrides = {}, position = { line: 0, column: 0 }) {
+ const clear = jest.fn();
+ const props = generateDefaults(overrides);
+
+ const doc = createMockDocument(clear, position);
+ setDocument(props.selectedSource.id, doc);
+
+ const component = shallow(<SourceFooter.WrappedComponent {...props} />, {
+ lifecycleExperimental: true,
+ });
+ return { component, props, clear, doc };
+}
+
+describe("SourceFooter Component", () => {
+ describe("default case", () => {
+ it("should render", () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+ });
+
+ describe("move cursor", () => {
+ it("should render new cursor position", () => {
+ const { component } = render();
+ component.setState({ cursorPosition: { line: 5, column: 10 } });
+
+ expect(component).toMatchSnapshot();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap
new file mode 100644
index 0000000000..48cda915a4
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Breakpoints Component should render breakpoints with columns 1`] = `
+<div>
+ <Breakpoint
+ breakpoint={
+ Object {
+ "location": Object {
+ "column": 2,
+ "source": Object {
+ "id": "server1.conn1.child1/source1",
+ },
+ },
+ }
+ }
+ breakpointActions={Object {}}
+ cx={Object {}}
+ editor={
+ Object {
+ "codeMirror": Object {
+ "setGutterMarker": [MockFunction],
+ },
+ }
+ }
+ editorActions={Object {}}
+ key="undefined:undefined:2"
+ selectedSource={
+ Object {
+ "get": [Function],
+ "sourceId": "server1.conn1.child1/source1",
+ }
+ }
+ />
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap
new file mode 100644
index 0000000000..d2f52bb6e3
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap
@@ -0,0 +1,630 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ConditionalPanel it should render at location of selected breakpoint 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "options": Object {
+ "condition": undefined,
+ "logValue": undefined,
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea />,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Breakpoint condition, e.g. items.length > 0",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 1,
+ <div>
+ <div
+ class="conditional-breakpoint-panel"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea />
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ }
+ }
+ log={false}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
+
+exports[`ConditionalPanel it should render with condition at selected breakpoint location 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "options": Object {
+ "condition": "I'm a condition",
+ "logValue": "not a log",
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea>
+ I'm a condition
+ </textarea>,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Breakpoint condition, e.g. items.length > 0",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 2,
+ <div>
+ <div
+ class="conditional-breakpoint-panel"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea>
+ I'm a condition
+ </textarea>
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ }
+ }
+ log={false}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
+
+exports[`ConditionalPanel it should render with logpoint at selected breakpoint location 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "options": Object {
+ "condition": "not a condition",
+ "logValue": "I'm a log",
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea>
+ I'm a log
+ </textarea>,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Log message, e.g. displayName",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 3,
+ <div>
+ <div
+ class="conditional-breakpoint-panel log-point"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea>
+ I'm a log
+ </textarea>
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ }
+ }
+ log={true}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap
new file mode 100644
index 0000000000..d6123d4c67
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap
@@ -0,0 +1,105 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SourceFooter Component default case should render 1`] = `
+<div
+ className="source-footer"
+>
+ <div
+ className="source-footer-start"
+ >
+ <div
+ className="commands"
+ >
+ <button
+ aria-label="Ignore source"
+ className="action black-box"
+ key="black-box"
+ onClick={[Function]}
+ title="Ignore source"
+ >
+ <AccessibleImage
+ className="blackBox"
+ />
+ </button>
+ <button
+ className="action prettyPrint"
+ disabled={true}
+ key="prettyPrint"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="prettyPrint"
+ />
+ </button>
+ </div>
+ </div>
+ <div
+ className="source-footer-end"
+ >
+ <div
+ className="cursor-position"
+ title="(Line 1, column 1)"
+ >
+ (1, 1)
+ </div>
+ <PaneToggleButton
+ collapsed={false}
+ horizontal={false}
+ key="toggle"
+ position="end"
+ />
+ </div>
+</div>
+`;
+
+exports[`SourceFooter Component move cursor should render new cursor position 1`] = `
+<div
+ className="source-footer"
+>
+ <div
+ className="source-footer-start"
+ >
+ <div
+ className="commands"
+ >
+ <button
+ aria-label="Ignore source"
+ className="action black-box"
+ key="black-box"
+ onClick={[Function]}
+ title="Ignore source"
+ >
+ <AccessibleImage
+ className="blackBox"
+ />
+ </button>
+ <button
+ className="action prettyPrint"
+ disabled={true}
+ key="prettyPrint"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="prettyPrint"
+ />
+ </button>
+ </div>
+ </div>
+ <div
+ className="source-footer-end"
+ >
+ <div
+ className="cursor-position"
+ title="(Line 6, column 11)"
+ >
+ (6, 11)
+ </div>
+ <PaneToggleButton
+ collapsed={false}
+ horizontal={false}
+ key="toggle"
+ position="end"
+ />
+ </div>
+</div>
+`;