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