summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/components
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/client/debugger/src/components
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/debugger/src/components')
-rw-r--r--devtools/client/debugger/src/components/App.css151
-rw-r--r--devtools/client/debugger/src/components/App.js396
-rw-r--r--devtools/client/debugger/src/components/Editor/BlackboxLines.js139
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoint.js157
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoints.css153
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoints.js78
-rw-r--r--devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js128
-rw-r--r--devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js94
-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.js137
-rw-r--r--devtools/client/debugger/src/components/Editor/Editor.css216
-rw-r--r--devtools/client/debugger/src/components/Editor/EmptyLines.js91
-rw-r--r--devtools/client/debugger/src/components/Editor/Exception.js102
-rw-r--r--devtools/client/debugger/src/components/Editor/Exceptions.js66
-rw-r--r--devtools/client/debugger/src/components/Editor/Footer.css85
-rw-r--r--devtools/client/debugger/src/components/Editor/Footer.js288
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightLine.js194
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightLines.js74
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreview.css29
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreview.js74
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreviewRow.js103
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreviews.js80
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview.css111
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js142
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/Popup.css174
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/Popup.js277
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/index.js128
-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.js99
-rw-r--r--devtools/client/debugger/src/components/Editor/SearchInFileBar.css32
-rw-r--r--devtools/client/debugger/src/components/Editor/SearchInFileBar.js368
-rw-r--r--devtools/client/debugger/src/components/Editor/Tab.js148
-rw-r--r--devtools/client/debugger/src/components/Editor/Tabs.css127
-rw-r--r--devtools/client/debugger/src/components/Editor/Tabs.js320
-rw-r--r--devtools/client/debugger/src/components/Editor/index.js795
-rw-r--r--devtools/client/debugger/src/components/Editor/moz.build31
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js53
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js77
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js88
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/Footer.spec.js70
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap32
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap747
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap93
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Outline.css158
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Outline.js388
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css23
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js68
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css227
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js480
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Sources.css244
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js352
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js249
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/index.js133
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/moz.build15
-rw-r--r--devtools/client/debugger/src/components/QuickOpenModal.css28
-rw-r--r--devtools/client/debugger/src/components/QuickOpenModal.js508
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js235
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js84
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css235
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js40
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js172
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build13
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js22
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap18
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css33
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js502
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css76
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js189
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css155
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js339
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Expressions.css171
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Expressions.js486
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js232
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js18
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css185
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css38
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js191
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js220
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build13
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js110
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js270
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js93
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap1172
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap1001
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap2286
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Scopes.css100
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Scopes.js413
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css98
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Thread.js81
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Threads.css64
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Threads.js40
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css53
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js218
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css108
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js383
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/index.js548
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/moz.build22
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js78
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js136
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js77
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js341
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap408
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap203
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap619
-rw-r--r--devtools/client/debugger/src/components/ShortcutsModal.css47
-rw-r--r--devtools/client/debugger/src/components/ShortcutsModal.js155
-rw-r--r--devtools/client/debugger/src/components/WelcomeBox.css83
-rw-r--r--devtools/client/debugger/src/components/WelcomeBox.js137
-rw-r--r--devtools/client/debugger/src/components/moz.build18
-rw-r--r--devtools/client/debugger/src/components/shared/AccessibleImage.css201
-rw-r--r--devtools/client/debugger/src/components/shared/AccessibleImage.js21
-rw-r--r--devtools/client/debugger/src/components/shared/Accordion.css107
-rw-r--r--devtools/client/debugger/src/components/shared/Accordion.js89
-rw-r--r--devtools/client/debugger/src/components/shared/Badge.css16
-rw-r--r--devtools/client/debugger/src/components/shared/Badge.js30
-rw-r--r--devtools/client/debugger/src/components/shared/BracketArrow.css64
-rw-r--r--devtools/client/debugger/src/components/shared/BracketArrow.js28
-rw-r--r--devtools/client/debugger/src/components/shared/Button/CloseButton.js30
-rw-r--r--devtools/client/debugger/src/components/shared/Button/CommandBarButton.js55
-rw-r--r--devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js58
-rw-r--r--devtools/client/debugger/src/components/shared/Button/index.js9
-rw-r--r--devtools/client/debugger/src/components/shared/Button/moz.build15
-rw-r--r--devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css35
-rw-r--r--devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css73
-rw-r--r--devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css29
-rw-r--r--devtools/client/debugger/src/components/shared/Button/styles/moz.build8
-rw-r--r--devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js31
-rw-r--r--devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js44
-rw-r--r--devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js51
-rw-r--r--devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap13
-rw-r--r--devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap18
-rw-r--r--devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap13
-rw-r--r--devtools/client/debugger/src/components/shared/Dropdown.css97
-rw-r--r--devtools/client/debugger/src/components/shared/Dropdown.js74
-rw-r--r--devtools/client/debugger/src/components/shared/Modal.css47
-rw-r--r--devtools/client/debugger/src/components/shared/Modal.js45
-rw-r--r--devtools/client/debugger/src/components/shared/Popover.css32
-rw-r--r--devtools/client/debugger/src/components/shared/Popover.js324
-rw-r--r--devtools/client/debugger/src/components/shared/PreviewFunction.css23
-rw-r--r--devtools/client/debugger/src/components/shared/PreviewFunction.js108
-rw-r--r--devtools/client/debugger/src/components/shared/ResultList.css131
-rw-r--r--devtools/client/debugger/src/components/shared/ResultList.js102
-rw-r--r--devtools/client/debugger/src/components/shared/SearchInput.css223
-rw-r--r--devtools/client/debugger/src/components/shared/SearchInput.js362
-rw-r--r--devtools/client/debugger/src/components/shared/SmartGap.js170
-rw-r--r--devtools/client/debugger/src/components/shared/SourceIcon.css176
-rw-r--r--devtools/client/debugger/src/components/shared/SourceIcon.js71
-rw-r--r--devtools/client/debugger/src/components/shared/menu.css55
-rw-r--r--devtools/client/debugger/src/components/shared/moz.build23
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Accordion.spec.js47
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Badge.spec.js19
-rw-r--r--devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js24
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js21
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Modal.spec.js56
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Popover.spec.js212
-rw-r--r--devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js130
-rw-r--r--devtools/client/debugger/src/components/shared/tests/ResultList.spec.js48
-rw-r--r--devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js126
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap81
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap9
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap27
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap34
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap13
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap549
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap23
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap55
-rw-r--r--devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap267
-rw-r--r--devtools/client/debugger/src/components/test/QuickOpenModal.spec.js803
-rw-r--r--devtools/client/debugger/src/components/test/WelcomeBox.spec.js58
-rw-r--r--devtools/client/debugger/src/components/test/WhyPaused.spec.js61
-rw-r--r--devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap1777
-rw-r--r--devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap67
-rw-r--r--devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap103
-rw-r--r--devtools/client/debugger/src/components/variables.css45
175 files changed, 30736 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/App.css b/devtools/client/debugger/src/components/App.css
new file mode 100644
index 0000000000..796bf84574
--- /dev/null
+++ b/devtools/client/debugger/src/components/App.css
@@ -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/>. */
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+#mount {
+ height: 100%;
+}
+
+button {
+ background: transparent;
+ border: none;
+ font-family: inherit;
+ font-size: inherit;
+}
+
+button:hover {
+ background-color: var(--theme-toolbar-background-hover);
+}
+
+.theme-dark button:hover {
+ background-color: var(--theme-toolbar-hover);
+}
+
+.debugger {
+ display: flex;
+ flex: 1;
+ height: 100%;
+}
+
+.debugger .tree-indent {
+ width: 16px;
+ margin-inline-start: 0;
+ border-inline-start: 0;
+}
+
+.editor-pane {
+ display: flex;
+ position: relative;
+ flex: 1;
+ background-color: var(--theme-body-background);
+ height: 100%;
+ overflow: hidden;
+}
+
+.editor-container {
+ width: 100%;
+ display: grid;
+ grid-template-areas:
+ "editor-header"
+ "editor "
+ "notification "
+ "editor-footer";
+ grid-template-rows:
+ var(--editor-header-height)
+ 1fr
+ auto
+ var(--editor-footer-height);
+ max-height: 100%;
+ overflow-y: auto;
+}
+
+.editor-notification-footer {
+ background: var(--theme-warning-background);
+ border-top: 1px solid var(--theme-warning-border);
+ color: var(--theme-warning-color);
+ padding: 0.5em;
+ gap: 8px;
+ grid-area: notification;
+ display: flex;
+}
+
+/* Utils */
+.absolute-center {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.d-flex {
+ display: flex;
+}
+
+.align-items-center {
+ align-items: center;
+}
+
+.rounded-circle {
+ border-radius: 50%;
+}
+
+.text-white {
+ color: white;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.min-width-0 {
+ min-width: 0;
+}
+
+/*
+ Prevents horizontal scrollbar from displaying when
+ right pane collapsed (#7505)
+*/
+.split-box > .splitter:last-child {
+ display: none;
+}
+
+/**
+ * In RTL layouts, the Debugger UI overlays the splitters. See Bug 1731233.
+ * Note: we need to the `.debugger` prefix here to beat the specificity of the
+ * general rule defined in SlitBox.css for `.split-box.vert > .splitter`.
+ */
+.debugger .split-box.vert > .splitter {
+ border-left-width: var(--devtools-splitter-inline-start-width);
+ border-right-width: var(--devtools-splitter-inline-end-width);
+
+ margin-left: calc(-1 * var(--devtools-splitter-inline-start-width) - 1px);
+ margin-right: calc(-1 * var(--devtools-splitter-inline-end-width));
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ background: transparent;
+}
+
+::-webkit-scrollbar-track {
+ border-radius: 8px;
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ border-radius: 8px;
+ background: rgba(113, 113, 113, 0.5);
+}
diff --git a/devtools/client/debugger/src/components/App.js b/devtools/client/debugger/src/components/App.js
new file mode 100644
index 0000000000..40911e5167
--- /dev/null
+++ b/devtools/client/debugger/src/components/App.js
@@ -0,0 +1,396 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ main,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { prefs } from "../utils/prefs";
+import { primaryPaneTabs } from "../constants";
+import actions from "../actions/index";
+import AccessibleImage from "./shared/AccessibleImage";
+
+import {
+ getSelectedLocation,
+ getPaneCollapse,
+ getActiveSearch,
+ getQuickOpenEnabled,
+ getOrientation,
+ getIsCurrentThreadPaused,
+ isMapScopesEnabled,
+ getSourceMapErrorForSourceActor,
+} from "../selectors/index";
+const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
+
+const SplitBox = require("resource://devtools/client/shared/components/splitter/SplitBox.js");
+const AppErrorBoundary = require("resource://devtools/client/shared/components/AppErrorBoundary.js");
+
+const shortcuts = new KeyShortcuts({ window });
+
+const horizontalLayoutBreakpoint = window.matchMedia("(min-width: 800px)");
+const verticalLayoutBreakpoint = window.matchMedia(
+ "(min-width: 10px) and (max-width: 799px)"
+);
+
+import { ShortcutsModal } from "./ShortcutsModal";
+import PrimaryPanes from "./PrimaryPanes/index";
+import Editor from "./Editor/index";
+import SecondaryPanes from "./SecondaryPanes/index";
+import WelcomeBox from "./WelcomeBox";
+import EditorTabs from "./Editor/Tabs";
+import EditorFooter from "./Editor/Footer";
+import QuickOpenModal from "./QuickOpenModal";
+
+class App extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ shortcutsModalEnabled: false,
+ startPanelSize: 0,
+ endPanelSize: 0,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ activeSearch: PropTypes.oneOf(["file", "project"]),
+ closeActiveSearch: PropTypes.func.isRequired,
+ closeQuickOpen: PropTypes.func.isRequired,
+ endPanelCollapsed: PropTypes.bool.isRequired,
+ fluentBundles: PropTypes.array.isRequired,
+ openQuickOpen: PropTypes.func.isRequired,
+ orientation: PropTypes.oneOf(["horizontal", "vertical"]).isRequired,
+ quickOpenEnabled: PropTypes.bool.isRequired,
+ selectedLocation: PropTypes.object,
+ setActiveSearch: PropTypes.func.isRequired,
+ setOrientation: PropTypes.func.isRequired,
+ setPrimaryPaneTab: PropTypes.func.isRequired,
+ startPanelCollapsed: PropTypes.bool.isRequired,
+ toolboxDoc: PropTypes.object.isRequired,
+ showOriginalVariableMappingWarning: PropTypes.bool,
+ };
+ }
+
+ getChildContext() {
+ return {
+ fluentBundles: this.props.fluentBundles,
+ toolboxDoc: this.props.toolboxDoc,
+ shortcuts,
+ l10n: L10N,
+ };
+ }
+
+ componentDidMount() {
+ horizontalLayoutBreakpoint.addListener(this.onLayoutChange);
+ verticalLayoutBreakpoint.addListener(this.onLayoutChange);
+ this.setOrientation();
+
+ shortcuts.on(L10N.getStr("symbolSearch.search.key2"), e =>
+ this.toggleQuickOpenModal(e, "@")
+ );
+
+ [
+ L10N.getStr("sources.search.key2"),
+ L10N.getStr("sources.search.alt.key"),
+ ].forEach(key => shortcuts.on(key, this.toggleQuickOpenModal));
+
+ shortcuts.on(L10N.getStr("gotoLineModal.key3"), e =>
+ this.toggleQuickOpenModal(e, ":")
+ );
+
+ shortcuts.on(
+ L10N.getStr("projectTextSearch.key"),
+ this.jumpToProjectSearch
+ );
+
+ shortcuts.on("Escape", this.onEscape);
+ shortcuts.on("CmdOrCtrl+/", this.onCommandSlash);
+ }
+
+ componentWillUnmount() {
+ horizontalLayoutBreakpoint.removeListener(this.onLayoutChange);
+ verticalLayoutBreakpoint.removeListener(this.onLayoutChange);
+ shortcuts.off(
+ L10N.getStr("symbolSearch.search.key2"),
+ this.toggleQuickOpenModal
+ );
+
+ [
+ L10N.getStr("sources.search.key2"),
+ L10N.getStr("sources.search.alt.key"),
+ ].forEach(key => shortcuts.off(key, this.toggleQuickOpenModal));
+
+ shortcuts.off(L10N.getStr("gotoLineModal.key3"), this.toggleQuickOpenModal);
+
+ shortcuts.off(
+ L10N.getStr("projectTextSearch.key"),
+ this.jumpToProjectSearch
+ );
+
+ shortcuts.off("Escape", this.onEscape);
+ shortcuts.off("CmdOrCtrl+/", this.onCommandSlash);
+ }
+
+ jumpToProjectSearch = e => {
+ e.preventDefault();
+ this.props.setPrimaryPaneTab(primaryPaneTabs.PROJECT_SEARCH);
+ this.props.setActiveSearch(primaryPaneTabs.PROJECT_SEARCH);
+ };
+
+ onEscape = e => {
+ const {
+ activeSearch,
+ closeActiveSearch,
+ closeQuickOpen,
+ quickOpenEnabled,
+ } = this.props;
+ const { shortcutsModalEnabled } = this.state;
+
+ if (activeSearch) {
+ e.preventDefault();
+ closeActiveSearch();
+ }
+
+ if (quickOpenEnabled) {
+ e.preventDefault();
+ closeQuickOpen();
+ }
+
+ if (shortcutsModalEnabled) {
+ e.preventDefault();
+ this.toggleShortcutsModal();
+ }
+ };
+
+ onCommandSlash = () => {
+ this.toggleShortcutsModal();
+ };
+
+ isHorizontal() {
+ return this.props.orientation === "horizontal";
+ }
+
+ toggleQuickOpenModal = (e, query) => {
+ const { quickOpenEnabled, openQuickOpen, closeQuickOpen } = this.props;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (quickOpenEnabled === true) {
+ closeQuickOpen();
+ return;
+ }
+
+ if (query != null) {
+ openQuickOpen(query);
+ return;
+ }
+ openQuickOpen();
+ };
+
+ onLayoutChange = () => {
+ this.setOrientation();
+ };
+
+ setOrientation() {
+ // If the orientation does not match (if it is not visible) it will
+ // not setOrientation, or if it is the same as before, calling
+ // setOrientation will not cause a rerender.
+ if (horizontalLayoutBreakpoint.matches) {
+ this.props.setOrientation("horizontal");
+ } else if (verticalLayoutBreakpoint.matches) {
+ this.props.setOrientation("vertical");
+ }
+ }
+
+ renderEditorNotificationBar() {
+ if (this.props.sourceMapError) {
+ return div(
+ { className: "editor-notification-footer", "aria-role": "status" },
+ span(
+ { className: "info icon" },
+ React.createElement(AccessibleImage, { className: "sourcemap" })
+ ),
+ `Source Map Error: ${this.props.sourceMapError}`
+ );
+ }
+ if (this.props.showOriginalVariableMappingWarning) {
+ return div(
+ { className: "editor-notification-footer", "aria-role": "status" },
+ span(
+ { className: "info icon" },
+ React.createElement(AccessibleImage, { className: "sourcemap" })
+ ),
+ L10N.getFormatStr(
+ "editorNotificationFooter.noOriginalScopes",
+ L10N.getStr("scopes.showOriginalScopes")
+ )
+ );
+ }
+ return null;
+ }
+
+ renderEditorPane = () => {
+ const { startPanelCollapsed, endPanelCollapsed } = this.props;
+ const { endPanelSize, startPanelSize } = this.state;
+ const horizontal = this.isHorizontal();
+ return main(
+ {
+ className: "editor-pane",
+ },
+ div(
+ {
+ className: "editor-container",
+ },
+ React.createElement(EditorTabs, {
+ startPanelCollapsed: startPanelCollapsed,
+ endPanelCollapsed: endPanelCollapsed,
+ horizontal: horizontal,
+ }),
+ React.createElement(Editor, {
+ startPanelSize: startPanelSize,
+ endPanelSize: endPanelSize,
+ }),
+ !this.props.selectedLocation
+ ? React.createElement(WelcomeBox, {
+ horizontal,
+ toggleShortcutsModal: () => this.toggleShortcutsModal(),
+ })
+ : null,
+ this.renderEditorNotificationBar(),
+ React.createElement(EditorFooter, {
+ horizontal,
+ })
+ )
+ );
+ };
+
+ toggleShortcutsModal() {
+ this.setState(prevState => ({
+ shortcutsModalEnabled: !prevState.shortcutsModalEnabled,
+ }));
+ }
+
+ // Important so that the tabs chevron updates appropriately when
+ // the user resizes the left or right columns
+ triggerEditorPaneResize() {
+ const editorPane = window.document.querySelector(".editor-pane");
+ if (editorPane) {
+ editorPane.dispatchEvent(new Event("resizeend"));
+ }
+ }
+
+ renderLayout = () => {
+ const { startPanelCollapsed, endPanelCollapsed } = this.props;
+ const horizontal = this.isHorizontal();
+ return React.createElement(SplitBox, {
+ style: {
+ width: "100vw",
+ },
+ initialSize: prefs.endPanelSize,
+ minSize: 30,
+ maxSize: "70%",
+ splitterSize: 1,
+ vert: horizontal,
+ onResizeEnd: num => {
+ prefs.endPanelSize = num;
+ this.triggerEditorPaneResize();
+ },
+ startPanel: React.createElement(SplitBox, {
+ style: {
+ width: "100vw",
+ },
+ initialSize: prefs.startPanelSize,
+ minSize: 30,
+ maxSize: "85%",
+ splitterSize: 1,
+ onResizeEnd: num => {
+ prefs.startPanelSize = num;
+ this.triggerEditorPaneResize();
+ },
+ startPanelCollapsed: startPanelCollapsed,
+ startPanel: React.createElement(PrimaryPanes, {
+ horizontal,
+ }),
+ endPanel: this.renderEditorPane(),
+ }),
+ endPanelControl: true,
+ endPanel: React.createElement(SecondaryPanes, {
+ horizontal,
+ }),
+ endPanelCollapsed: endPanelCollapsed,
+ });
+ };
+
+ render() {
+ const { quickOpenEnabled } = this.props;
+ return div(
+ {
+ className: "debugger",
+ },
+ React.createElement(
+ AppErrorBoundary,
+ {
+ componentName: "Debugger",
+ panel: L10N.getStr("ToolboxDebugger.label"),
+ },
+ this.renderLayout(),
+ quickOpenEnabled === true &&
+ React.createElement(QuickOpenModal, {
+ shortcutsModalEnabled: this.state.shortcutsModalEnabled,
+ toggleShortcutsModal: () => this.toggleShortcutsModal(),
+ }),
+ React.createElement(ShortcutsModal, {
+ enabled: this.state.shortcutsModalEnabled,
+ handleClose: () => this.toggleShortcutsModal(),
+ })
+ )
+ );
+ }
+}
+
+App.childContextTypes = {
+ toolboxDoc: PropTypes.object,
+ shortcuts: PropTypes.object,
+ l10n: PropTypes.object,
+ fluentBundles: PropTypes.array,
+};
+
+const mapStateToProps = state => {
+ const selectedLocation = getSelectedLocation(state);
+ const mapScopeEnabled = isMapScopesEnabled(state);
+ const isPaused = getIsCurrentThreadPaused(state);
+
+ const showOriginalVariableMappingWarning =
+ isPaused &&
+ selectedLocation?.source.isOriginal &&
+ !selectedLocation?.source.isPrettyPrinted &&
+ !mapScopeEnabled;
+
+ return {
+ showOriginalVariableMappingWarning,
+ selectedLocation,
+ startPanelCollapsed: getPaneCollapse(state, "start"),
+ endPanelCollapsed: getPaneCollapse(state, "end"),
+ activeSearch: getActiveSearch(state),
+ quickOpenEnabled: getQuickOpenEnabled(state),
+ orientation: getOrientation(state),
+ sourceMapError: selectedLocation?.sourceActor
+ ? getSourceMapErrorForSourceActor(state, selectedLocation.sourceActor.id)
+ : null,
+ };
+};
+
+export default connect(mapStateToProps, {
+ setActiveSearch: actions.setActiveSearch,
+ closeActiveSearch: actions.closeActiveSearch,
+ openQuickOpen: actions.openQuickOpen,
+ closeQuickOpen: actions.closeQuickOpen,
+ setOrientation: actions.setOrientation,
+ setPrimaryPaneTab: actions.setPrimaryPaneTab,
+})(App);
diff --git a/devtools/client/debugger/src/components/Editor/BlackboxLines.js b/devtools/client/debugger/src/components/Editor/BlackboxLines.js
new file mode 100644
index 0000000000..1281bc635a
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/BlackboxLines.js
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { Component } from "devtools/client/shared/vendor/react";
+import { toEditorLine, fromEditorLine } from "../../utils/editor/index";
+import { isLineBlackboxed } from "../../utils/source";
+import { isWasm } from "../../utils/wasm";
+
+// This renders blackbox line highlighting in the editor
+class BlackboxLines extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ blackboxedRangesForSelectedSource: PropTypes.array,
+ isSourceOnIgnoreList: PropTypes.bool,
+ };
+ }
+
+ componentDidMount() {
+ const { selectedSource, blackboxedRangesForSelectedSource, editor } =
+ this.props;
+
+ if (this.props.isSourceOnIgnoreList) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ // When `blackboxedRangesForSelectedSource` is defined and the array is empty,
+ // the whole source was blackboxed.
+ if (!blackboxedRangesForSelectedSource.length) {
+ this.setAllBlackboxLines(editor);
+ } else {
+ editor.codeMirror.operation(() => {
+ blackboxedRangesForSelectedSource.forEach(range => {
+ const start = toEditorLine(selectedSource.id, range.start.line);
+ // CodeMirror.eachLine doesn't include `end` line offset, so bump by one
+ const end = toEditorLine(selectedSource.id, range.end.line) + 1;
+ editor.codeMirror.eachLine(start, end, lineHandle => {
+ this.setBlackboxLine(editor, lineHandle);
+ });
+ });
+ });
+ }
+ }
+
+ componentDidUpdate() {
+ const {
+ selectedSource,
+ blackboxedRangesForSelectedSource,
+ editor,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ if (this.props.isSourceOnIgnoreList) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ // when unblackboxed
+ if (!blackboxedRangesForSelectedSource) {
+ this.clearAllBlackboxLines(editor);
+ return;
+ }
+
+ // When the whole source is blackboxed
+ if (!blackboxedRangesForSelectedSource.length) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ const sourceIsWasm = isWasm(selectedSource.id);
+
+ // TODO: Possible perf improvement. Instead of going
+ // over all the lines each time get diffs of what has
+ // changed and update those.
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ const line = fromEditorLine(
+ selectedSource.id,
+ editor.codeMirror.getLineNumber(lineHandle),
+ sourceIsWasm
+ );
+
+ if (
+ isLineBlackboxed(
+ blackboxedRangesForSelectedSource,
+ line,
+ isSourceOnIgnoreList
+ )
+ ) {
+ this.setBlackboxLine(editor, lineHandle);
+ } else {
+ this.clearBlackboxLine(editor, lineHandle);
+ }
+ });
+ });
+ }
+
+ componentWillUnmount() {
+ // Lets make sure we remove everything relating to
+ // blackboxing lines when this component is unmounted.
+ this.clearAllBlackboxLines(this.props.editor);
+ }
+
+ clearAllBlackboxLines(editor) {
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ this.clearBlackboxLine(editor, lineHandle);
+ });
+ });
+ }
+
+ setAllBlackboxLines(editor) {
+ //TODO:We might be able to handle the whole source
+ // than adding the blackboxing line by line
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ this.setBlackboxLine(editor, lineHandle);
+ });
+ });
+ }
+
+ clearBlackboxLine(editor, lineHandle) {
+ editor.codeMirror.removeLineClass(lineHandle, "wrap", "blackboxed-line");
+ }
+
+ setBlackboxLine(editor, lineHandle) {
+ editor.codeMirror.addLineClass(lineHandle, "wrap", "blackboxed-line");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default BlackboxLines;
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoint.js b/devtools/client/debugger/src/components/Editor/Breakpoint.js
new file mode 100644
index 0000000000..4559a20289
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoint.js
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { getDocument, toEditorLine } from "../../utils/editor/index";
+import { getSelectedLocation } from "../../utils/selected-location";
+import { features } from "../../utils/prefs";
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+const breakpointSvg = document.createElement("div");
+breakpointSvg.innerHTML =
+ '<svg viewBox="0 0 60 15" width="60" height="15"><path d="M53.07.5H1.5c-.54 0-1 .46-1 1v12c0 .54.46 1 1 1h51.57c.58 0 1.15-.26 1.53-.7l4.7-6.3-4.7-6.3c-.38-.44-.95-.7-1.53-.7z"/></svg>';
+
+class Breakpoint extends PureComponent {
+ static get propTypes() {
+ return {
+ breakpoint: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object,
+ };
+ }
+
+ componentDidMount() {
+ this.addBreakpoint(this.props);
+ }
+
+ componentDidUpdate(prevProps) {
+ this.removeBreakpoint(prevProps);
+ this.addBreakpoint(this.props);
+ }
+
+ componentWillUnmount() {
+ this.removeBreakpoint(this.props);
+ }
+
+ makeMarker() {
+ const { breakpoint } = this.props;
+ const bp = breakpointSvg.cloneNode(true);
+
+ bp.className = classnames("editor new-breakpoint", {
+ "breakpoint-disabled": breakpoint.disabled,
+ "folding-enabled": features.codeFolding,
+ });
+ bp.onmousedown = this.onClick;
+ bp.oncontextmenu = this.onContextMenu;
+
+ return bp;
+ }
+
+ onClick = event => {
+ const {
+ continueToHere,
+ toggleBreakpointsAtLine,
+ removeBreakpointsAtLine,
+ breakpoint,
+ selectedSource,
+ } = this.props;
+
+ // ignore right clicks
+ if ((event.ctrlKey && event.button === 0) || event.button === 2) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+ if (event.metaKey) {
+ continueToHere(selectedLocation);
+ return;
+ }
+
+ if (event.shiftKey) {
+ toggleBreakpointsAtLine(!breakpoint.disabled, selectedLocation.line);
+ return;
+ }
+
+ removeBreakpointsAtLine(selectedLocation.source, selectedLocation.line);
+ };
+
+ onContextMenu = event => {
+ event.stopPropagation();
+ event.preventDefault();
+
+ this.props.showEditorEditBreakpointContextMenu(
+ event,
+ this.props.breakpoint
+ );
+ };
+
+ addBreakpoint(props) {
+ const { breakpoint, editor, selectedSource } = props;
+ // Hidden Breakpoints are never rendered on the client
+ if (breakpoint.options.hidden) {
+ return;
+ }
+
+ if (!selectedSource) {
+ return;
+ }
+
+ const doc = getDocument(selectedSource.id);
+ if (!doc) {
+ return;
+ }
+
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+ const line = toEditorLine(selectedSource.id, selectedLocation.line);
+
+ doc.setGutterMarker(line, "breakpoints", this.makeMarker());
+
+ editor.codeMirror.addLineClass(line, "wrap", "new-breakpoint");
+ editor.codeMirror.removeLineClass(line, "wrap", "breakpoint-disabled");
+ editor.codeMirror.removeLineClass(line, "wrap", "has-condition");
+ editor.codeMirror.removeLineClass(line, "wrap", "has-log");
+
+ if (breakpoint.disabled) {
+ editor.codeMirror.addLineClass(line, "wrap", "breakpoint-disabled");
+ }
+
+ if (breakpoint.options.logValue) {
+ editor.codeMirror.addLineClass(line, "wrap", "has-log");
+ } else if (breakpoint.options.condition) {
+ editor.codeMirror.addLineClass(line, "wrap", "has-condition");
+ }
+ }
+
+ removeBreakpoint(props) {
+ const { selectedSource, breakpoint } = props;
+ if (!selectedSource) {
+ return;
+ }
+
+ const doc = getDocument(selectedSource.id);
+ if (!doc) {
+ return;
+ }
+
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+ const line = toEditorLine(selectedSource.id, selectedLocation.line);
+
+ doc.setGutterMarker(line, "breakpoints", null);
+ doc.removeLineClass(line, "wrap", "new-breakpoint");
+ doc.removeLineClass(line, "wrap", "breakpoint-disabled");
+ doc.removeLineClass(line, "wrap", "has-condition");
+ doc.removeLineClass(line, "wrap", "has-log");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default Breakpoint;
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.css b/devtools/client/debugger/src/components/Editor/Breakpoints.css
new file mode 100644
index 0000000000..1269f73f82
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoints.css
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.theme-light {
+ --gutter-hover-background-color: #dde1e4;
+ --breakpoint-fill: var(--blue-50);
+ --breakpoint-stroke: var(--blue-60);
+}
+
+.theme-dark {
+ --gutter-hover-background-color: #414141;
+ --breakpoint-fill: var(--blue-55);
+ --breakpoint-stroke: var(--blue-40);
+}
+
+.theme-light,
+.theme-dark {
+ --logpoint-fill: var(--theme-graphs-purple);
+ --logpoint-stroke: var(--purple-60);
+ --breakpoint-condition-fill: var(--theme-graphs-yellow);
+ --breakpoint-condition-stroke: var(--theme-graphs-orange);
+ --breakpoint-skipped-opacity: 0.15;
+ --breakpoint-inactive-opacity: 0.3;
+ --breakpoint-disabled-opacity: 0.6;
+}
+
+/* Standard gutter breakpoints */
+.editor-wrapper .breakpoints {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.new-breakpoint .CodeMirror-linenumber {
+ pointer-events: none;
+}
+
+.editor-wrapper :not(.empty-line, .new-breakpoint)
+ > .CodeMirror-gutter-wrapper
+ > .CodeMirror-linenumber:hover::after {
+ content: "";
+ position: absolute;
+ /* paint below the number */
+ z-index: -1;
+ top: 0;
+ left: 0;
+ right: -4px;
+ bottom: 0;
+ height: 15px;
+ background-color: var(--gutter-hover-background-color);
+ mask: url(chrome://devtools/content/debugger/images/breakpoint.svg)
+ no-repeat;
+ mask-size: auto 15px;
+ mask-position: right;
+}
+
+.editor.new-breakpoint svg {
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ width: 60px;
+ height: 15px;
+ position: absolute;
+ top: 0px;
+ right: -4px;
+}
+
+.editor .breakpoint {
+ position: absolute;
+ right: -2px;
+}
+
+.editor.new-breakpoint.folding-enabled svg {
+ right: -16px;
+}
+
+.new-breakpoint.has-condition .CodeMirror-gutter-wrapper svg {
+ fill: var(--breakpoint-condition-fill);
+ stroke: var(--breakpoint-condition-stroke);
+}
+
+.new-breakpoint.has-log .CodeMirror-gutter-wrapper svg {
+ fill: var(--logpoint-fill);
+ stroke: var(--logpoint-stroke);
+}
+
+.editor.new-breakpoint.breakpoint-disabled svg,
+.blackboxed-line .editor.new-breakpoint svg {
+ fill-opacity: var(--breakpoint-disabled-opacity);
+ stroke-opacity: var(--breakpoint-disabled-opacity);
+}
+
+.editor-wrapper.skip-pausing .editor.new-breakpoint svg {
+ fill-opacity: var(--breakpoint-skipped-opacity);
+}
+
+/* Columnn breakpoints */
+.column-breakpoint {
+ display: inline;
+ padding-inline-start: 1px;
+ padding-inline-end: 1px;
+}
+
+.column-breakpoint:hover {
+ background-color: transparent;
+}
+
+.column-breakpoint svg {
+ display: inline-block;
+ cursor: pointer;
+ height: 13px;
+ width: 11px;
+ vertical-align: top;
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ fill-opacity: var(--breakpoint-inactive-opacity);
+ stroke-opacity: var(--breakpoint-inactive-opacity);
+}
+
+.column-breakpoint.active svg {
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ fill-opacity: 1;
+ stroke-opacity: 1;
+}
+
+.column-breakpoint.disabled svg {
+ fill-opacity: var(--breakpoint-disabled-opacity);
+ stroke-opacity: var(--breakpoint-disabled-opacity);
+}
+
+.column-breakpoint.has-log.disabled svg {
+ fill-opacity: 0.5;
+ stroke-opacity: 0.5;
+}
+
+.column-breakpoint.has-condition svg {
+ fill: var(--breakpoint-condition-fill);
+ stroke: var(--breakpoint-condition-stroke);
+}
+
+.column-breakpoint.has-log svg {
+ fill: var(--logpoint-fill);
+ stroke: var(--logpoint-stroke);
+}
+
+.editor-wrapper.skip-pausing .column-breakpoint svg {
+ fill-opacity: var(--breakpoint-skipped-opacity);
+}
+
+.img.column-marker {
+ background-image: url(chrome://devtools/content/debugger/images/column-marker.svg);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.js b/devtools/client/debugger/src/components/Editor/Breakpoints.js
new file mode 100644
index 0000000000..6d1d088f11
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoints.js
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import Breakpoint from "./Breakpoint";
+
+import {
+ getSelectedSource,
+ getFirstVisibleBreakpoints,
+} from "../../selectors/index";
+import { makeBreakpointId } from "../../utils/breakpoint/index";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../actions/index";
+
+class Breakpoints extends Component {
+ static get propTypes() {
+ return {
+ breakpoints: PropTypes.array,
+ editor: PropTypes.object,
+ selectedSource: PropTypes.object,
+ removeBreakpointsAtLine: PropTypes.func,
+ toggleBreakpointsAtLine: PropTypes.func,
+ continueToHere: PropTypes.func,
+ showEditorEditBreakpointContextMenu: PropTypes.func,
+ };
+ }
+ render() {
+ const {
+ breakpoints,
+ selectedSource,
+ editor,
+ showEditorEditBreakpointContextMenu,
+ continueToHere,
+ toggleBreakpointsAtLine,
+ removeBreakpointsAtLine,
+ } = this.props;
+
+ if (!selectedSource || !breakpoints) {
+ return null;
+ }
+ return div(
+ null,
+ breakpoints.map(breakpoint => {
+ return React.createElement(Breakpoint, {
+ key: makeBreakpointId(breakpoint.location),
+ breakpoint,
+ selectedSource,
+ showEditorEditBreakpointContextMenu,
+ continueToHere,
+ toggleBreakpointsAtLine,
+ removeBreakpointsAtLine,
+ editor,
+ });
+ })
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ return {
+ // Retrieves only the first breakpoint per line so that the
+ // breakpoint marker represents only the first breakpoint
+ breakpoints: getFirstVisibleBreakpoints(state),
+ selectedSource,
+ };
+};
+
+export default connect(mapStateToProps, {
+ showEditorEditBreakpointContextMenu:
+ actions.showEditorEditBreakpointContextMenu,
+ continueToHere: actions.continueToHere,
+ toggleBreakpointsAtLine: actions.toggleBreakpointsAtLine,
+ removeBreakpointsAtLine: actions.removeBreakpointsAtLine,
+})(Breakpoints);
diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js
new file mode 100644
index 0000000000..867f3af4b7
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { getDocument } from "../../utils/editor/index";
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+// eslint-disable-next-line max-len
+
+const breakpointButton = document.createElement("button");
+breakpointButton.innerHTML =
+ '<svg viewBox="0 0 11 13" width="11" height="13"><path d="M5.07.5H1.5c-.54 0-1 .46-1 1v10c0 .54.46 1 1 1h3.57c.58 0 1.15-.26 1.53-.7l3.7-5.3-3.7-5.3C6.22.76 5.65.5 5.07.5z"/></svg>';
+
+function makeBookmark({ breakpoint }, { onClick, onContextMenu }) {
+ const bp = breakpointButton.cloneNode(true);
+
+ const isActive = breakpoint && !breakpoint.disabled;
+ const isDisabled = breakpoint?.disabled;
+ const condition = breakpoint?.options.condition;
+ const logValue = breakpoint?.options.logValue;
+
+ bp.className = classnames("column-breakpoint", {
+ "has-condition": condition,
+ "has-log": logValue,
+ active: isActive,
+ disabled: isDisabled,
+ });
+
+ bp.setAttribute("title", logValue || condition || "");
+ bp.onclick = onClick;
+ bp.oncontextmenu = onContextMenu;
+
+ return bp;
+}
+
+export default class ColumnBreakpoint extends PureComponent {
+ bookmark;
+
+ static get propTypes() {
+ return {
+ columnBreakpoint: PropTypes.object.isRequired,
+ source: PropTypes.object.isRequired,
+ };
+ }
+
+ addColumnBreakpoint = nextProps => {
+ const { columnBreakpoint, source } = nextProps || this.props;
+
+ const sourceId = source.id;
+ const doc = getDocument(sourceId);
+ if (!doc) {
+ return;
+ }
+
+ const { line, column } = columnBreakpoint.location;
+ const widget = makeBookmark(columnBreakpoint, {
+ onClick: this.onClick,
+ onContextMenu: this.onContextMenu,
+ });
+
+ this.bookmark = doc.setBookmark({ line: line - 1, ch: column }, { widget });
+ };
+
+ clearColumnBreakpoint = () => {
+ if (this.bookmark) {
+ this.bookmark.clear();
+ this.bookmark = null;
+ }
+ };
+
+ onClick = event => {
+ event.stopPropagation();
+ event.preventDefault();
+ const {
+ columnBreakpoint,
+ toggleDisabledBreakpoint,
+ removeBreakpoint,
+ addBreakpoint,
+ } = this.props;
+
+ // disable column breakpoint on shift-click.
+ if (event.shiftKey) {
+ toggleDisabledBreakpoint(columnBreakpoint.breakpoint);
+ return;
+ }
+
+ if (columnBreakpoint.breakpoint) {
+ removeBreakpoint(columnBreakpoint.breakpoint);
+ } else {
+ addBreakpoint(columnBreakpoint.location);
+ }
+ };
+
+ onContextMenu = event => {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const {
+ columnBreakpoint: { breakpoint, location },
+ } = this.props;
+
+ if (breakpoint) {
+ this.props.showEditorEditBreakpointContextMenu(event, breakpoint);
+ } else {
+ this.props.showEditorCreateBreakpointContextMenu(event, location);
+ }
+ };
+
+ componentDidMount() {
+ this.addColumnBreakpoint();
+ }
+
+ componentWillUnmount() {
+ this.clearColumnBreakpoint();
+ }
+
+ componentDidUpdate() {
+ this.clearColumnBreakpoint();
+ this.addColumnBreakpoint();
+ }
+
+ render() {
+ return null;
+ }
+}
diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js
new file mode 100644
index 0000000000..33ccfad325
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js
@@ -0,0 +1,94 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import ColumnBreakpoint from "./ColumnBreakpoint";
+
+import {
+ getSelectedSource,
+ visibleColumnBreakpoints,
+ isSourceBlackBoxed,
+} from "../../selectors/index";
+import actions from "../../actions/index";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { makeBreakpointId } from "../../utils/breakpoint/index";
+
+// eslint-disable-next-line max-len
+
+class ColumnBreakpoints extends Component {
+ static get propTypes() {
+ return {
+ columnBreakpoints: PropTypes.array.isRequired,
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object,
+ addBreakpoint: PropTypes.func,
+ removeBreakpoint: PropTypes.func,
+ toggleDisabledBreakpoint: PropTypes.func,
+ showEditorCreateBreakpointContextMenu: PropTypes.func,
+ showEditorEditBreakpointContextMenu: PropTypes.func,
+ };
+ }
+
+ render() {
+ const {
+ editor,
+ columnBreakpoints,
+ selectedSource,
+ showEditorCreateBreakpointContextMenu,
+ showEditorEditBreakpointContextMenu,
+ toggleDisabledBreakpoint,
+ removeBreakpoint,
+ addBreakpoint,
+ } = this.props;
+
+ if (!selectedSource || columnBreakpoints.length === 0) {
+ return null;
+ }
+
+ let breakpoints;
+ editor.codeMirror.operation(() => {
+ breakpoints = columnBreakpoints.map(columnBreakpoint =>
+ React.createElement(ColumnBreakpoint, {
+ key: makeBreakpointId(columnBreakpoint.location),
+ columnBreakpoint,
+ editor,
+ source: selectedSource,
+ showEditorCreateBreakpointContextMenu,
+ showEditorEditBreakpointContextMenu,
+ toggleDisabledBreakpoint,
+ removeBreakpoint,
+ addBreakpoint,
+ })
+ );
+ });
+ return div(null, breakpoints);
+ }
+}
+
+const mapStateToProps = state => {
+ // Avoid rendering this component is there is no selected source,
+ // or if the selected source is blackboxed.
+ // Also avoid computing visible column breakpoint when this happens.
+ const selectedSource = getSelectedSource(state);
+ if (!selectedSource || isSourceBlackBoxed(state, selectedSource)) {
+ return {};
+ }
+ return {
+ selectedSource,
+ columnBreakpoints: visibleColumnBreakpoints(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ showEditorCreateBreakpointContextMenu:
+ actions.showEditorCreateBreakpointContextMenu,
+ showEditorEditBreakpointContextMenu:
+ actions.showEditorEditBreakpointContextMenu,
+ toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint,
+ removeBreakpoint: actions.removeBreakpoint,
+ addBreakpoint: actions.addBreakpoint,
+})(ColumnBreakpoints);
diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.css b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css
new file mode 100644
index 0000000000..4ce8dbcd8c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.conditional-breakpoint-panel {
+ cursor: initial;
+ margin: 1em 0;
+ position: relative;
+ display: flex;
+ align-items: center;
+ background: var(--theme-toolbar-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.conditional-breakpoint-panel .prompt {
+ font-size: 1.8em;
+ color: var(--theme-graphs-orange);
+ padding-left: 3px;
+ padding-right: 3px;
+ padding-bottom: 3px;
+ text-align: right;
+ width: 30px;
+ align-self: baseline;
+ margin-top: 3px;
+}
+
+.conditional-breakpoint-panel.log-point .prompt {
+ color: var(--purple-60);
+}
+
+.conditional-breakpoint-panel .CodeMirror {
+ margin: 6px 10px;
+}
+
+.conditional-breakpoint-panel .CodeMirror pre.CodeMirror-placeholder {
+ /* Match the color of the placeholder text to existing inputs in the Debugger */
+ color: var(--theme-text-color-alt);
+}
diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.js b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js
new file mode 100644
index 0000000000..8ff84c287a
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js
@@ -0,0 +1,280 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ textarea,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import ReactDOM from "devtools/client/shared/vendor/react-dom";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { toEditorLine } from "../../utils/editor/index";
+import { prefs } from "../../utils/prefs";
+import actions from "../../actions/index";
+
+import {
+ getClosestBreakpoint,
+ getConditionalPanelLocation,
+ getLogPointStatus,
+} from "../../selectors/index";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+function addNewLine(doc) {
+ const cursor = doc.getCursor();
+ const pos = { line: cursor.line, ch: cursor.ch };
+ doc.replaceRange("\n", pos);
+}
+
+export class ConditionalPanel extends PureComponent {
+ cbPanel;
+ input;
+ codeMirror;
+ panelNode;
+ scrollParent;
+
+ constructor() {
+ super();
+ this.cbPanel = null;
+ }
+
+ static get propTypes() {
+ return {
+ breakpoint: PropTypes.object,
+ closeConditionalPanel: PropTypes.func.isRequired,
+ editor: PropTypes.object.isRequired,
+ location: PropTypes.any.isRequired,
+ log: PropTypes.bool.isRequired,
+ openConditionalPanel: PropTypes.func.isRequired,
+ setBreakpointOptions: PropTypes.func.isRequired,
+ };
+ }
+
+ keepFocusOnInput() {
+ if (this.input) {
+ this.input.focus();
+ }
+ }
+
+ saveAndClose = () => {
+ if (this.input) {
+ this.setBreakpoint(this.input.value.trim());
+ }
+
+ this.props.closeConditionalPanel();
+ };
+
+ onKey = e => {
+ if (e.key === "Enter") {
+ if (this.codeMirror && e.altKey) {
+ addNewLine(this.codeMirror.doc);
+ } else {
+ this.saveAndClose();
+ }
+ } else if (e.key === "Escape") {
+ this.props.closeConditionalPanel();
+ }
+ };
+
+ setBreakpoint(value) {
+ const { log, breakpoint } = this.props;
+ // If breakpoint is `pending`, props will not contain a breakpoint.
+ // If source is a URL without location, breakpoint will contain no generatedLocation.
+ const location =
+ breakpoint && breakpoint.generatedLocation
+ ? breakpoint.generatedLocation
+ : this.props.location;
+ const options = breakpoint ? breakpoint.options : {};
+ const type = log ? "logValue" : "condition";
+ return this.props.setBreakpointOptions(location, {
+ ...options,
+ [type]: value,
+ });
+ }
+
+ clearConditionalPanel() {
+ if (this.cbPanel) {
+ this.cbPanel.clear();
+ this.cbPanel = null;
+ }
+ if (this.scrollParent) {
+ this.scrollParent.removeEventListener("scroll", this.repositionOnScroll);
+ }
+ }
+
+ repositionOnScroll = () => {
+ if (this.panelNode && this.scrollParent) {
+ const { scrollLeft } = this.scrollParent;
+ this.panelNode.style.transform = `translateX(${scrollLeft}px)`;
+ }
+ };
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillMount() {
+ return this.renderToWidget(this.props);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate() {
+ return this.clearConditionalPanel();
+ }
+
+ componentDidUpdate(prevProps) {
+ this.keepFocusOnInput();
+ }
+
+ componentWillUnmount() {
+ // This is called if CodeMirror is re-initializing itself before the
+ // user closes the conditional panel. Clear the widget, and re-render it
+ // as soon as this component gets remounted
+ return this.clearConditionalPanel();
+ }
+
+ renderToWidget(props) {
+ if (this.cbPanel) {
+ this.clearConditionalPanel();
+ }
+ const { location, editor } = props;
+
+ const editorLine = toEditorLine(location.source.id, location.line || 0);
+ this.cbPanel = editor.codeMirror.addLineWidget(
+ editorLine,
+ this.renderConditionalPanel(props),
+ {
+ coverGutter: true,
+ noHScroll: true,
+ }
+ );
+
+ if (this.input) {
+ let parent = this.input.parentNode;
+ while (parent) {
+ if (
+ parent instanceof HTMLElement &&
+ parent.classList.contains("CodeMirror-scroll")
+ ) {
+ this.scrollParent = parent;
+ break;
+ }
+ parent = parent.parentNode;
+ }
+
+ if (this.scrollParent) {
+ this.scrollParent.addEventListener("scroll", this.repositionOnScroll);
+ this.repositionOnScroll();
+ }
+ }
+ }
+
+ createEditor = input => {
+ const { log, editor, closeConditionalPanel } = this.props;
+ const codeMirror = editor.CodeMirror.fromTextArea(input, {
+ mode: "javascript",
+ theme: "mozilla",
+ placeholder: L10N.getStr(
+ log
+ ? "editor.conditionalPanel.logPoint.placeholder2"
+ : "editor.conditionalPanel.placeholder2"
+ ),
+ cursorBlinkRate: prefs.cursorBlinkRate,
+ });
+
+ codeMirror.on("keydown", (cm, e) => {
+ if (e.key === "Enter") {
+ e.codemirrorIgnore = true;
+ }
+ });
+
+ codeMirror.on("blur", (cm, e) => {
+ if (
+ e?.relatedTarget &&
+ e.relatedTarget.closest(".conditional-breakpoint-panel")
+ ) {
+ return;
+ }
+
+ closeConditionalPanel();
+ });
+
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirrorWrapper.addEventListener("keydown", e => {
+ codeMirror.save();
+ this.onKey(e);
+ });
+
+ this.input = input;
+ this.codeMirror = codeMirror;
+ codeMirror.focus();
+ codeMirror.setCursor(codeMirror.lineCount(), 0);
+ };
+
+ getDefaultValue() {
+ const { breakpoint, log } = this.props;
+ const options = breakpoint?.options || {};
+ return log ? options.logValue : options.condition;
+ }
+
+ renderConditionalPanel(props) {
+ const { log } = props;
+ const defaultValue = this.getDefaultValue();
+
+ const panel = document.createElement("div");
+ ReactDOM.render(
+ div(
+ {
+ className: classnames("conditional-breakpoint-panel", {
+ "log-point": log,
+ }),
+ onClick: () => this.keepFocusOnInput(),
+ ref: node => (this.panelNode = node),
+ },
+ div(
+ {
+ className: "prompt",
+ },
+ "»"
+ ),
+ textarea({
+ defaultValue,
+ ref: input => this.createEditor(input),
+ })
+ ),
+ panel
+ );
+ return panel;
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = state => {
+ const location = getConditionalPanelLocation(state);
+
+ if (!location) {
+ throw new Error("Conditional panel location needed.");
+ }
+
+ const breakpoint = getClosestBreakpoint(state, location);
+
+ return {
+ breakpoint,
+ location,
+ log: getLogPointStatus(state),
+ };
+};
+
+const { setBreakpointOptions, openConditionalPanel, closeConditionalPanel } =
+ actions;
+
+const mapDispatchToProps = {
+ setBreakpointOptions,
+ openConditionalPanel,
+ closeConditionalPanel,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ConditionalPanel);
diff --git a/devtools/client/debugger/src/components/Editor/DebugLine.js b/devtools/client/debugger/src/components/Editor/DebugLine.js
new file mode 100644
index 0000000000..1b8e59ba64
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/DebugLine.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import {
+ toEditorPosition,
+ getDocument,
+ hasDocument,
+ startOperation,
+ endOperation,
+ getTokenEnd,
+} from "../../utils/editor/index";
+import { isException } from "../../utils/pause/index";
+import { getIndentation } from "../../utils/indentation";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import {
+ getVisibleSelectedFrame,
+ getPauseReason,
+ getSourceTextContent,
+ getCurrentThread,
+} from "../../selectors/index";
+
+export class DebugLine extends PureComponent {
+ debugExpression;
+
+ static get propTypes() {
+ return {
+ location: PropTypes.object,
+ why: PropTypes.object,
+ };
+ }
+
+ componentDidMount() {
+ const { why, location } = this.props;
+ this.setDebugLine(why, location);
+ }
+
+ componentWillUnmount() {
+ const { why, location } = this.props;
+ this.clearDebugLine(why, location);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { why, location } = this.props;
+
+ startOperation();
+ this.clearDebugLine(prevProps.why, prevProps.location);
+ this.setDebugLine(why, location);
+ endOperation();
+ }
+
+ setDebugLine(why, location) {
+ if (!location) {
+ return;
+ }
+ const doc = getDocument(location.source.id);
+
+ let { line, column } = toEditorPosition(location);
+ let { markTextClass, lineClass } = this.getTextClasses(why);
+ doc.addLineClass(line, "wrap", lineClass);
+
+ const lineText = doc.getLine(line);
+ column = Math.max(column, getIndentation(lineText));
+
+ // If component updates because user clicks on
+ // another source tab, codeMirror will be null.
+ const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null;
+
+ if (columnEnd === null) {
+ markTextClass += " to-line-end";
+ }
+
+ this.debugExpression = doc.markText(
+ { ch: column, line },
+ { ch: columnEnd, line },
+ { className: markTextClass }
+ );
+ }
+
+ clearDebugLine(why, location) {
+ // Avoid clearing the line if we didn't set a debug line before,
+ // or, if the document is no longer available
+ if (!location || !hasDocument(location.source.id)) {
+ return;
+ }
+
+ if (this.debugExpression) {
+ this.debugExpression.clear();
+ }
+
+ const { line } = toEditorPosition(location);
+ const doc = getDocument(location.source.id);
+ const { lineClass } = this.getTextClasses(why);
+ doc.removeLineClass(line, "wrap", lineClass);
+ }
+
+ getTextClasses(why) {
+ if (why && isException(why)) {
+ return {
+ markTextClass: "debug-expression-error",
+ lineClass: "new-debug-line-error",
+ };
+ }
+
+ return { markTextClass: "debug-expression", lineClass: "new-debug-line" };
+ }
+
+ render() {
+ return null;
+ }
+}
+
+function isDocumentReady(location, sourceTextContent) {
+ return location && sourceTextContent && hasDocument(location.source.id);
+}
+
+const mapStateToProps = state => {
+ // Avoid unecessary intermediate updates when there is no location
+ // or the source text content isn't yet fully loaded
+ const frame = getVisibleSelectedFrame(state);
+ const location = frame?.location;
+ if (!location) {
+ return {};
+ }
+ const sourceTextContent = getSourceTextContent(state, location);
+ if (!isDocumentReady(location, sourceTextContent)) {
+ return {};
+ }
+ return {
+ location,
+ why: getPauseReason(state, getCurrentThread(state)),
+ };
+};
+
+export default connect(mapStateToProps)(DebugLine);
diff --git a/devtools/client/debugger/src/components/Editor/Editor.css b/devtools/client/debugger/src/components/Editor/Editor.css
new file mode 100644
index 0000000000..0c48da019e
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Editor.css
@@ -0,0 +1,216 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.editor-wrapper {
+ --debug-line-border: rgb(145, 188, 219);
+ --debug-expression-background: rgba(202, 227, 255, 0.5);
+ --debug-line-error-border: rgb(255, 0, 0);
+ --debug-expression-error-background: rgba(231, 116, 113, 0.3);
+ --line-exception-background: hsl(344, 73%, 97%);
+ --highlight-line-duration: 5000ms;
+}
+
+.theme-dark .editor-wrapper {
+ --debug-expression-background: rgba(202, 227, 255, 0.3);
+ --debug-line-border: #7786a2;
+ --line-exception-background: hsl(345, 23%, 24%);
+}
+
+.editor-wrapper .CodeMirror-linewidget {
+ margin-right: -7px;
+}
+
+.editor-wrapper {
+ min-width: 0 !important;
+}
+
+.CodeMirror.cm-s-mozilla,
+.CodeMirror-scroll,
+.CodeMirror-sizer {
+ overflow-anchor: none;
+}
+
+/* Prevents inline preview from shifting source height (#1576163) */
+.CodeMirror-linewidget {
+ padding: 0;
+ display: flow-root;
+}
+
+/**
+ * There's a known codemirror flex issue with chrome that this addresses.
+ * BUG https://github.com/firefox-devtools/debugger/issues/63
+ */
+.editor-wrapper {
+ width: calc(100% - 1px);
+ overflow-y: auto;
+ grid-area: editor;
+}
+
+html[dir="rtl"] .editor-mount {
+ direction: ltr;
+}
+
+.function-search {
+ max-height: 300px;
+ overflow: hidden;
+}
+
+.function-search .results {
+ height: auto;
+}
+
+.editor.hit-marker {
+ height: 15px;
+}
+
+.editor-wrapper .highlight-lines {
+ background: var(--theme-selection-background-hover);
+}
+
+.CodeMirror {
+ width: 100%;
+ height: 100%;
+}
+
+.editor-wrapper .editor-mount {
+ width: 100%;
+ background-color: var(--theme-body-background);
+ font-size: var(--theme-code-font-size);
+ line-height: var(--theme-code-line-height);
+}
+
+/* set the linenumber white when there is a breakpoint */
+.editor-wrapper:not(.skip-pausing)
+ .new-breakpoint
+ .CodeMirror-gutter-wrapper
+ .CodeMirror-linenumber {
+ color: white;
+}
+
+/* move the breakpoint below the other gutter elements */
+.new-breakpoint .CodeMirror-gutter-elt:nth-child(2) {
+ z-index: 0;
+}
+
+.theme-dark .editor-wrapper .CodeMirror-line .cm-comment {
+ color: var(--theme-comment);
+}
+
+.debug-expression {
+ background-color: var(--debug-expression-background);
+ border-style: solid;
+ border-color: var(--debug-expression-background);
+ border-width: 1px 0px 1px 0px;
+ position: relative;
+}
+
+.debug-expression::before {
+ content: "";
+ line-height: 1px;
+ border-top: 1px solid var(--blue-50);
+ background: transparent;
+ position: absolute;
+ top: -2px;
+ left: 0px;
+ width: 100%;
+ }
+
+.debug-expression::after {
+ content: "";
+ line-height: 1px;
+ border-bottom: 1px solid var(--blue-50);
+ position: absolute;
+ bottom: -2px;
+ left: 0px;
+ width: 100%;
+ }
+
+.to-line-end ~ .CodeMirror-widget {
+ background-color: var(--debug-expression-background);
+}
+
+.debug-expression-error {
+ background-color: var(--debug-expression-error-background);
+}
+
+.new-debug-line > .CodeMirror-line {
+ background-color: transparent !important;
+ outline: var(--debug-line-border) solid 1px;
+}
+
+/* Don't display the highlight color since the debug line
+ is already highlighted */
+.new-debug-line .CodeMirror-activeline-background {
+ display: none;
+}
+
+.new-debug-line-error > .CodeMirror-line {
+ background-color: var(--debug-expression-error-background) !important;
+ outline: var(--debug-line-error-border) solid 1px;
+}
+
+/* Don't display the highlight color since the debug line
+ is already highlighted */
+.new-debug-line-error .CodeMirror-activeline-background {
+ display: none;
+}
+.highlight-line .CodeMirror-line {
+ animation-name: fade-highlight-out;
+ animation-duration: var(--highlight-line-duration);
+ animation-timing-function: ease-out;
+ animation-fill-mode: forwards;
+}
+
+@keyframes fade-highlight-out {
+ 0%, 30% {
+ /* We want to use a color with some transparency so text selection is visible through it */
+ background-color: var(--theme-contrast-background-alpha);
+ }
+ 100% {
+ background-color: transparent;
+ }
+}
+
+.visible {
+ visibility: visible;
+}
+
+/* Code folding */
+.editor-wrapper .CodeMirror-foldgutter-open {
+ color: var(--grey-40);
+}
+
+.editor-wrapper .CodeMirror-foldgutter-open,
+.editor-wrapper .CodeMirror-foldgutter-folded {
+ fill: var(--grey-40);
+}
+
+.editor-wrapper .CodeMirror-foldgutter-open::before,
+.editor-wrapper .CodeMirror-foldgutter-open::after {
+ border-top: none;
+}
+
+.editor-wrapper .CodeMirror-foldgutter-folded::before,
+.editor-wrapper .CodeMirror-foldgutter-folded::after {
+ border-left: none;
+}
+
+.editor-wrapper .CodeMirror-foldgutter .CodeMirror-guttermarker-subtle {
+ visibility: visible;
+}
+
+.editor-wrapper .CodeMirror-foldgutter .CodeMirror-linenumber {
+ text-align: left;
+ padding: 0 0 0 2px;
+}
+
+/* Exception line */
+.line-exception {
+ background-color: var(--line-exception-background);
+}
+
+.mark-text-exception {
+ text-decoration: var(--red-50) wavy underline;
+ text-decoration-skip-ink: none;
+}
diff --git a/devtools/client/debugger/src/components/Editor/EmptyLines.js b/devtools/client/debugger/src/components/Editor/EmptyLines.js
new file mode 100644
index 0000000000..31513408a8
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/EmptyLines.js
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { Component } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import {
+ getSelectedSource,
+ getSelectedBreakableLines,
+} from "../../selectors/index";
+import { fromEditorLine } from "../../utils/editor/index";
+import { isWasm } from "../../utils/wasm";
+
+class EmptyLines extends Component {
+ static get propTypes() {
+ return {
+ breakableLines: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.disableEmptyLines();
+ }
+
+ componentDidUpdate() {
+ this.disableEmptyLines();
+ }
+
+ componentWillUnmount() {
+ const { editor } = this.props;
+
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ editor.codeMirror.removeLineClass(lineHandle, "wrap", "empty-line");
+ });
+ });
+ }
+
+ shouldComponentUpdate(nextProps) {
+ const { breakableLines, selectedSource } = this.props;
+ return (
+ // Breakable lines are something that evolves over time,
+ // but we either have them loaded or not. So only compare the size
+ // as sometimes we always get a blank new empty Set instance.
+ breakableLines.size != nextProps.breakableLines.size ||
+ selectedSource.id != nextProps.selectedSource.id
+ );
+ }
+
+ disableEmptyLines() {
+ const { breakableLines, selectedSource, editor } = this.props;
+
+ const { codeMirror } = editor;
+ const isSourceWasm = isWasm(selectedSource.id);
+
+ codeMirror.operation(() => {
+ const lineCount = codeMirror.lineCount();
+ for (let i = 0; i < lineCount; i++) {
+ const line = fromEditorLine(selectedSource.id, i, isSourceWasm);
+
+ if (breakableLines.has(line)) {
+ codeMirror.removeLineClass(i, "wrap", "empty-line");
+ } else {
+ codeMirror.addLineClass(i, "wrap", "empty-line");
+ }
+ }
+ });
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ if (!selectedSource) {
+ throw new Error("no selectedSource");
+ }
+ const breakableLines = getSelectedBreakableLines(state);
+
+ return {
+ selectedSource,
+ breakableLines,
+ };
+};
+
+export default connect(mapStateToProps)(EmptyLines);
diff --git a/devtools/client/debugger/src/components/Editor/Exception.js b/devtools/client/debugger/src/components/Editor/Exception.js
new file mode 100644
index 0000000000..b76923e597
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Exception.js
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import {
+ toEditorPosition,
+ getTokenEnd,
+ hasDocument,
+} from "../../utils/editor/index";
+
+import { getIndentation } from "../../utils/indentation";
+import { createLocation } from "../../utils/location";
+
+export default class Exception extends PureComponent {
+ exceptionLine;
+ markText;
+
+ static get propTypes() {
+ return {
+ exception: PropTypes.object.isRequired,
+ doc: PropTypes.object.isRequired,
+ selectedSource: PropTypes.string.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addEditorExceptionLine();
+ }
+
+ componentDidUpdate() {
+ this.clearEditorExceptionLine();
+ this.addEditorExceptionLine();
+ }
+
+ componentWillUnmount() {
+ this.clearEditorExceptionLine();
+ }
+
+ setEditorExceptionLine(doc, line, column, lineText) {
+ doc.addLineClass(line, "wrap", "line-exception");
+
+ column = Math.max(column, getIndentation(lineText));
+ const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null;
+
+ const markText = doc.markText(
+ { ch: column, line },
+ { ch: columnEnd, line },
+ { className: "mark-text-exception" }
+ );
+
+ this.exceptionLine = line;
+ this.markText = markText;
+ }
+
+ addEditorExceptionLine() {
+ const { exception, doc, selectedSource } = this.props;
+ const { columnNumber, lineNumber } = exception;
+
+ if (!hasDocument(selectedSource.id)) {
+ return;
+ }
+
+ const location = createLocation({
+ source: selectedSource,
+ line: lineNumber,
+ // Exceptions are reported with column being 1-based
+ // while the frontend uses 0-based column.
+ column: columnNumber - 1,
+ });
+
+ const { line, column } = toEditorPosition(location);
+ const lineText = doc.getLine(line);
+
+ this.setEditorExceptionLine(doc, line, column, lineText);
+ }
+
+ clearEditorExceptionLine() {
+ if (this.markText) {
+ const { selectedSource } = this.props;
+
+ this.markText.clear();
+
+ if (hasDocument(selectedSource.id)) {
+ this.props.doc.removeLineClass(
+ this.exceptionLine,
+ "wrap",
+ "line-exception"
+ );
+ }
+ this.exceptionLine = null;
+ this.markText = null;
+ }
+ }
+
+ // This component is only used as a "proxy" to manipulate the editor.
+ render() {
+ return null;
+ }
+}
diff --git a/devtools/client/debugger/src/components/Editor/Exceptions.js b/devtools/client/debugger/src/components/Editor/Exceptions.js
new file mode 100644
index 0000000000..2fb183f135
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Exceptions.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import Exception from "./Exception";
+
+import {
+ getSelectedSource,
+ getSelectedSourceExceptions,
+} from "../../selectors/index";
+import { getDocument } from "../../utils/editor/index";
+
+class Exceptions extends Component {
+ static get propTypes() {
+ return {
+ exceptions: PropTypes.array,
+ selectedSource: PropTypes.object,
+ };
+ }
+
+ render() {
+ const { exceptions, selectedSource } = this.props;
+
+ if (!selectedSource || !exceptions.length) {
+ return null;
+ }
+
+ const doc = getDocument(selectedSource.id);
+ return React.createElement(
+ React.Fragment,
+ null,
+ exceptions.map(exception =>
+ React.createElement(Exception, {
+ exception,
+ doc,
+ key: `${exception.sourceActorId}:${exception.lineNumber}`,
+ selectedSource,
+ })
+ )
+ );
+ }
+}
+
+export default connect(state => {
+ const selectedSource = getSelectedSource(state);
+
+ // Avoid calling getSelectedSourceExceptions when there is no source selected.
+ if (!selectedSource) {
+ return {};
+ }
+
+ // Avoid causing any update until we start having exceptions
+ const exceptions = getSelectedSourceExceptions(state);
+ if (!exceptions.length) {
+ return {};
+ }
+
+ return {
+ exceptions: getSelectedSourceExceptions(state),
+ selectedSource,
+ };
+})(Exceptions);
diff --git a/devtools/client/debugger/src/components/Editor/Footer.css b/devtools/client/debugger/src/components/Editor/Footer.css
new file mode 100644
index 0000000000..4a3272879b
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Footer.css
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.source-footer {
+ background: var(--theme-body-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ grid-area: editor-footer;
+ display: flex;
+ opacity: 1;
+ width: calc(100% - 1px);
+ user-select: none;
+ height: var(--editor-footer-height);
+ box-sizing: border-box;
+}
+
+.source-footer button {
+ outline-offset: -2px;
+}
+
+.source-footer-start {
+ display: flex;
+ align-items: center;
+ justify-self: start;
+}
+
+.source-footer-end {
+ display: flex;
+ margin-left: auto;
+}
+
+.source-footer .commands * {
+ user-select: none;
+}
+
+.source-footer .commands {
+ display: flex;
+}
+
+.source-footer .commands .action {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: opacity 200ms;
+ border: none;
+ background: transparent;
+ padding: 4px 6px;
+}
+
+.source-footer .commands button.action:hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+:root.theme-dark .source-footer .commands .action {
+ fill: var(--theme-body-color);
+}
+
+:root.theme-dark .source-footer .commands .action:hover {
+ fill: var(--theme-selection-color);
+}
+
+.source-footer .blackboxed .img.blackBox {
+ background-color: #806414;
+}
+
+.source-footer .commands button.prettyPrint:disabled {
+ opacity: 0.6;
+}
+
+.source-footer .mapped-source,
+.source-footer .cursor-position {
+ color: var(--theme-body-color);
+ padding-right: 2.5px;
+}
+
+.source-footer .mapped-source {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.source-footer .cursor-position {
+ padding: 5px;
+ white-space: nowrap;
+}
diff --git a/devtools/client/debugger/src/components/Editor/Footer.js b/devtools/client/debugger/src/components/Editor/Footer.js
new file mode 100644
index 0000000000..c4ff02caf4
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Footer.js
@@ -0,0 +1,288 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ button,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../actions/index";
+import {
+ getSelectedSource,
+ getSelectedLocation,
+ getSelectedSourceTextContent,
+ getPrettySource,
+ getPaneCollapse,
+ isSourceBlackBoxed,
+ canPrettyPrintSource,
+ getPrettyPrintMessage,
+ isSourceOnSourceMapIgnoreList,
+ isSourceMapIgnoreListEnabled,
+ getSelectedMappedSource,
+} from "../../selectors/index";
+
+import { isPretty, getFilename, shouldBlackbox } from "../../utils/source";
+
+import { PaneToggleButton } from "../shared/Button/index";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class SourceFooter extends PureComponent {
+ static get propTypes() {
+ return {
+ canPrettyPrint: PropTypes.bool.isRequired,
+ prettyPrintMessage: PropTypes.string.isRequired,
+ endPanelCollapsed: PropTypes.bool.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ jumpToMappedLocation: PropTypes.func.isRequired,
+ mappedSource: PropTypes.object,
+ selectedSource: PropTypes.object,
+ selectedLocation: PropTypes.object,
+ isSelectedSourceBlackBoxed: PropTypes.bool.isRequired,
+ sourceLoaded: PropTypes.bool.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ togglePaneCollapse: PropTypes.func.isRequired,
+ prettyPrintAndSelectSource: PropTypes.func.isRequired,
+ isSourceOnIgnoreList: PropTypes.bool.isRequired,
+ };
+ }
+
+ prettyPrintButton() {
+ const {
+ selectedSource,
+ canPrettyPrint,
+ prettyPrintMessage,
+ prettyPrintAndSelectSource,
+ sourceLoaded,
+ } = this.props;
+
+ if (!selectedSource) {
+ return null;
+ }
+
+ if (!sourceLoaded && selectedSource.isPrettyPrinted) {
+ return div(
+ {
+ className: "action",
+ key: "pretty-loader",
+ },
+ React.createElement(AccessibleImage, {
+ className: "loader spin",
+ })
+ );
+ }
+
+ const type = "prettyPrint";
+ return button(
+ {
+ onClick: () => {
+ if (!canPrettyPrint) {
+ return;
+ }
+ prettyPrintAndSelectSource(selectedSource);
+ },
+ className: classnames("action", type, {
+ active: sourceLoaded && canPrettyPrint,
+ pretty: isPretty(selectedSource),
+ }),
+ key: type,
+ title: prettyPrintMessage,
+ "aria-label": prettyPrintMessage,
+ disabled: !canPrettyPrint,
+ },
+ React.createElement(AccessibleImage, {
+ className: type,
+ })
+ );
+ }
+
+ blackBoxButton() {
+ const {
+ selectedSource,
+ isSelectedSourceBlackBoxed,
+ toggleBlackBox,
+ sourceLoaded,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ if (!selectedSource || !shouldBlackbox(selectedSource)) {
+ return null;
+ }
+
+ let tooltip = isSelectedSourceBlackBoxed
+ ? L10N.getStr("sourceFooter.unignore")
+ : L10N.getStr("sourceFooter.ignore");
+
+ if (isSourceOnIgnoreList) {
+ tooltip = L10N.getStr("sourceFooter.ignoreList");
+ }
+
+ const type = "black-box";
+ return button(
+ {
+ onClick: () => toggleBlackBox(selectedSource),
+ className: classnames("action", type, {
+ active: sourceLoaded,
+ blackboxed: isSelectedSourceBlackBoxed || isSourceOnIgnoreList,
+ }),
+ key: type,
+ title: tooltip,
+ "aria-label": tooltip,
+ disabled: isSourceOnIgnoreList,
+ },
+ React.createElement(AccessibleImage, {
+ className: "blackBox",
+ })
+ );
+ }
+
+ renderToggleButton() {
+ if (this.props.horizontal) {
+ return null;
+ }
+ return React.createElement(PaneToggleButton, {
+ key: "toggle",
+ collapsed: this.props.endPanelCollapsed,
+ horizontal: this.props.horizontal,
+ handleClick: this.props.togglePaneCollapse,
+ position: "end",
+ });
+ }
+
+ renderCommands() {
+ const commands = [this.blackBoxButton(), this.prettyPrintButton()].filter(
+ Boolean
+ );
+
+ return commands.length
+ ? div(
+ {
+ className: "commands",
+ },
+ commands
+ )
+ : null;
+ }
+
+ renderSourceSummary() {
+ const { mappedSource, jumpToMappedLocation, selectedLocation } = this.props;
+
+ if (!mappedSource) {
+ return null;
+ }
+
+ const tooltip = L10N.getFormatStr(
+ mappedSource.isOriginal
+ ? "sourceFooter.mappedOriginalSource.tooltip"
+ : "sourceFooter.mappedGeneratedSource.tooltip",
+ mappedSource.url
+ );
+ const filename = getFilename(mappedSource);
+ const label = L10N.getFormatStr(
+ mappedSource.isOriginal
+ ? "sourceFooter.mappedOriginalSource.title"
+ : "sourceFooter.mappedGeneratedSource.title",
+ filename
+ );
+ return button(
+ {
+ className: "mapped-source",
+ onClick: () => jumpToMappedLocation(selectedLocation),
+ title: tooltip,
+ },
+ span(null, label)
+ );
+ }
+
+ renderCursorPosition() {
+ // When we open a new source, there is no particular location selected and the line will be set to zero or falsy
+ if (!this.props.selectedLocation || !this.props.selectedLocation.line) {
+ return null;
+ }
+
+ // Note that line is 1-based while column is 0-based.
+ const { line, column } = this.props.selectedLocation;
+
+ const text = L10N.getFormatStr(
+ "sourceFooter.currentCursorPosition",
+ line,
+ column + 1
+ );
+ const title = L10N.getFormatStr(
+ "sourceFooter.currentCursorPosition.tooltip",
+ line,
+ column + 1
+ );
+ return div(
+ {
+ className: "cursor-position",
+ title,
+ },
+ text
+ );
+ }
+
+ render() {
+ return div(
+ {
+ className: "source-footer",
+ },
+ div(
+ {
+ className: "source-footer-start",
+ },
+ this.renderCommands()
+ ),
+ div(
+ {
+ className: "source-footer-end",
+ },
+ this.renderSourceSummary(),
+ this.renderCursorPosition(),
+ this.renderToggleButton()
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ const selectedLocation = getSelectedLocation(state);
+ const sourceTextContent = getSelectedSourceTextContent(state);
+
+ return {
+ selectedSource,
+ selectedLocation,
+ isSelectedSourceBlackBoxed: selectedSource
+ ? isSourceBlackBoxed(state, selectedSource)
+ : null,
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, selectedSource),
+ sourceLoaded: !!sourceTextContent,
+ mappedSource: getSelectedMappedSource(state),
+ prettySource: getPrettySource(
+ state,
+ selectedSource ? selectedSource.id : null
+ ),
+ endPanelCollapsed: getPaneCollapse(state, "end"),
+ canPrettyPrint: selectedLocation
+ ? canPrettyPrintSource(state, selectedLocation)
+ : false,
+ prettyPrintMessage: selectedLocation
+ ? getPrettyPrintMessage(state, selectedLocation)
+ : null,
+ };
+};
+
+export default connect(mapStateToProps, {
+ prettyPrintAndSelectSource: actions.prettyPrintAndSelectSource,
+ toggleBlackBox: actions.toggleBlackBox,
+ jumpToMappedLocation: actions.jumpToMappedLocation,
+ togglePaneCollapse: actions.togglePaneCollapse,
+})(SourceFooter);
diff --git a/devtools/client/debugger/src/components/Editor/HighlightLine.js b/devtools/client/debugger/src/components/Editor/HighlightLine.js
new file mode 100644
index 0000000000..8639128905
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightLine.js
@@ -0,0 +1,194 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import {
+ toEditorLine,
+ endOperation,
+ startOperation,
+} from "../../utils/editor/index";
+import { getDocument, hasDocument } from "../../utils/editor/source-documents";
+
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import {
+ getVisibleSelectedFrame,
+ getSelectedLocation,
+ getSelectedSourceTextContent,
+ getPauseCommand,
+ getCurrentThread,
+ getShouldHighlightSelectedLocation,
+} from "../../selectors/index";
+
+function isDebugLine(selectedFrame, selectedLocation) {
+ if (!selectedFrame) {
+ return false;
+ }
+
+ return (
+ selectedFrame.location.source.id == selectedLocation.source.id &&
+ selectedFrame.location.line == selectedLocation.line
+ );
+}
+
+function isDocumentReady(selectedLocation, selectedSourceTextContent) {
+ return (
+ selectedLocation &&
+ selectedSourceTextContent &&
+ hasDocument(selectedLocation.source.id)
+ );
+}
+
+export class HighlightLine extends Component {
+ isStepping = false;
+ previousEditorLine = null;
+
+ static get propTypes() {
+ return {
+ pauseCommand: PropTypes.oneOf([
+ "expression",
+ "resume",
+ "stepOver",
+ "stepIn",
+ "stepOut",
+ ]),
+ selectedFrame: PropTypes.object,
+ selectedLocation: PropTypes.object.isRequired,
+ selectedSourceTextContent: PropTypes.object.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps) {
+ const { selectedLocation, selectedSourceTextContent } = nextProps;
+ return this.shouldSetHighlightLine(
+ selectedLocation,
+ selectedSourceTextContent
+ );
+ }
+
+ componentDidUpdate(prevProps) {
+ this.completeHighlightLine(prevProps);
+ }
+
+ componentDidMount() {
+ this.completeHighlightLine(null);
+ }
+
+ shouldSetHighlightLine(selectedLocation, selectedSourceTextContent) {
+ const { line } = selectedLocation;
+ const editorLine = toEditorLine(selectedLocation.source.id, line);
+
+ if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) {
+ return false;
+ }
+
+ if (this.isStepping && editorLine === this.previousEditorLine) {
+ return false;
+ }
+
+ return true;
+ }
+
+ completeHighlightLine(prevProps) {
+ const {
+ pauseCommand,
+ selectedLocation,
+ selectedFrame,
+ selectedSourceTextContent,
+ shouldHighlightSelectedLocation,
+ } = this.props;
+ if (pauseCommand) {
+ this.isStepping = true;
+ }
+
+ startOperation();
+ if (prevProps) {
+ this.clearHighlightLine(
+ prevProps.selectedLocation,
+ prevProps.selectedSourceTextContent
+ );
+ }
+ if (shouldHighlightSelectedLocation) {
+ this.setHighlightLine(
+ selectedLocation,
+ selectedFrame,
+ selectedSourceTextContent
+ );
+ }
+ endOperation();
+ }
+
+ setHighlightLine(selectedLocation, selectedFrame, selectedSourceTextContent) {
+ const { line } = selectedLocation;
+ if (
+ !this.shouldSetHighlightLine(selectedLocation, selectedSourceTextContent)
+ ) {
+ return;
+ }
+
+ this.isStepping = false;
+ const sourceId = selectedLocation.source.id;
+ const editorLine = toEditorLine(sourceId, line);
+ this.previousEditorLine = editorLine;
+
+ if (!line || isDebugLine(selectedFrame, selectedLocation)) {
+ return;
+ }
+
+ const doc = getDocument(sourceId);
+ doc.addLineClass(editorLine, "wrap", "highlight-line");
+ this.resetHighlightLine(doc, editorLine);
+ }
+
+ resetHighlightLine(doc, editorLine) {
+ const editorWrapper = document.querySelector(".editor-wrapper");
+
+ if (editorWrapper === null) {
+ return;
+ }
+
+ const duration = parseInt(
+ getComputedStyle(editorWrapper).getPropertyValue(
+ "--highlight-line-duration"
+ ),
+ 10
+ );
+
+ setTimeout(
+ () => doc && doc.removeLineClass(editorLine, "wrap", "highlight-line"),
+ duration
+ );
+ }
+
+ clearHighlightLine(selectedLocation, selectedSourceTextContent) {
+ if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) {
+ return;
+ }
+
+ const { line } = selectedLocation;
+ const sourceId = selectedLocation.source.id;
+ const editorLine = toEditorLine(sourceId, line);
+ const doc = getDocument(sourceId);
+ doc.removeLineClass(editorLine, "wrap", "highlight-line");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default connect(state => {
+ const selectedLocation = getSelectedLocation(state);
+
+ if (!selectedLocation) {
+ throw new Error("must have selected location");
+ }
+ return {
+ pauseCommand: getPauseCommand(state, getCurrentThread(state)),
+ shouldHighlightSelectedLocation: getShouldHighlightSelectedLocation(state),
+ selectedFrame: getVisibleSelectedFrame(state),
+ selectedLocation,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ };
+})(HighlightLine);
diff --git a/devtools/client/debugger/src/components/Editor/HighlightLines.js b/devtools/client/debugger/src/components/Editor/HighlightLines.js
new file mode 100644
index 0000000000..e34a86aba9
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightLines.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+class HighlightLines extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ range: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.highlightLineRange();
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate() {
+ this.clearHighlightRange();
+ }
+
+ componentDidUpdate() {
+ this.highlightLineRange();
+ }
+
+ componentWillUnmount() {
+ this.clearHighlightRange();
+ }
+
+ clearHighlightRange() {
+ const { range, editor } = this.props;
+
+ const { codeMirror } = editor;
+
+ if (!range || !codeMirror) {
+ return;
+ }
+
+ const { start, end } = range;
+ codeMirror.operation(() => {
+ for (let line = start - 1; line < end; line++) {
+ codeMirror.removeLineClass(line, "wrap", "highlight-lines");
+ }
+ });
+ }
+
+ highlightLineRange = () => {
+ const { range, editor } = this.props;
+
+ const { codeMirror } = editor;
+
+ if (!range || !codeMirror) {
+ return;
+ }
+
+ const { start, end } = range;
+
+ codeMirror.operation(() => {
+ editor.alignLine(start);
+ for (let line = start - 1; line < end; line++) {
+ codeMirror.addLineClass(line, "wrap", "highlight-lines");
+ }
+ });
+ };
+
+ render() {
+ return null;
+ }
+}
+
+export default HighlightLines;
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.css b/devtools/client/debugger/src/components/Editor/InlinePreview.css
new file mode 100644
index 0000000000..13f1b5e23c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreview.css
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.inline-preview {
+ display: inline-block;
+ margin-inline-start: 8px;
+ user-select: none;
+}
+
+.inline-preview-outer {
+ background-color: var(--theme-inline-preview-background);
+ border: 1px solid var(--theme-inline-preview-border-color);
+ border-radius: 3px;
+ font-size: 10px;
+ margin-right: 5px;
+ white-space: nowrap;
+}
+
+.inline-preview-label {
+ padding: 0px 2px 0px 4px;
+ border-radius: 2px 0 0 2px;
+ color: var(--theme-inline-preview-label-color);
+ background-color: var(--theme-inline-preview-label-background);
+}
+
+.inline-preview-value {
+ padding: 2px 6px;
+}
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.js b/devtools/client/debugger/src/components/Editor/InlinePreview.js
new file mode 100644
index 0000000000..552143dcf2
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreview.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import { span } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import Reps from "devtools/client/shared/components/reps/index";
+
+const {
+ REPS: {
+ Rep,
+ ElementNode: { supportsObject: isElement },
+ },
+ MODE,
+} = Reps;
+
+// Renders single variable preview inside a codemirror line widget
+class InlinePreview extends PureComponent {
+ static get propTypes() {
+ return {
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ value: PropTypes.any,
+ variable: PropTypes.string.isRequired,
+ };
+ }
+
+ showInScopes(variable) {
+ // TODO: focus on variable value in the scopes sidepanel
+ // we will need more info from parent comp
+ }
+
+ render() {
+ const {
+ value,
+ variable,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const mode = isElement(value) ? MODE.TINY : MODE.SHORT;
+ return span(
+ {
+ className: "inline-preview-outer",
+ onClick: () => this.showInScopes(variable),
+ },
+ span(
+ {
+ className: "inline-preview-label",
+ },
+ variable,
+ ":"
+ ),
+ span(
+ {
+ className: "inline-preview-value",
+ },
+ React.createElement(Rep, {
+ object: value,
+ mode,
+ onDOMNodeClick: grip => openElementInInspector(grip),
+ onInspectIconClick: grip => openElementInInspector(grip),
+ onDOMNodeMouseOver: grip => highlightDomElement(grip),
+ onDOMNodeMouseOut: grip => unHighlightDomElement(grip),
+ })
+ )
+ );
+ }
+}
+
+export default InlinePreview;
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js
new file mode 100644
index 0000000000..bc54fc5b4d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import ReactDOM from "devtools/client/shared/vendor/react-dom";
+
+import actions from "../../actions/index";
+import assert from "../../utils/assert";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import InlinePreview from "./InlinePreview";
+
+// Handles rendering for each line ( row )
+// * Renders single widget for each line in codemirror
+// * Renders InlinePreview for each preview inside the widget
+class InlinePreviewRow extends PureComponent {
+ bookmark;
+ widgetNode;
+
+ componentDidMount() {
+ this.updatePreviewWidget(this.props, null);
+ }
+
+ componentDidUpdate(prevProps) {
+ this.updatePreviewWidget(this.props, prevProps);
+ }
+
+ componentWillUnmount() {
+ this.updatePreviewWidget(null, this.props);
+ }
+
+ updatePreviewWidget(props, prevProps) {
+ if (
+ this.bookmark &&
+ prevProps &&
+ (!props ||
+ prevProps.editor !== props.editor ||
+ prevProps.line !== props.line)
+ ) {
+ this.bookmark.clear();
+ this.bookmark = null;
+ this.widgetNode = null;
+ }
+
+ if (!props) {
+ assert(!this.bookmark, "Inline Preview widget shouldn't be present.");
+ return;
+ }
+
+ const {
+ editor,
+ line,
+ previews,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = props;
+
+ if (!this.bookmark) {
+ this.widgetNode = document.createElement("div");
+ this.widgetNode.classList.add("inline-preview");
+ }
+
+ ReactDOM.render(
+ React.createElement(
+ React.Fragment,
+ null,
+ previews.map(preview =>
+ React.createElement(InlinePreview, {
+ line: line,
+ key: `${line}-${preview.name}`,
+ variable: preview.name,
+ value: preview.value,
+ openElementInInspector: openElementInInspector,
+ highlightDomElement: highlightDomElement,
+ unHighlightDomElement: unHighlightDomElement,
+ })
+ )
+ ),
+ this.widgetNode,
+ () => {
+ // Only set the codeMirror bookmark once React rendered the element into this.widgetNode
+ this.bookmark = editor.codeMirror.setBookmark(
+ {
+ line,
+ ch: Infinity,
+ },
+ this.widgetNode
+ );
+ }
+ );
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default connect(() => ({}), {
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+})(InlinePreviewRow);
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviews.js b/devtools/client/debugger/src/components/Editor/InlinePreviews.js
new file mode 100644
index 0000000000..18616ae3ed
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreviews.js
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import InlinePreviewRow from "./InlinePreviewRow";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import {
+ getSelectedFrame,
+ getCurrentThread,
+ getInlinePreviews,
+} from "../../selectors/index";
+
+function hasPreviews(previews) {
+ return !!previews && !!Object.keys(previews).length;
+}
+
+class InlinePreviews extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ previews: PropTypes.object,
+ selectedFrame: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ };
+ }
+
+ shouldComponentUpdate({ previews }) {
+ return hasPreviews(previews);
+ }
+
+ render() {
+ const { editor, selectedFrame, selectedSource, previews } = this.props;
+
+ // Render only if currently open file is the one where debugger is paused
+ if (
+ !selectedFrame ||
+ selectedFrame.location.source.id !== selectedSource.id ||
+ !hasPreviews(previews)
+ ) {
+ return null;
+ }
+ const previewsObj = previews;
+
+ let inlinePreviewRows;
+ editor.codeMirror.operation(() => {
+ inlinePreviewRows = Object.keys(previewsObj).map(line => {
+ const lineNum = parseInt(line, 10);
+ return React.createElement(InlinePreviewRow, {
+ editor: editor,
+ key: line,
+ line: lineNum,
+ previews: previewsObj[line],
+ });
+ });
+ });
+ return div(null, inlinePreviewRows);
+ }
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ const selectedFrame = getSelectedFrame(state, thread);
+
+ if (!selectedFrame) {
+ return {
+ selectedFrame: null,
+ previews: null,
+ };
+ }
+
+ return {
+ selectedFrame,
+ previews: getInlinePreviews(state, thread, selectedFrame.id),
+ };
+};
+
+export default connect(mapStateToProps)(InlinePreviews);
diff --git a/devtools/client/debugger/src/components/Editor/Preview.css b/devtools/client/debugger/src/components/Editor/Preview.css
new file mode 100644
index 0000000000..7e3d788c68
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview.css
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.popover .preview {
+ background: var(--theme-body-background);
+ width: 350px;
+ border: 1px solid var(--theme-splitter-color);
+ padding: 10px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.theme-dark .popover .preview {
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.popover .preview .header {
+ width: 100%;
+ line-height: 20px;
+ border-bottom: 1px solid #cccccc;
+ display: flex;
+ flex-direction: column;
+}
+
+.popover .preview .header .link {
+ align-self: flex-end;
+ color: var(--theme-link-color);
+ text-decoration: underline;
+}
+
+.selection,
+.debug-expression.selection {
+ background-color: var(--theme-highlight-yellow);
+}
+
+.theme-dark .selection,
+.theme-dark .debug-expression.selection {
+ background-color: #743884;
+}
+
+.theme-dark .cm-s-mozilla .selection,
+.theme-dark .cm-s-mozilla .debug-expression.selection {
+ color: #e7ebee;
+}
+
+.popover .preview .function-signature {
+ padding-top: 10px;
+}
+
+.theme-dark .popover .preview {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip {
+ position: fixed;
+ z-index: 100;
+}
+
+.tooltip .preview {
+ background: var(--theme-toolbar-background);
+ max-width: inherit;
+ border: 1px solid var(--theme-splitter-color);
+ box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt);
+ padding: 5px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+}
+
+.theme-dark .tooltip .preview {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip .gap {
+ height: 4px;
+ padding-top: 4px;
+}
+
+.add-to-expression-bar {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: none;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ font-size: 14px;
+ line-height: 30px;
+ background: var(--theme-toolbar-background);
+ color: var(--theme-text-color-inactive);
+ padding: 0 4px;
+}
+
+.add-to-expression-bar .prompt {
+ width: 1em;
+}
+
+.add-to-expression-bar .expression-to-save-label {
+ width: calc(100% - 4em);
+}
+
+.add-to-expression-bar .expression-to-save-button {
+ font-size: 14px;
+ color: var(--theme-comment);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js
new file mode 100644
index 0000000000..0789b82694
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js
@@ -0,0 +1,142 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div, span } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { StringRep },
+} = Reps;
+
+import actions from "../../../actions/index";
+
+import AccessibleImage from "../../shared/AccessibleImage";
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const ANONYMOUS_FN_NAME = "<anonymous>";
+
+// The exception popup works in two modes:
+// a. when the stacktrace is closed the exception popup
+// gets closed when the mouse leaves the popup.
+// b. when the stacktrace is opened the exception popup
+// gets closed only by clicking outside the popup.
+class ExceptionPopup extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isStacktraceExpanded: true,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ mouseout: PropTypes.func.isRequired,
+ selectSourceURL: PropTypes.func.isRequired,
+ exception: PropTypes.object.isRequired,
+ };
+ }
+
+ onExceptionMessageClick() {
+ const isStacktraceExpanded = this.state.isStacktraceExpanded;
+ this.setState({ isStacktraceExpanded: !isStacktraceExpanded });
+ }
+
+ buildStackFrame(frame) {
+ const { filename, lineNumber } = frame;
+ const functionName = frame.functionName || ANONYMOUS_FN_NAME;
+ return div(
+ {
+ className: "frame",
+ onClick: () =>
+ this.props.selectSourceURL(filename, {
+ line: lineNumber,
+ }),
+ },
+ span(
+ {
+ className: "title",
+ },
+ functionName
+ ),
+ span(
+ {
+ className: "location",
+ },
+ span(
+ {
+ className: "filename",
+ },
+ filename
+ ),
+ ":",
+ span(
+ {
+ className: "line",
+ },
+ lineNumber
+ )
+ )
+ );
+ }
+
+ renderStacktrace(stacktrace) {
+ const isStacktraceExpanded = this.state.isStacktraceExpanded;
+
+ if (stacktrace.length && isStacktraceExpanded) {
+ return div(
+ {
+ className: "exception-stacktrace",
+ },
+ stacktrace.map(frame => this.buildStackFrame(frame))
+ );
+ }
+ return null;
+ }
+
+ renderArrowIcon(stacktrace) {
+ if (stacktrace.length) {
+ return React.createElement(AccessibleImage, {
+ className: classnames("arrow", {
+ expanded: this.state.isStacktraceExpanded,
+ }),
+ });
+ }
+ return null;
+ }
+
+ render() {
+ const {
+ exception: { stacktrace, errorMessage },
+ mouseout,
+ } = this.props;
+ return div(
+ {
+ className: "preview-popup exception-popup",
+ dir: "ltr",
+ onMouseLeave: () => mouseout(true, this.state.isStacktraceExpanded),
+ },
+ div(
+ {
+ className: "exception-message",
+ onClick: () => this.onExceptionMessageClick(),
+ },
+ this.renderArrowIcon(stacktrace),
+ StringRep.rep({
+ object: errorMessage,
+ useQuotes: false,
+ className: "exception-text",
+ })
+ ),
+ this.renderStacktrace(stacktrace)
+ );
+ }
+}
+
+const mapDispatchToProps = {
+ selectSourceURL: actions.selectSourceURL,
+};
+
+export default connect(null, mapDispatchToProps)(ExceptionPopup);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.css b/devtools/client/debugger/src/components/Editor/Preview/Popup.css
new file mode 100644
index 0000000000..745d272fbb
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.css
@@ -0,0 +1,174 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.preview-popup {
+ border: 1px solid var(--theme-splitter-color);
+ height: auto;
+ overflow: auto;
+ background: var(--theme-body-background);
+ box-shadow: 1px 1px 3px var(--popup-shadow-color);
+}
+
+.popover .preview-popup {
+ padding: 5px 10px;
+ max-width: 450px;
+ min-width: 200px;
+}
+
+.tooltip .preview-popup {
+ max-width: inherit;
+ padding: 5px;
+ min-height: inherit;
+ max-height: 200px;
+}
+
+.preview-popup .tree {
+ /* Setting a fixed line height to avoid issues in custom formatters changing
+ * the line height like the CLJS DevTools */
+ line-height: 15px;
+}
+
+.gap svg {
+ pointer-events: none;
+}
+
+.gap polygon {
+ pointer-events: auto;
+}
+
+.popover .preview-popup .object-node {
+ padding-inline-start: 0px;
+}
+
+.preview-token:hover {
+ cursor: default;
+}
+
+.preview-token,
+.debug-expression.preview-token {
+ background-color: var(--theme-highlight-yellow);
+}
+
+.theme-dark .preview-token,
+.theme-dark .debug-expression.preview-token {
+ background-color: #743884;
+}
+
+.theme-dark .cm-s-mozilla .preview-token,
+.theme-dark .cm-s-mozilla .debug-expression.preview-token {
+ color: #e7ebee;
+}
+
+.theme-dark .popover .preview-popup {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip {
+ position: fixed;
+ z-index: 100;
+}
+
+
+
+.theme-dark .tooltip .preview-popup {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip .gap {
+ height: 4px;
+ padding-top: 0px;
+}
+
+.add-to-expression-bar {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: none;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ font-size: 14px;
+ line-height: 30px;
+ background: var(--theme-toolbar-background);
+ color: var(--theme-text-color-inactive);
+ padding: 0 4px;
+}
+
+.add-to-expression-bar .prompt {
+ width: 1em;
+}
+
+.add-to-expression-bar .expression-to-save-label {
+ width: calc(100% - 4em);
+}
+
+.add-to-expression-bar .expression-to-save-button {
+ font-size: 14px;
+ color: var(--theme-comment);
+}
+
+/* Exception popup */
+.exception-popup .exception-text {
+ color: var(--red-70);
+}
+
+.theme-dark .exception-popup .exception-text {
+ color: var(--red-20);
+}
+
+.exception-popup .exception-message {
+ display: flex;
+ align-items: center;
+}
+
+.exception-message .arrow {
+ margin-inline-end: 4px;
+}
+
+.exception-popup .exception-stacktrace {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-column-gap: 8px;
+ padding-inline: 20px 3px;
+ line-height: var(--theme-code-line-height);
+}
+
+.exception-stacktrace .frame {
+ display: contents;
+ cursor: pointer;
+}
+
+.exception-stacktrace .title {
+ grid-column: 1/2;
+ color: var(--grey-90);
+}
+
+.theme-dark .exception-stacktrace .title {
+ color: white;
+}
+
+.exception-stacktrace .location {
+ grid-column: -1/-2;
+ color: var(--theme-highlight-purple);
+ direction: rtl;
+ text-align: end;
+ white-space: nowrap;
+ /* Force the location to be on one line and crop at start if wider then max-width */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 350px;
+}
+
+.theme-dark .exception-stacktrace .location {
+ color: var(--blue-40);
+}
+
+.exception-stacktrace .line {
+ color: var(--theme-highlight-blue);
+}
+
+.theme-dark .exception-stacktrace .line {
+ color: hsl(210, 40%, 60%);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.js b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
new file mode 100644
index 0000000000..a010358dc1
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
@@ -0,0 +1,277 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { Grip },
+ MODE,
+ objectInspector,
+} = Reps;
+
+const { ObjectInspector, utils } = objectInspector;
+
+const {
+ node: { nodeIsPrimitive },
+} = utils;
+
+import ExceptionPopup from "./ExceptionPopup";
+
+import actions from "../../../actions/index";
+import Popover from "../../shared/Popover";
+
+export class Popup extends Component {
+ constructor(props) {
+ super(props);
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ openLink: PropTypes.func.isRequired,
+ preview: PropTypes.object.isRequired,
+ selectSourceURL: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addHighlightToToken(this.props.preview.target);
+ }
+
+ componentWillUnmount() {
+ this.removeHighlightFromToken(this.props.preview.target);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { target } = this.props.preview;
+ if (prevProps.target == target) {
+ return;
+ }
+
+ this.removeHighlightFromToken(prevProps.preview.target);
+ this.addHighlightToToken(target);
+ }
+
+ addHighlightToToken(target) {
+ if (!target) {
+ return;
+ }
+
+ target.classList.add("preview-token");
+ addHighlightToTargetSiblings(target, this.props);
+ }
+
+ removeHighlightFromToken(target) {
+ if (!target) {
+ return;
+ }
+
+ target.classList.remove("preview-token");
+ removeHighlightForTargetSiblings(target);
+ }
+
+ calculateMaxHeight = () => {
+ const { editorRef } = this.props;
+ if (!editorRef) {
+ return "auto";
+ }
+
+ const { height, top } = editorRef.getBoundingClientRect();
+ const maxHeight = height + top;
+ if (maxHeight < 250) {
+ return maxHeight;
+ }
+
+ return 250;
+ };
+
+ createElement(element) {
+ return document.createElement(element);
+ }
+
+ renderExceptionPreview(exception) {
+ return React.createElement(ExceptionPopup, {
+ exception: exception,
+ clearPreview: this.props.clearPreview,
+ });
+ }
+
+ renderPreview() {
+ const {
+ preview: { root, exception, resultGrip },
+ } = this.props;
+
+ const usesCustomFormatter =
+ root?.contents?.value?.useCustomFormatter ?? false;
+
+ if (exception) {
+ return this.renderExceptionPreview(exception);
+ }
+
+ return div(
+ {
+ className: "preview-popup",
+ style: {
+ maxHeight: this.calculateMaxHeight(),
+ },
+ },
+ React.createElement(ObjectInspector, {
+ roots: [root],
+ autoExpandDepth: 1,
+ autoReleaseObjectActors: false,
+ mode: usesCustomFormatter ? MODE.LONG : MODE.SHORT,
+ disableWrap: true,
+ displayRootNodeAsHeader: true,
+ focusable: false,
+ openLink: this.props.openLink,
+ defaultRep: Grip,
+ createElement: this.createElement,
+ onDOMNodeClick: grip => this.props.openElementInInspector(grip),
+ onInspectIconClick: grip => this.props.openElementInInspector(grip),
+ onDOMNodeMouseOver: grip => this.props.highlightDomElement(grip),
+ onDOMNodeMouseOut: grip => this.props.unHighlightDomElement(grip),
+ mayUseCustomFormatter: true,
+ onViewSourceInDebugger: () => {
+ return (
+ resultGrip.location &&
+ this.props.selectSourceURL(resultGrip.location.url, {
+ line: resultGrip.location.line,
+ column: resultGrip.location.column,
+ })
+ );
+ },
+ })
+ );
+ }
+
+ getPreviewType() {
+ const {
+ preview: { root, exception },
+ } = this.props;
+ if (exception || nodeIsPrimitive(root)) {
+ return "tooltip";
+ }
+
+ return "popover";
+ }
+
+ render() {
+ const {
+ preview: { cursorPos, resultGrip, exception },
+ editorRef,
+ } = this.props;
+
+ if (
+ !exception &&
+ (typeof resultGrip == "undefined" || resultGrip?.optimizedOut)
+ ) {
+ return null;
+ }
+
+ const type = this.getPreviewType();
+ return React.createElement(
+ Popover,
+ {
+ targetPosition: cursorPos,
+ type: type,
+ editorRef: editorRef,
+ target: this.props.preview.target,
+ mouseout: this.props.clearPreview,
+ },
+ this.renderPreview()
+ );
+ }
+}
+
+export function addHighlightToTargetSiblings(target, props) {
+ // This function searches for related tokens that should also be highlighted when previewed.
+ // Here is the process:
+ // It conducts a search on the target's next siblings and then another search for the previous siblings.
+ // If a sibling is not an element node (nodeType === 1), the highlight is not added and the search is short-circuited.
+ // If the element sibling is the same token type as the target, and is also found in the preview expression, the highlight class is added.
+
+ const tokenType = target.classList.item(0);
+ const previewExpression = props.preview.expression;
+
+ if (
+ tokenType &&
+ previewExpression &&
+ target.innerHTML !== previewExpression
+ ) {
+ let nextSibling = target.nextSibling;
+ let nextElementSibling = target.nextElementSibling;
+
+ // Note: Declaring previous/next ELEMENT siblings as well because
+ // properties like innerHTML can't be checked on nextSibling
+ // without creating a flow error even if the node is an element type.
+ while (
+ nextSibling &&
+ nextElementSibling &&
+ nextSibling.nodeType === 1 &&
+ nextElementSibling.className.includes(tokenType) &&
+ previewExpression.includes(nextElementSibling.innerHTML)
+ ) {
+ // All checks passed, add highlight and continue the search.
+ nextElementSibling.classList.add("preview-token");
+
+ nextSibling = nextSibling.nextSibling;
+ nextElementSibling = nextElementSibling.nextElementSibling;
+ }
+
+ let previousSibling = target.previousSibling;
+ let previousElementSibling = target.previousElementSibling;
+
+ while (
+ previousSibling &&
+ previousElementSibling &&
+ previousSibling.nodeType === 1 &&
+ previousElementSibling.className.includes(tokenType) &&
+ previewExpression.includes(previousElementSibling.innerHTML)
+ ) {
+ // All checks passed, add highlight and continue the search.
+ previousElementSibling.classList.add("preview-token");
+
+ previousSibling = previousSibling.previousSibling;
+ previousElementSibling = previousElementSibling.previousElementSibling;
+ }
+ }
+}
+
+export function removeHighlightForTargetSiblings(target) {
+ // Look at target's previous and next token siblings.
+ // If they also have the highlight class 'preview-token',
+ // remove that class.
+ let nextSibling = target.nextElementSibling;
+ while (nextSibling && nextSibling.className.includes("preview-token")) {
+ nextSibling.classList.remove("preview-token");
+ nextSibling = nextSibling.nextElementSibling;
+ }
+ let previousSibling = target.previousElementSibling;
+ while (
+ previousSibling &&
+ previousSibling.className.includes("preview-token")
+ ) {
+ previousSibling.classList.remove("preview-token");
+ previousSibling = previousSibling.previousElementSibling;
+ }
+}
+
+const mapDispatchToProps = {
+ addExpression: actions.addExpression,
+ selectSourceURL: actions.selectSourceURL,
+ openLink: actions.openLink,
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+};
+
+export default connect(null, mapDispatchToProps)(Popup);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/index.js b/devtools/client/debugger/src/components/Editor/Preview/index.js
new file mode 100644
index 0000000000..218d33007f
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/index.js
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import Popup from "./Popup";
+
+import { getIsCurrentThreadPaused } from "../../../selectors/index";
+import actions from "../../../actions/index";
+
+const EXCEPTION_MARKER = "mark-text-exception";
+
+class Preview extends PureComponent {
+ target = null;
+ constructor(props) {
+ super(props);
+ this.state = { selecting: false };
+ }
+
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ getExceptionPreview: PropTypes.func.isRequired,
+ getPreview: PropTypes.func,
+ };
+ }
+
+ componentDidMount() {
+ this.updateListeners();
+ }
+
+ componentWillUnmount() {
+ const { codeMirror } = this.props.editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirror.off("tokenenter", this.onTokenEnter);
+ codeMirror.off("scroll", this.onScroll);
+ codeMirrorWrapper.removeEventListener("mouseup", this.onMouseUp);
+ codeMirrorWrapper.removeEventListener("mousedown", this.onMouseDown);
+ }
+
+ updateListeners(prevProps) {
+ const { codeMirror } = this.props.editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+ codeMirror.on("tokenenter", this.onTokenEnter);
+ codeMirror.on("scroll", this.onScroll);
+ codeMirrorWrapper.addEventListener("mouseup", this.onMouseUp);
+ codeMirrorWrapper.addEventListener("mousedown", this.onMouseDown);
+ }
+
+ // Note that these events are emitted by utils/editor/tokens.js
+ onTokenEnter = async ({ target, tokenPos }) => {
+ // Use a temporary object to uniquely identify the asynchronous processing of this user event
+ // and bail out if we started hovering another token.
+ const tokenId = {};
+ this.currentTokenId = tokenId;
+
+ const { editor, getPreview, getExceptionPreview } = this.props;
+ const isTargetException = target.classList.contains(EXCEPTION_MARKER);
+
+ let preview;
+ if (isTargetException) {
+ preview = await getExceptionPreview(target, tokenPos, editor.codeMirror);
+ }
+
+ if (!preview && this.props.isPaused && !this.state.selecting) {
+ preview = await getPreview(target, tokenPos, editor.codeMirror);
+ }
+
+ // Prevent modifying state and showing this preview if we started hovering another token
+ if (!preview || this.currentTokenId !== tokenId) {
+ return;
+ }
+ this.setState({ preview });
+ };
+
+ onMouseUp = () => {
+ if (this.props.isPaused) {
+ this.setState({ selecting: false });
+ }
+ };
+
+ onMouseDown = () => {
+ if (this.props.isPaused) {
+ this.setState({ selecting: true });
+ }
+ };
+
+ onScroll = () => {
+ if (this.props.isPaused) {
+ this.clearPreview();
+ }
+ };
+
+ clearPreview = () => {
+ this.setState({ preview: null });
+ };
+
+ render() {
+ const { preview } = this.state;
+ if (!preview || this.state.selecting) {
+ return null;
+ }
+ return React.createElement(Popup, {
+ preview: preview,
+ editor: this.props.editor,
+ editorRef: this.props.editorRef,
+ clearPreview: this.clearPreview,
+ });
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ isPaused: getIsCurrentThreadPaused(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ addExpression: actions.addExpression,
+ getPreview: actions.getPreview,
+ getExceptionPreview: actions.getExceptionPreview,
+})(Preview);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/moz.build b/devtools/client/debugger/src/components/Editor/Preview/moz.build
new file mode 100644
index 0000000000..362faadc42
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "ExceptionPopup.js",
+ "index.js",
+ "Popup.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js
new file mode 100644
index 0000000000..e504c9f12c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ addHighlightToTargetSiblings,
+ removeHighlightForTargetSiblings,
+} from "../Popup";
+
+describe("addHighlightToTargetSiblings", () => {
+ it("should add preview highlight class to related target siblings", async () => {
+ const div = document.createElement("div");
+ const divChildren = ["a", "divided", "token"];
+ divChildren.forEach(function (span) {
+ const child = document.createElement("span");
+ const text = document.createTextNode(span);
+ child.appendChild(text);
+ child.classList.add("cm-property");
+ div.appendChild(child);
+ });
+
+ const target = div.children[1];
+ const props = {
+ preview: {
+ expression: "adividedtoken",
+ },
+ };
+
+ addHighlightToTargetSiblings(target, props);
+
+ const previous = target.previousElementSibling;
+ expect(previous.className.includes("preview-token")).toEqual(true);
+
+ const next = target.nextElementSibling;
+ expect(next.className.includes("preview-token")).toEqual(true);
+ });
+
+ it("should not add preview highlight class to target's related siblings after non-element nodes", () => {
+ const div = document.createElement("div");
+
+ const elementBeforePeriod = document.createElement("span");
+ elementBeforePeriod.innerHTML = "object";
+ elementBeforePeriod.classList.add("cm-property");
+ div.appendChild(elementBeforePeriod);
+
+ const period = document.createTextNode(".");
+ div.appendChild(period);
+
+ const target = document.createElement("span");
+ target.innerHTML = "property";
+ target.classList.add("cm-property");
+ div.appendChild(target);
+
+ const anotherPeriod = document.createTextNode(".");
+ div.appendChild(anotherPeriod);
+
+ const elementAfterPeriod = document.createElement("span");
+ elementAfterPeriod.innerHTML = "anotherProperty";
+ elementAfterPeriod.classList.add("cm-property");
+ div.appendChild(elementAfterPeriod);
+
+ const props = {
+ preview: {
+ expression: "object.property.anotherproperty",
+ },
+ };
+ addHighlightToTargetSiblings(target, props);
+
+ expect(elementBeforePeriod.className.includes("preview-token")).toEqual(
+ false
+ );
+ expect(elementAfterPeriod.className.includes("preview-token")).toEqual(
+ false
+ );
+ });
+});
+
+describe("removeHighlightForTargetSiblings", () => {
+ it("should remove preview highlight class from target's related siblings", async () => {
+ const div = document.createElement("div");
+ const divChildren = ["a", "divided", "token"];
+ divChildren.forEach(function (span) {
+ const child = document.createElement("span");
+ const text = document.createTextNode(span);
+ child.appendChild(text);
+ child.classList.add("preview-token");
+ div.appendChild(child);
+ });
+ const target = div.children[1];
+
+ removeHighlightForTargetSiblings(target);
+
+ const previous = target.previousElementSibling;
+ expect(previous.className.includes("preview-token")).toEqual(false);
+
+ const next = target.nextElementSibling;
+ expect(next.className.includes("preview-token")).toEqual(false);
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.css b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css
new file mode 100644
index 0000000000..fe90b2a960
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.search-bar {
+ position: relative;
+ display: flex;
+ border-top: 1px solid var(--theme-splitter-color);
+ height: var(--editor-searchbar-height);
+ transition: outline 150ms ease-out;
+}
+
+/* Display an outline on the container when the child input is focused. If another element
+ is focused (e.g. a button), we only want the outline on that element */
+.search-bar:focus-within:has(input:focus-visible) {
+ outline: var(--theme-focus-outline);
+ outline-offset: -2px;
+}
+
+.search-bar .search-outline {
+ flex-grow: 1;
+ border-width: 0;
+}
+
+/* The outline is set on .search-bar already */
+.search-bar input:focus-visible {
+ outline: none;
+}
+
+.search-bar .result-list {
+ max-height: 230px;
+}
diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.js b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js
new file mode 100644
index 0000000000..a3491a3fef
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js
@@ -0,0 +1,368 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../actions/index";
+import {
+ getActiveSearch,
+ getSelectedSource,
+ getSelectedSourceTextContent,
+ getSearchOptions,
+} from "../../selectors/index";
+
+import { searchKeys } from "../../constants";
+import { scrollList } from "../../utils/result-list";
+
+import SearchInput from "../shared/SearchInput";
+
+const { PluralForm } = require("resource://devtools/shared/plural-form.js");
+const { debounce } = require("resource://devtools/shared/debounce.js");
+import { renderWasmText } from "../../utils/wasm";
+import {
+ clearSearch,
+ find,
+ findNext,
+ findPrev,
+ removeOverlay,
+} from "../../utils/editor/index";
+import { isFulfilled } from "../../utils/async-value";
+
+function getSearchShortcut() {
+ return L10N.getStr("sourceSearch.search.key2");
+}
+
+class SearchInFileBar extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ query: "",
+ selectedResultIndex: 0,
+ results: {
+ matches: [],
+ matchIndex: -1,
+ count: 0,
+ index: -1,
+ },
+ inputFocused: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ closeFileSearch: PropTypes.func.isRequired,
+ editor: PropTypes.object,
+ modifiers: PropTypes.object.isRequired,
+ searchInFileEnabled: PropTypes.bool.isRequired,
+ selectedSourceTextContent: PropTypes.bool.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ setActiveSearch: PropTypes.func.isRequired,
+ querySearchWorker: PropTypes.func.isRequired,
+ };
+ }
+
+ componentWillUnmount() {
+ const { shortcuts } = this.context;
+
+ shortcuts.off(getSearchShortcut(), this.toggleSearch);
+ shortcuts.off("Escape", this.onEscape);
+
+ this.doSearch.cancel();
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { query } = this.state;
+ // If a new source is selected update the file search results
+ if (
+ this.props.selectedSource &&
+ nextProps.selectedSource !== this.props.selectedSource &&
+ this.props.searchInFileEnabled &&
+ query
+ ) {
+ this.doSearch(query, false);
+ }
+ }
+
+ componentDidMount() {
+ // overwrite this.doSearch with debounced version to
+ // reduce frequency of queries
+ this.doSearch = debounce(this.doSearch, 100);
+ const { shortcuts } = this.context;
+
+ shortcuts.on(getSearchShortcut(), this.toggleSearch);
+ shortcuts.on("Escape", this.onEscape);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (this.refs.resultList && this.refs.resultList.refs) {
+ scrollList(this.refs.resultList.refs, this.state.selectedResultIndex);
+ }
+ }
+
+ onEscape = e => {
+ this.closeSearch(e);
+ };
+
+ clearSearch = () => {
+ const { editor: ed } = this.props;
+ if (ed) {
+ const ctx = { ed, cm: ed.codeMirror };
+ removeOverlay(ctx, this.state.query);
+ }
+ };
+
+ closeSearch = e => {
+ const { closeFileSearch, editor, searchInFileEnabled } = this.props;
+ this.clearSearch();
+ if (editor && searchInFileEnabled) {
+ closeFileSearch();
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ this.setState({ inputFocused: false });
+ };
+
+ toggleSearch = e => {
+ e.stopPropagation();
+ e.preventDefault();
+ const { editor, searchInFileEnabled, setActiveSearch } = this.props;
+
+ // Set inputFocused to false, so that search query is highlighted whenever search shortcut is used, even if the input already has focus.
+ this.setState({ inputFocused: false });
+
+ if (!searchInFileEnabled) {
+ setActiveSearch("file");
+ }
+
+ if (searchInFileEnabled && editor) {
+ const query = editor.codeMirror.getSelection() || this.state.query;
+
+ if (query !== "") {
+ this.setState({ query, inputFocused: true });
+ this.doSearch(query);
+ } else {
+ this.setState({ query: "", inputFocused: true });
+ }
+ }
+ };
+
+ doSearch = async (query, focusFirstResult = true) => {
+ const { editor, modifiers, selectedSourceTextContent } = this.props;
+ if (
+ !editor ||
+ !selectedSourceTextContent ||
+ !isFulfilled(selectedSourceTextContent) ||
+ !modifiers
+ ) {
+ return;
+ }
+ const selectedContent = selectedSourceTextContent.value;
+
+ const ctx = { ed: editor, cm: editor.codeMirror };
+
+ if (!query) {
+ clearSearch(ctx.cm, query);
+ return;
+ }
+
+ let text;
+ if (selectedContent.type === "wasm") {
+ text = renderWasmText(this.props.selectedSource.id, selectedContent).join(
+ "\n"
+ );
+ } else {
+ text = selectedContent.value;
+ }
+
+ const matches = await this.props.querySearchWorker(query, text, modifiers);
+
+ const res = find(ctx, query, true, modifiers, focusFirstResult);
+ if (!res) {
+ return;
+ }
+
+ const { ch, line } = res;
+
+ const matchIndex = matches.findIndex(
+ elm => elm.line === line && elm.ch === ch
+ );
+ this.setState({
+ results: {
+ matches,
+ matchIndex,
+ count: matches.length,
+ index: ch,
+ },
+ });
+ };
+
+ traverseResults = (e, reverse = false) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const { editor } = this.props;
+
+ if (!editor) {
+ return;
+ }
+
+ const ctx = { ed: editor, cm: editor.codeMirror };
+
+ const { modifiers } = this.props;
+ const { query } = this.state;
+ const { matches } = this.state.results;
+
+ if (query === "" && !this.props.searchInFileEnabled) {
+ this.props.setActiveSearch("file");
+ }
+
+ if (modifiers) {
+ const findArgs = [ctx, query, true, modifiers];
+ const results = reverse ? findPrev(...findArgs) : findNext(...findArgs);
+
+ if (!results) {
+ return;
+ }
+ const { ch, line } = results;
+ const matchIndex = matches.findIndex(
+ elm => elm.line === line && elm.ch === ch
+ );
+ this.setState({
+ results: {
+ matches,
+ matchIndex,
+ count: matches.length,
+ index: ch,
+ },
+ });
+ }
+ };
+
+ // Handlers
+
+ onChange = e => {
+ this.setState({ query: e.target.value });
+
+ return this.doSearch(e.target.value);
+ };
+
+ onFocus = e => {
+ this.setState({ inputFocused: true });
+ };
+
+ onBlur = e => {
+ this.setState({ inputFocused: false });
+ };
+
+ onKeyDown = e => {
+ if (e.key !== "Enter" && e.key !== "F3") {
+ return;
+ }
+
+ this.traverseResults(e, e.shiftKey);
+ e.preventDefault();
+ this.doSearch(e.target.value);
+ };
+
+ onHistoryScroll = query => {
+ this.setState({ query });
+ this.doSearch(query);
+ };
+
+ // Renderers
+ buildSummaryMsg() {
+ const {
+ query,
+ results: { matchIndex, count, index },
+ } = this.state;
+
+ if (query.trim() == "") {
+ return "";
+ }
+
+ if (count == 0) {
+ return L10N.getStr("editor.noResultsFound");
+ }
+
+ if (index == -1) {
+ const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary1");
+ return PluralForm.get(count, resultsSummaryString).replace("#1", count);
+ }
+
+ const searchResultsString = L10N.getStr("editor.searchResults1");
+ return PluralForm.get(count, searchResultsString)
+ .replace("#1", count)
+ .replace("%d", matchIndex + 1);
+ }
+
+ shouldShowErrorEmoji() {
+ const {
+ query,
+ results: { count },
+ } = this.state;
+ return !!query && !count;
+ }
+
+ render() {
+ const { searchInFileEnabled } = this.props;
+ const {
+ results: { count },
+ } = this.state;
+
+ if (!searchInFileEnabled) {
+ return div(null);
+ }
+ return div(
+ {
+ className: "search-bar",
+ },
+ React.createElement(SearchInput, {
+ query: this.state.query,
+ count: count,
+ placeholder: L10N.getStr("sourceSearch.search.placeholder2"),
+ summaryMsg: this.buildSummaryMsg(),
+ isLoading: false,
+ onChange: this.onChange,
+ onFocus: this.onFocus,
+ onBlur: this.onBlur,
+ showErrorEmoji: this.shouldShowErrorEmoji(),
+ onKeyDown: this.onKeyDown,
+ onHistoryScroll: this.onHistoryScroll,
+ handleNext: e => this.traverseResults(e, false),
+ handlePrev: e => this.traverseResults(e, true),
+ shouldFocus: this.state.inputFocused,
+ showClose: true,
+ showExcludePatterns: false,
+ handleClose: this.closeSearch,
+ showSearchModifiers: true,
+ searchKey: searchKeys.FILE_SEARCH,
+ onToggleSearchModifier: () => this.doSearch(this.state.query),
+ })
+ );
+ }
+}
+
+SearchInFileBar.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = (state, p) => {
+ const selectedSource = getSelectedSource(state);
+
+ return {
+ searchInFileEnabled: getActiveSearch(state) === "file",
+ selectedSource,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ modifiers: getSearchOptions(state, "file-search"),
+ };
+};
+
+export default connect(mapStateToProps, {
+ setFileSearchQuery: actions.setFileSearchQuery,
+ setActiveSearch: actions.setActiveSearch,
+ closeFileSearch: actions.closeFileSearch,
+ querySearchWorker: actions.querySearchWorker,
+})(SearchInFileBar);
diff --git a/devtools/client/debugger/src/components/Editor/Tab.js b/devtools/client/debugger/src/components/Editor/Tab.js
new file mode 100644
index 0000000000..ba5e1c1934
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tab.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import { div, span } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import SourceIcon from "../shared/SourceIcon";
+import { CloseButton } from "../shared/Button/index";
+
+import actions from "../../actions/index";
+
+import {
+ getDisplayPath,
+ getFileURL,
+ getSourceQueryString,
+ getTruncatedFileName,
+ isPretty,
+} from "../../utils/source";
+import { createLocation } from "../../utils/location";
+
+import {
+ getSelectedLocation,
+ getSourcesForTabs,
+ isSourceBlackBoxed,
+} from "../../selectors/index";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class Tab extends PureComponent {
+ static get propTypes() {
+ return {
+ closeTab: PropTypes.func.isRequired,
+ onDragEnd: PropTypes.func.isRequired,
+ onDragOver: PropTypes.func.isRequired,
+ onDragStart: PropTypes.func.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ source: PropTypes.object.isRequired,
+ sourceActor: PropTypes.object.isRequired,
+ tabSources: PropTypes.array.isRequired,
+ isBlackBoxed: PropTypes.bool.isRequired,
+ };
+ }
+
+ onContextMenu = event => {
+ event.preventDefault();
+ this.props.showTabContextMenu(event, this.props.source);
+ };
+
+ isSourceSearchEnabled() {
+ return this.props.activeSearch === "source";
+ }
+
+ render() {
+ const {
+ selectSource,
+ closeTab,
+ source,
+ sourceActor,
+ tabSources,
+ onDragOver,
+ onDragStart,
+ onDragEnd,
+ index,
+ isActive,
+ } = this.props;
+ const sourceId = source.id;
+ const isPrettyCode = isPretty(source);
+
+ function onClickClose(e) {
+ e.stopPropagation();
+ closeTab(source);
+ }
+
+ function handleTabClick(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return selectSource(source, sourceActor);
+ }
+
+ const className = classnames("source-tab", {
+ active: isActive,
+ pretty: isPrettyCode,
+ blackboxed: this.props.isBlackBoxed,
+ });
+
+ const path = getDisplayPath(source, tabSources);
+ const query = getSourceQueryString(source);
+ return div(
+ {
+ draggable: true,
+ onDragOver: onDragOver,
+ onDragStart: onDragStart,
+ onDragEnd: onDragEnd,
+ className: className,
+ "data-index": index,
+ "data-source-id": sourceId,
+ onClick: handleTabClick,
+ // Accommodate middle click to close tab
+ onMouseUp: e => e.button === 1 && closeTab(source),
+ onContextMenu: this.onContextMenu,
+ title: getFileURL(source, false),
+ },
+ React.createElement(SourceIcon, {
+ location: createLocation({
+ source,
+ sourceActor,
+ }),
+ forTab: true,
+ modifier: icon => (["file", "javascript"].includes(icon) ? null : icon),
+ }),
+ div(
+ {
+ className: "filename",
+ },
+ getTruncatedFileName(source, query),
+ path && span(null, `../${path}/..`)
+ ),
+ React.createElement(CloseButton, {
+ handleClick: onClickClose,
+ tooltip: L10N.getStr("sourceTabs.closeTabButtonTooltip"),
+ })
+ );
+ }
+}
+
+const mapStateToProps = (state, { source }) => {
+ return {
+ tabSources: getSourcesForTabs(state),
+ isBlackBoxed: isSourceBlackBoxed(state, source),
+ isActive: source.id === getSelectedLocation(state)?.source.id,
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ {
+ selectSource: actions.selectSource,
+ closeTab: actions.closeTab,
+ showTabContextMenu: actions.showTabContextMenu,
+ },
+ null,
+ {
+ withRef: true,
+ }
+)(Tab);
diff --git a/devtools/client/debugger/src/components/Editor/Tabs.css b/devtools/client/debugger/src/components/Editor/Tabs.css
new file mode 100644
index 0000000000..ab70876d5e
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tabs.css
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.source-header {
+ grid-area: editor-header;
+ display: flex;
+ width: 100%;
+ height: var(--editor-header-height);
+ border-bottom: 1px solid var(--theme-splitter-color);
+ background-color: var(--theme-toolbar-background);
+}
+
+.source-header * {
+ user-select: none;
+}
+
+.source-header .command-bar {
+ flex: initial;
+ flex-shrink: 0;
+ border-bottom: 0;
+ border-inline-start: 1px solid var(--theme-splitter-color);
+}
+
+.source-tabs {
+ flex: auto;
+ align-self: flex-start;
+ /* Reserve space for the overflow button (even if not visible) */
+ padding-inline-end: 28px;
+ /* Make sure that overflowing tabs don't show through other elements (see Bug 1855458) */
+ max-height: 100%;
+ overflow: hidden;
+}
+
+.source-tab {
+ display: inline-flex;
+ align-items: center;
+ position: relative;
+ min-width: 40px;
+ max-width: 100%;
+ overflow: hidden;
+ padding: 4px 10px;
+ cursor: default;
+ height: calc(var(--editor-header-height) - 1px);
+ font-size: 12px;
+ background-color: transparent;
+ vertical-align: bottom;
+}
+
+.source-tab::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background-color: var(--tab-line-color, transparent);
+ transition: transform 250ms var(--animation-curve),
+ opacity 250ms var(--animation-curve);
+ opacity: 0;
+ transform: scaleX(0);
+}
+
+.source-tab.active {
+ --tab-line-color: var(--tab-line-selected-color);
+ color: var(--theme-toolbar-selected-color);
+ border-bottom-color: transparent;
+}
+
+.source-tab:not(.active):hover {
+ --tab-line-color: var(--tab-line-hover-color);
+ background-color: var(--theme-toolbar-hover);
+}
+
+.source-tab:hover::before,
+.source-tab.active::before {
+ opacity: 1;
+ transform: scaleX(1);
+}
+
+.source-tab .img:is(.prettyPrint,.blackBox) {
+ mask-size: 14px;
+}
+
+.source-tab .img.prettyPrint {
+ background-color: currentColor;
+}
+
+.source-tab .img.source-icon.blackBox {
+ background-color: #806414;
+}
+
+.source-tab .filename {
+ display: block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-inline-end: 4px;
+}
+
+.source-tab .filename span {
+ opacity: 0.7;
+ padding-inline-start: 4px;
+}
+
+.source-tab .close-btn {
+ visibility: hidden;
+ margin-inline-end: -6px;
+}
+
+.source-tab.active .close-btn {
+ color: inherit;
+}
+
+.source-tab.active .close-btn,
+.source-tab:hover .close-btn {
+ visibility: visible;
+}
+
+.source-tab.active .source-icon {
+ background-color: currentColor;
+}
+
+.source-tab .close-btn:hover {
+ color: var(--theme-selection-color);
+ background-color: var(--theme-selection-background);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Tabs.js b/devtools/client/debugger/src/components/Editor/Tabs.js
new file mode 100644
index 0000000000..3577a4909c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tabs.js
@@ -0,0 +1,320 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ ul,
+ li,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import {
+ getSourceTabs,
+ getSelectedSource,
+ getSourcesForTabs,
+ getIsPaused,
+ getCurrentThread,
+ getBlackBoxRanges,
+} from "../../selectors/index";
+import { isVisible } from "../../utils/ui";
+
+import { getHiddenTabs } from "../../utils/tabs";
+import { getFilename, isPretty, getFileURL } from "../../utils/source";
+import actions from "../../actions/index";
+
+import Tab from "./Tab";
+import { PaneToggleButton } from "../shared/Button/index";
+import Dropdown from "../shared/Dropdown";
+import AccessibleImage from "../shared/AccessibleImage";
+import CommandBar from "../SecondaryPanes/CommandBar";
+
+const { debounce } = require("resource://devtools/shared/debounce.js");
+
+function haveTabSourcesChanged(tabSources, prevTabSources) {
+ if (tabSources.length !== prevTabSources.length) {
+ return true;
+ }
+
+ for (let i = 0; i < tabSources.length; ++i) {
+ if (tabSources[i].id !== prevTabSources[i].id) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+class Tabs extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ dropdownShown: false,
+ hiddenTabs: [],
+ };
+
+ this.onResize = debounce(() => {
+ this.updateHiddenTabs();
+ });
+ }
+
+ static get propTypes() {
+ return {
+ endPanelCollapsed: PropTypes.bool.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ moveTab: PropTypes.func.isRequired,
+ moveTabBySourceId: PropTypes.func.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object,
+ blackBoxRanges: PropTypes.object.isRequired,
+ startPanelCollapsed: PropTypes.bool.isRequired,
+ tabSources: PropTypes.array.isRequired,
+ tabs: PropTypes.array.isRequired,
+ togglePaneCollapse: PropTypes.func.isRequired,
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ this.props.selectedSource !== prevProps.selectedSource ||
+ haveTabSourcesChanged(this.props.tabSources, prevProps.tabSources)
+ ) {
+ this.updateHiddenTabs();
+ }
+ }
+
+ componentDidMount() {
+ window.requestIdleCallback(this.updateHiddenTabs);
+ window.addEventListener("resize", this.onResize);
+ window.document
+ .querySelector(".editor-pane")
+ .addEventListener("resizeend", this.onResize);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener("resize", this.onResize);
+ window.document
+ .querySelector(".editor-pane")
+ .removeEventListener("resizeend", this.onResize);
+ }
+
+ /*
+ * Updates the hiddenSourceTabs state, by
+ * finding the source tabs which are wrapped and are not on the top row.
+ */
+ updateHiddenTabs = () => {
+ if (!this.refs.sourceTabs) {
+ return;
+ }
+ const { selectedSource, tabSources, moveTab } = this.props;
+ const sourceTabEls = this.refs.sourceTabs.children;
+ const hiddenTabs = getHiddenTabs(tabSources, sourceTabEls);
+
+ if (
+ selectedSource &&
+ isVisible() &&
+ hiddenTabs.find(tab => tab.id == selectedSource.id)
+ ) {
+ moveTab(selectedSource.url, 0);
+ return;
+ }
+
+ this.setState({ hiddenTabs });
+ };
+
+ toggleSourcesDropdown() {
+ this.setState(prevState => ({
+ dropdownShown: !prevState.dropdownShown,
+ }));
+ }
+
+ getIconClass(source) {
+ if (isPretty(source)) {
+ return "prettyPrint";
+ }
+ if (this.props.blackBoxRanges[source.url]) {
+ return "blackBox";
+ }
+ return "file";
+ }
+
+ renderDropdownSource = source => {
+ const { selectSource } = this.props;
+ const filename = getFilename(source);
+
+ const onClick = () => selectSource(source);
+ return li(
+ {
+ key: source.id,
+ onClick: onClick,
+ title: getFileURL(source, false),
+ },
+ React.createElement(AccessibleImage, {
+ className: `dropdown-icon ${this.getIconClass(source)}`,
+ }),
+ span(
+ {
+ className: "dropdown-label",
+ },
+ filename
+ )
+ );
+ };
+
+ // Note that these three listener will be called from Tab component
+ // so that e.target will be Tab's DOM (and not Tabs one).
+ onTabDragStart = e => {
+ this.draggedSourceId = e.target.dataset.sourceId;
+ this.draggedSourceIndex = e.target.dataset.index;
+ };
+
+ onTabDragEnd = () => {
+ this.draggedSourceId = null;
+ this.draggedSourceIndex = -1;
+ };
+
+ onTabDragOver = e => {
+ e.preventDefault();
+
+ const hoveredTabIndex = e.target.dataset.index;
+ const { moveTabBySourceId } = this.props;
+
+ if (hoveredTabIndex === this.draggedSourceIndex) {
+ return;
+ }
+
+ const tabDOMRect = e.target.getBoundingClientRect();
+ const { pageX: mouseCursorX } = e;
+ if (
+ /* Case: the mouse cursor moves into the left half of any target tab */
+ mouseCursorX - tabDOMRect.left <
+ tabDOMRect.width / 2
+ ) {
+ // The current tab goes to the left of the target tab
+ const targetTab =
+ hoveredTabIndex > this.draggedSourceIndex
+ ? hoveredTabIndex - 1
+ : hoveredTabIndex;
+ moveTabBySourceId(this.draggedSourceId, targetTab);
+ this.draggedSourceIndex = targetTab;
+ } else if (
+ /* Case: the mouse cursor moves into the right half of any target tab */
+ mouseCursorX - tabDOMRect.left >=
+ tabDOMRect.width / 2
+ ) {
+ // The current tab goes to the right of the target tab
+ const targetTab =
+ hoveredTabIndex < this.draggedSourceIndex
+ ? hoveredTabIndex + 1
+ : hoveredTabIndex;
+ moveTabBySourceId(this.draggedSourceId, targetTab);
+ this.draggedSourceIndex = targetTab;
+ }
+ };
+
+ renderTabs() {
+ const { tabs } = this.props;
+ if (!tabs) {
+ return null;
+ }
+ return div(
+ {
+ className: "source-tabs",
+ ref: "sourceTabs",
+ },
+ tabs.map(({ source, sourceActor }, index) => {
+ return React.createElement(Tab, {
+ onDragStart: this.onTabDragStart,
+ onDragOver: this.onTabDragOver,
+ onDragEnd: this.onTabDragEnd,
+ key: source.id + sourceActor?.id,
+ index,
+ source,
+ sourceActor,
+ });
+ })
+ );
+ }
+
+ renderDropdown() {
+ const { hiddenTabs } = this.state;
+ if (!hiddenTabs || !hiddenTabs.length) {
+ return null;
+ }
+ const panel = ul(null, hiddenTabs.map(this.renderDropdownSource));
+ const icon = React.createElement(AccessibleImage, {
+ className: "more-tabs",
+ });
+ return React.createElement(Dropdown, {
+ panel,
+ icon,
+ });
+ }
+
+ renderCommandBar() {
+ const { horizontal, endPanelCollapsed, isPaused } = this.props;
+ if (!endPanelCollapsed || !isPaused) {
+ return null;
+ }
+ return React.createElement(CommandBar, {
+ horizontal,
+ });
+ }
+
+ renderStartPanelToggleButton() {
+ return React.createElement(PaneToggleButton, {
+ position: "start",
+ collapsed: this.props.startPanelCollapsed,
+ handleClick: this.props.togglePaneCollapse,
+ });
+ }
+
+ renderEndPanelToggleButton() {
+ const { horizontal, endPanelCollapsed, togglePaneCollapse } = this.props;
+ if (!horizontal) {
+ return null;
+ }
+ return React.createElement(PaneToggleButton, {
+ position: "end",
+ collapsed: endPanelCollapsed,
+ handleClick: togglePaneCollapse,
+ horizontal,
+ });
+ }
+
+ render() {
+ return div(
+ {
+ className: "source-header",
+ },
+ this.renderStartPanelToggleButton(),
+ this.renderTabs(),
+ this.renderDropdown(),
+ this.renderEndPanelToggleButton(),
+ this.renderCommandBar()
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ selectedSource: getSelectedSource(state),
+ tabSources: getSourcesForTabs(state),
+ tabs: getSourceTabs(state),
+ blackBoxRanges: getBlackBoxRanges(state),
+ isPaused: getIsPaused(state, getCurrentThread(state)),
+ };
+};
+
+export default connect(mapStateToProps, {
+ selectSource: actions.selectSource,
+ moveTab: actions.moveTab,
+ moveTabBySourceId: actions.moveTabBySourceId,
+ closeTab: actions.closeTab,
+ togglePaneCollapse: actions.togglePaneCollapse,
+ showSource: actions.showSource,
+})(Tabs);
diff --git a/devtools/client/debugger/src/components/Editor/index.js b/devtools/client/debugger/src/components/Editor/index.js
new file mode 100644
index 0000000000..c659de77d2
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/index.js
@@ -0,0 +1,795 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import { bindActionCreators } from "devtools/client/shared/vendor/redux";
+import ReactDOM from "devtools/client/shared/vendor/react-dom";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import { getLineText, isLineBlackboxed } from "./../../utils/source";
+import { createLocation } from "./../../utils/location";
+import { getIndentation } from "../../utils/indentation";
+
+import {
+ getActiveSearch,
+ getSelectedLocation,
+ getSelectedSource,
+ getSelectedSourceTextContent,
+ getSelectedBreakableLines,
+ getConditionalPanelLocation,
+ getSymbols,
+ getIsCurrentThreadPaused,
+ getSkipPausing,
+ getInlinePreview,
+ getBlackBoxRanges,
+ isSourceBlackBoxed,
+ getHighlightedLineRangeForSelectedSource,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+ isMapScopesEnabled,
+} from "../../selectors/index";
+
+// Redux actions
+import actions from "../../actions/index";
+
+import SearchInFileBar from "./SearchInFileBar";
+import HighlightLines from "./HighlightLines";
+import Preview from "./Preview/index";
+import Breakpoints from "./Breakpoints";
+import ColumnBreakpoints from "./ColumnBreakpoints";
+import DebugLine from "./DebugLine";
+import HighlightLine from "./HighlightLine";
+import EmptyLines from "./EmptyLines";
+import ConditionalPanel from "./ConditionalPanel";
+import InlinePreviews from "./InlinePreviews";
+import Exceptions from "./Exceptions";
+import BlackboxLines from "./BlackboxLines";
+
+import {
+ showSourceText,
+ showLoading,
+ showErrorMessage,
+ getEditor,
+ clearEditor,
+ getCursorLine,
+ getCursorColumn,
+ lineAtHeight,
+ toSourceLine,
+ getDocument,
+ scrollToPosition,
+ toEditorPosition,
+ getSourceLocationFromMouseEvent,
+ hasDocument,
+ onMouseOver,
+ startOperation,
+ endOperation,
+} from "../../utils/editor/index";
+
+import { resizeToggleButton, resizeBreakpointGutter } from "../../utils/ui";
+
+const { debounce } = require("resource://devtools/shared/debounce.js");
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+const { appinfo } = Services;
+const isMacOS = appinfo.OS === "Darwin";
+
+function isSecondary(ev) {
+ return isMacOS && ev.ctrlKey && ev.button === 0;
+}
+
+function isCmd(ev) {
+ return isMacOS ? ev.metaKey : ev.ctrlKey;
+}
+
+const cssVars = {
+ searchbarHeight: "var(--editor-searchbar-height)",
+};
+
+class Editor extends PureComponent {
+ static get propTypes() {
+ return {
+ selectedSource: PropTypes.object,
+ selectedSourceTextContent: PropTypes.object,
+ selectedSourceIsBlackBoxed: PropTypes.bool,
+ closeTab: PropTypes.func.isRequired,
+ toggleBreakpointAtLine: PropTypes.func.isRequired,
+ conditionalPanelLocation: PropTypes.object,
+ closeConditionalPanel: PropTypes.func.isRequired,
+ openConditionalPanel: PropTypes.func.isRequired,
+ updateViewport: PropTypes.func.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ addBreakpointAtLine: PropTypes.func.isRequired,
+ continueToHere: PropTypes.func.isRequired,
+ updateCursorPosition: PropTypes.func.isRequired,
+ jumpToMappedLocation: PropTypes.func.isRequired,
+ selectedLocation: PropTypes.object,
+ symbols: PropTypes.object,
+ startPanelSize: PropTypes.number.isRequired,
+ endPanelSize: PropTypes.number.isRequired,
+ searchInFileEnabled: PropTypes.bool.isRequired,
+ inlinePreviewEnabled: PropTypes.bool.isRequired,
+ skipPausing: PropTypes.bool.isRequired,
+ blackboxedRanges: PropTypes.object.isRequired,
+ breakableLines: PropTypes.object.isRequired,
+ highlightedLineRange: PropTypes.object,
+ isSourceOnIgnoreList: PropTypes.bool,
+ mapScopesEnabled: PropTypes.bool,
+ };
+ }
+
+ $editorWrapper;
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ editor: null,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ let { editor } = this.state;
+
+ if (!editor && nextProps.selectedSource) {
+ editor = this.setupEditor();
+ }
+
+ const shouldUpdateText =
+ nextProps.selectedSource !== this.props.selectedSource ||
+ nextProps.selectedSourceTextContent?.value !==
+ this.props.selectedSourceTextContent?.value ||
+ nextProps.symbols !== this.props.symbols;
+
+ const shouldUpdateSize =
+ nextProps.startPanelSize !== this.props.startPanelSize ||
+ nextProps.endPanelSize !== this.props.endPanelSize;
+
+ const shouldScroll =
+ nextProps.selectedLocation &&
+ this.shouldScrollToLocation(nextProps, editor);
+
+ if (shouldUpdateText || shouldUpdateSize || shouldScroll) {
+ startOperation();
+ if (shouldUpdateText) {
+ this.setText(nextProps, editor);
+ }
+ if (shouldUpdateSize) {
+ editor.codeMirror.setSize();
+ }
+ if (shouldScroll) {
+ this.scrollToLocation(nextProps, editor);
+ }
+ endOperation();
+ }
+
+ if (this.props.selectedSource != nextProps.selectedSource) {
+ this.props.updateViewport();
+ resizeBreakpointGutter(editor.codeMirror);
+ resizeToggleButton(editor.codeMirror);
+ }
+ }
+
+ setupEditor() {
+ const editor = getEditor();
+
+ // disables the default search shortcuts
+ editor._initShortcuts = () => {};
+
+ const node = ReactDOM.findDOMNode(this);
+ if (node instanceof HTMLElement) {
+ editor.appendToLocalElement(node.querySelector(".editor-mount"));
+ }
+
+ const { codeMirror } = editor;
+
+ this.abortController = new window.AbortController();
+
+ // CodeMirror refreshes its internal state on window resize, but we need to also
+ // refresh it when the side panels are resized.
+ // We could have a ResizeObserver instead, but we wouldn't be able to differentiate
+ // between window resize and side panel resize and as a result, might refresh
+ // codeMirror twice, which is wasteful.
+ window.document
+ .querySelector(".editor-pane")
+ .addEventListener("resizeend", () => codeMirror.refresh(), {
+ signal: this.abortController.signal,
+ });
+
+ codeMirror.on("gutterClick", this.onGutterClick);
+ codeMirror.on("cursorActivity", this.onCursorChange);
+
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+ // Set code editor wrapper to be focusable
+ codeMirrorWrapper.tabIndex = 0;
+ codeMirrorWrapper.addEventListener("keydown", e => this.onKeyDown(e));
+ codeMirrorWrapper.addEventListener("click", e => this.onClick(e));
+ codeMirrorWrapper.addEventListener("mouseover", onMouseOver(codeMirror));
+
+ const toggleFoldMarkerVisibility = e => {
+ if (node instanceof HTMLElement) {
+ node
+ .querySelectorAll(".CodeMirror-guttermarker-subtle")
+ .forEach(elem => {
+ elem.classList.toggle("visible");
+ });
+ }
+ };
+
+ const codeMirrorGutter = codeMirror.getGutterElement();
+ codeMirrorGutter.addEventListener("mouseleave", toggleFoldMarkerVisibility);
+ codeMirrorGutter.addEventListener("mouseenter", toggleFoldMarkerVisibility);
+ codeMirrorWrapper.addEventListener("contextmenu", event =>
+ this.openMenu(event)
+ );
+
+ codeMirror.on("scroll", this.onEditorScroll);
+ this.onEditorScroll();
+ this.setState({ editor });
+ return editor;
+ }
+
+ componentDidMount() {
+ const { shortcuts } = this.context;
+
+ shortcuts.on(L10N.getStr("toggleBreakpoint.key"), this.onToggleBreakpoint);
+ shortcuts.on(
+ L10N.getStr("toggleCondPanel.breakpoint.key"),
+ this.onToggleConditionalPanel
+ );
+ shortcuts.on(
+ L10N.getStr("toggleCondPanel.logPoint.key"),
+ this.onToggleConditionalPanel
+ );
+ shortcuts.on(
+ L10N.getStr("sourceTabs.closeTab.key"),
+ this.onCloseShortcutPress
+ );
+ shortcuts.on("Esc", this.onEscape);
+ }
+
+ onCloseShortcutPress = e => {
+ const { selectedSource } = this.props;
+ if (selectedSource) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.closeTab(selectedSource, "shortcut");
+ }
+ };
+
+ componentWillUnmount() {
+ const { editor } = this.state;
+ if (editor) {
+ editor.destroy();
+ editor.codeMirror.off("scroll", this.onEditorScroll);
+ this.setState({ editor: null });
+ }
+
+ const { shortcuts } = this.context;
+ shortcuts.off(L10N.getStr("sourceTabs.closeTab.key"));
+ shortcuts.off(L10N.getStr("toggleBreakpoint.key"));
+ shortcuts.off(L10N.getStr("toggleCondPanel.breakpoint.key"));
+ shortcuts.off(L10N.getStr("toggleCondPanel.logPoint.key"));
+
+ if (this.abortController) {
+ this.abortController.abort();
+ this.abortController = null;
+ }
+ }
+
+ getCurrentLine() {
+ const { codeMirror } = this.state.editor;
+ const { selectedSource } = this.props;
+ if (!selectedSource) {
+ return null;
+ }
+
+ const line = getCursorLine(codeMirror);
+ return toSourceLine(selectedSource.id, line);
+ }
+
+ onToggleBreakpoint = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const line = this.getCurrentLine();
+ if (typeof line !== "number") {
+ return;
+ }
+
+ this.props.toggleBreakpointAtLine(line);
+ };
+
+ onToggleConditionalPanel = e => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const {
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ openConditionalPanel,
+ selectedSource,
+ } = this.props;
+
+ const line = this.getCurrentLine();
+
+ const { codeMirror } = this.state.editor;
+ // add one to column for correct position in editor.
+ const column = getCursorColumn(codeMirror) + 1;
+
+ if (conditionalPanelLocation) {
+ return closeConditionalPanel();
+ }
+
+ if (!selectedSource || typeof line !== "number") {
+ return null;
+ }
+
+ return openConditionalPanel(
+ createLocation({
+ line,
+ column,
+ source: selectedSource,
+ }),
+ false
+ );
+ };
+
+ onEditorScroll = debounce(this.props.updateViewport, 75);
+
+ onKeyDown(e) {
+ const { codeMirror } = this.state.editor;
+ const { key, target } = e;
+ const codeWrapper = codeMirror.getWrapperElement();
+ const textArea = codeWrapper.querySelector("textArea");
+
+ if (key === "Escape" && target == textArea) {
+ e.stopPropagation();
+ e.preventDefault();
+ codeWrapper.focus();
+ } else if (key === "Enter" && target == codeWrapper) {
+ e.preventDefault();
+ // Focus into editor's text area
+ textArea.focus();
+ }
+ }
+
+ /*
+ * The default Esc command is overridden in the CodeMirror keymap to allow
+ * the Esc keypress event to be catched by the toolbox and trigger the
+ * split console. Restore it here, but preventDefault if and only if there
+ * is a multiselection.
+ */
+ onEscape = e => {
+ if (!this.state.editor) {
+ return;
+ }
+
+ const { codeMirror } = this.state.editor;
+ if (codeMirror.listSelections().length > 1) {
+ codeMirror.execCommand("singleSelection");
+ e.preventDefault();
+ }
+ };
+
+ openMenu(event) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const {
+ selectedSource,
+ selectedSourceTextContent,
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ } = this.props;
+
+ const { editor } = this.state;
+
+ if (!selectedSource || !editor) {
+ return;
+ }
+
+ // only allow one conditionalPanel location.
+ if (conditionalPanelLocation) {
+ closeConditionalPanel();
+ }
+
+ const target = event.target;
+ const { id: sourceId } = selectedSource;
+ const line = lineAtHeight(editor, sourceId, event);
+
+ if (typeof line != "number") {
+ return;
+ }
+
+ if (target.classList.contains("CodeMirror-linenumber")) {
+ const location = createLocation({
+ line,
+ column: undefined,
+ source: selectedSource,
+ });
+
+ const lineText = getLineText(
+ sourceId,
+ selectedSourceTextContent,
+ line
+ ).trim();
+
+ this.props.showEditorGutterContextMenu(event, editor, location, lineText);
+ return;
+ }
+
+ if (target.getAttribute("id") === "columnmarker") {
+ return;
+ }
+
+ const location = getSourceLocationFromMouseEvent(
+ editor,
+ selectedSource,
+ event
+ );
+
+ this.props.showEditorContextMenu(event, editor, location);
+ }
+
+ /**
+ * CodeMirror event handler, called whenever the cursor moves
+ * for user-driven or programatic reasons.
+ */
+ onCursorChange = event => {
+ const { line, ch } = event.doc.getCursor();
+ this.props.selectLocation(
+ createLocation({
+ source: this.props.selectedSource,
+ // CodeMirror cursor location is all 0-based.
+ // Whereast in DevTools frontend and backend,
+ // only colunm is 0-based, the line is 1 based.
+ line: line + 1,
+ column: ch,
+ }),
+ {
+ // Reset the context, so that we don't switch to original
+ // while moving the cursor within a bundle
+ keepContext: false,
+
+ // Avoid highlighting the selected line
+ highlight: false,
+ }
+ );
+ };
+
+ onGutterClick = (cm, line, gutter, ev) => {
+ const {
+ selectedSource,
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ addBreakpointAtLine,
+ continueToHere,
+ breakableLines,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ // ignore right clicks in the gutter
+ if (isSecondary(ev) || ev.button === 2 || !selectedSource) {
+ return;
+ }
+
+ if (conditionalPanelLocation) {
+ closeConditionalPanel();
+ return;
+ }
+
+ if (gutter === "CodeMirror-foldgutter") {
+ return;
+ }
+
+ const sourceLine = toSourceLine(selectedSource.id, line);
+ if (typeof sourceLine !== "number") {
+ return;
+ }
+
+ // ignore clicks on a non-breakable line
+ if (!breakableLines.has(sourceLine)) {
+ return;
+ }
+
+ if (isCmd(ev)) {
+ continueToHere(
+ createLocation({
+ line: sourceLine,
+ column: undefined,
+ source: selectedSource,
+ })
+ );
+ return;
+ }
+
+ addBreakpointAtLine(
+ sourceLine,
+ ev.altKey,
+ ev.shiftKey ||
+ isLineBlackboxed(
+ blackboxedRanges[selectedSource.url],
+ sourceLine,
+ isSourceOnIgnoreList
+ )
+ );
+ };
+
+ onGutterContextMenu = event => {
+ this.openMenu(event);
+ };
+
+ onClick(e) {
+ const { selectedSource, updateCursorPosition, jumpToMappedLocation } =
+ this.props;
+
+ if (selectedSource) {
+ const sourceLocation = getSourceLocationFromMouseEvent(
+ this.state.editor,
+ selectedSource,
+ e
+ );
+
+ if (e.metaKey && e.altKey) {
+ jumpToMappedLocation(sourceLocation);
+ }
+
+ updateCursorPosition(sourceLocation);
+ }
+ }
+
+ shouldScrollToLocation(nextProps, editor) {
+ if (
+ !nextProps.selectedLocation?.line ||
+ !nextProps.selectedSourceTextContent
+ ) {
+ return false;
+ }
+
+ const { selectedLocation, selectedSourceTextContent } = this.props;
+ const contentChanged =
+ !selectedSourceTextContent?.value &&
+ nextProps.selectedSourceTextContent?.value;
+ const locationChanged = selectedLocation !== nextProps.selectedLocation;
+ const symbolsChanged = nextProps.symbols != this.props.symbols;
+
+ return contentChanged || locationChanged || symbolsChanged;
+ }
+
+ scrollToLocation(nextProps, editor) {
+ const { selectedLocation, selectedSource } = nextProps;
+
+ let { line, column } = toEditorPosition(selectedLocation);
+
+ if (selectedSource && hasDocument(selectedSource.id)) {
+ const doc = getDocument(selectedSource.id);
+ const lineText = doc.getLine(line);
+ column = Math.max(column, getIndentation(lineText));
+ }
+
+ scrollToPosition(editor.codeMirror, line, column);
+ }
+
+ setText(props, editor) {
+ const { selectedSource, selectedSourceTextContent, symbols } = props;
+
+ if (!editor) {
+ return;
+ }
+
+ // check if we previously had a selected source
+ if (!selectedSource) {
+ this.clearEditor();
+ return;
+ }
+
+ if (!selectedSourceTextContent?.value) {
+ showLoading(editor);
+ return;
+ }
+
+ if (selectedSourceTextContent.state === "rejected") {
+ let { value } = selectedSourceTextContent;
+ if (typeof value !== "string") {
+ value = "Unexpected source error";
+ }
+
+ this.showErrorMessage(value);
+ return;
+ }
+
+ showSourceText(editor, selectedSource, selectedSourceTextContent, symbols);
+ }
+
+ clearEditor() {
+ const { editor } = this.state;
+ if (!editor) {
+ return;
+ }
+
+ clearEditor(editor);
+ }
+
+ showErrorMessage(msg) {
+ const { editor } = this.state;
+ if (!editor) {
+ return;
+ }
+
+ showErrorMessage(editor, msg);
+ }
+
+ getInlineEditorStyles() {
+ const { searchInFileEnabled } = this.props;
+
+ if (searchInFileEnabled) {
+ return {
+ height: `calc(100% - ${cssVars.searchbarHeight})`,
+ };
+ }
+
+ return {
+ height: "100%",
+ };
+ }
+
+ renderItems() {
+ const {
+ selectedSource,
+ conditionalPanelLocation,
+ isPaused,
+ inlinePreviewEnabled,
+ highlightedLineRange,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ selectedSourceIsBlackBoxed,
+ mapScopesEnabled,
+ } = this.props;
+ const { editor } = this.state;
+
+ if (!selectedSource || !editor || !getDocument(selectedSource.id)) {
+ return null;
+ }
+ return div(
+ null,
+ React.createElement(DebugLine, null),
+ React.createElement(HighlightLine, null),
+ React.createElement(EmptyLines, {
+ editor,
+ }),
+ React.createElement(Breakpoints, {
+ editor,
+ }),
+ isPaused &&
+ selectedSource.isOriginal &&
+ !selectedSource.isPrettyPrinted &&
+ !mapScopesEnabled
+ ? null
+ : React.createElement(Preview, {
+ editor,
+ editorRef: this.$editorWrapper,
+ }),
+ highlightedLineRange
+ ? React.createElement(HighlightLines, {
+ editor,
+ range: highlightedLineRange,
+ })
+ : null,
+ isSourceOnIgnoreList || selectedSourceIsBlackBoxed
+ ? React.createElement(BlackboxLines, {
+ editor,
+ selectedSource,
+ isSourceOnIgnoreList,
+ blackboxedRangesForSelectedSource:
+ blackboxedRanges[selectedSource.url],
+ })
+ : null,
+ React.createElement(Exceptions, null),
+ conditionalPanelLocation
+ ? React.createElement(ConditionalPanel, {
+ editor,
+ })
+ : null,
+ React.createElement(ColumnBreakpoints, {
+ editor,
+ }),
+ isPaused &&
+ inlinePreviewEnabled &&
+ (!selectedSource.isOriginal ||
+ (selectedSource.isOriginal && selectedSource.isPrettyPrinted) ||
+ (selectedSource.isOriginal && mapScopesEnabled))
+ ? React.createElement(InlinePreviews, {
+ editor,
+ selectedSource,
+ })
+ : null
+ );
+ }
+
+ renderSearchInFileBar() {
+ if (!this.props.selectedSource) {
+ return null;
+ }
+ return React.createElement(SearchInFileBar, {
+ editor: this.state.editor,
+ });
+ }
+
+ render() {
+ const { selectedSourceIsBlackBoxed, skipPausing } = this.props;
+ return div(
+ {
+ className: classnames("editor-wrapper", {
+ blackboxed: selectedSourceIsBlackBoxed,
+ "skip-pausing": skipPausing,
+ }),
+ ref: c => (this.$editorWrapper = c),
+ },
+ div({
+ className: "editor-mount devtools-monospace",
+ style: this.getInlineEditorStyles(),
+ }),
+ this.renderSearchInFileBar(),
+ this.renderItems()
+ );
+ }
+}
+
+Editor.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ const selectedLocation = getSelectedLocation(state);
+
+ return {
+ selectedLocation,
+ selectedSource,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ selectedSourceIsBlackBoxed: selectedSource
+ ? isSourceBlackBoxed(state, selectedSource)
+ : null,
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, selectedSource),
+ searchInFileEnabled: getActiveSearch(state) === "file",
+ conditionalPanelLocation: getConditionalPanelLocation(state),
+ symbols: getSymbols(state, selectedLocation),
+ isPaused: getIsCurrentThreadPaused(state),
+ skipPausing: getSkipPausing(state),
+ inlinePreviewEnabled: getInlinePreview(state),
+ blackboxedRanges: getBlackBoxRanges(state),
+ breakableLines: getSelectedBreakableLines(state),
+ highlightedLineRange: getHighlightedLineRangeForSelectedSource(state),
+ mapScopesEnabled: selectedSource?.isOriginal
+ ? isMapScopesEnabled(state)
+ : null,
+ };
+};
+
+const mapDispatchToProps = dispatch => ({
+ ...bindActionCreators(
+ {
+ openConditionalPanel: actions.openConditionalPanel,
+ closeConditionalPanel: actions.closeConditionalPanel,
+ continueToHere: actions.continueToHere,
+ toggleBreakpointAtLine: actions.toggleBreakpointAtLine,
+ addBreakpointAtLine: actions.addBreakpointAtLine,
+ jumpToMappedLocation: actions.jumpToMappedLocation,
+ updateViewport: actions.updateViewport,
+ updateCursorPosition: actions.updateCursorPosition,
+ closeTab: actions.closeTab,
+ showEditorContextMenu: actions.showEditorContextMenu,
+ showEditorGutterContextMenu: actions.showEditorGutterContextMenu,
+ selectLocation: actions.selectLocation,
+ },
+ dispatch
+ ),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Editor);
diff --git a/devtools/client/debugger/src/components/Editor/moz.build b/devtools/client/debugger/src/components/Editor/moz.build
new file mode 100644
index 0000000000..909e57d4eb
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/moz.build
@@ -0,0 +1,31 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "Preview",
+]
+
+CompiledModules(
+ "BlackboxLines.js",
+ "Breakpoint.js",
+ "Breakpoints.js",
+ "ColumnBreakpoint.js",
+ "ColumnBreakpoints.js",
+ "ConditionalPanel.js",
+ "DebugLine.js",
+ "EmptyLines.js",
+ "Exception.js",
+ "Exceptions.js",
+ "Footer.js",
+ "HighlightLine.js",
+ "HighlightLines.js",
+ "index.js",
+ "InlinePreview.js",
+ "InlinePreviewRow.js",
+ "InlinePreviews.js",
+ "SearchInFileBar.js",
+ "Tab.js",
+ "Tabs.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js
new file mode 100644
index 0000000000..41024081ca
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import Breakpoints from "../Breakpoints";
+
+const BreakpointsComponent = Breakpoints.WrappedComponent;
+
+function generateDefaults(overrides) {
+ const sourceId = "server1.conn1.child1/source1";
+ const matchingBreakpoints = [{ location: { source: { id: sourceId } } }];
+
+ return {
+ selectedSource: { sourceId, get: () => false },
+ editor: {
+ codeMirror: {
+ setGutterMarker: jest.fn(),
+ },
+ },
+ blackboxedRanges: {},
+ breakpointActions: {},
+ editorActions: {},
+ breakpoints: matchingBreakpoints,
+ ...overrides,
+ };
+}
+
+function render(overrides = {}) {
+ const props = generateDefaults(overrides);
+ const component = shallow(React.createElement(BreakpointsComponent, props));
+ return { component, props };
+}
+
+describe("Breakpoints Component", () => {
+ it("should render breakpoints without columns", async () => {
+ const sourceId = "server1.conn1.child1/source1";
+ const breakpoints = [{ location: { source: { id: sourceId } } }];
+
+ const { component, props } = render({ breakpoints });
+ expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length);
+ });
+
+ it("should render breakpoints with columns", async () => {
+ const sourceId = "server1.conn1.child1/source1";
+ const breakpoints = [{ location: { column: 2, source: { id: sourceId } } }];
+
+ const { component, props } = render({ breakpoints });
+ expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length);
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js
new file mode 100644
index 0000000000..f0adb096c4
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js
@@ -0,0 +1,77 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { mount } from "enzyme";
+import { ConditionalPanel } from "../ConditionalPanel";
+import * as mocks from "../../../utils/test-mockup";
+
+const source = mocks.makeMockSource();
+
+function generateDefaults(overrides, log, line, column, condition, logValue) {
+ const breakpoint = mocks.makeMockBreakpoint(source, line, column);
+ breakpoint.options.condition = condition;
+ breakpoint.options.logValue = logValue;
+
+ return {
+ editor: {
+ CodeMirror: {
+ fromTextArea: jest.fn(() => {
+ return {
+ on: jest.fn(),
+ getWrapperElement: jest.fn(() => {
+ return {
+ addEventListener: jest.fn(),
+ };
+ }),
+ focus: jest.fn(),
+ setCursor: jest.fn(),
+ lineCount: jest.fn(),
+ };
+ }),
+ },
+ codeMirror: {
+ addLineWidget: jest.fn(),
+ },
+ },
+ location: breakpoint.location,
+ source,
+ breakpoint,
+ log,
+ getDefaultValue: jest.fn(),
+ openConditionalPanel: jest.fn(),
+ closeConditionalPanel: jest.fn(),
+ ...overrides,
+ };
+}
+
+function render(log, line, column, condition, logValue, overrides = {}) {
+ const defaults = generateDefaults(
+ overrides,
+ log,
+ line,
+ column,
+ condition,
+ logValue
+ );
+ const props = { ...defaults, ...overrides };
+ const wrapper = mount(React.createElement(ConditionalPanel, props));
+ return { wrapper, props };
+}
+
+describe("ConditionalPanel", () => {
+ it("should render at location of selected breakpoint", () => {
+ const { wrapper } = render(false, 2, 2);
+ expect(wrapper).toMatchSnapshot();
+ });
+ it("should render with condition at selected breakpoint location", () => {
+ const { wrapper } = render(false, 3, 3, "I'm a condition", "not a log");
+ expect(wrapper).toMatchSnapshot();
+ });
+ it("should render with logpoint at selected breakpoint location", () => {
+ const { wrapper } = render(true, 4, 4, "not a condition", "I'm a log");
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js
new file mode 100644
index 0000000000..767dde9e6d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js
@@ -0,0 +1,88 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import DebugLine from "../DebugLine";
+
+import { setDocument } from "../../../utils/editor";
+
+function createMockDocument(clear) {
+ const doc = {
+ addLineClass: jest.fn(),
+ removeLineClass: jest.fn(),
+ markText: jest.fn(() => ({ clear })),
+ getLine: line => "",
+ };
+
+ return doc;
+}
+
+function generateDefaults(editor, overrides) {
+ return {
+ editor,
+ pauseInfo: {
+ why: { type: "breakpoint" },
+ },
+ frame: null,
+ sourceTextContent: null,
+ ...overrides,
+ };
+}
+
+function createLocation(line) {
+ return {
+ source: {
+ id: "foo",
+ },
+ sourceId: "foo",
+ line,
+ column: 2,
+ };
+}
+
+function render(overrides = {}) {
+ const clear = jest.fn();
+ const editor = { codeMirror: {} };
+ const props = generateDefaults(editor, overrides);
+
+ const doc = createMockDocument(clear);
+ setDocument("foo", doc);
+
+ const component = shallow(
+ React.createElement(DebugLine.WrappedComponent, props),
+ {
+ lifecycleExperimental: true,
+ }
+ );
+ return { component, props, clear, editor, doc };
+}
+
+describe("DebugLine Component", () => {
+ describe("pausing at the first location", () => {
+ describe("when there is no selected frame", () => {
+ it("should not set the debug line", () => {
+ const { component, props, doc } = render({ frame: null });
+ const line = 2;
+ const location = createLocation(line);
+
+ component.setProps({ ...props, location });
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when there is a different source", () => {
+ it("should not set the debug line", async () => {
+ const { component, doc } = render();
+ const newSelectedFrame = { location: { sourceId: "bar" } };
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+
+ component.setProps({ frame: newSelectedFrame });
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js
new file mode 100644
index 0000000000..c132a28aa3
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js
@@ -0,0 +1,70 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import SourceFooter from "../Footer";
+import { createSourceObject } from "../../../utils/test-head";
+import { setDocument } from "../../../utils/editor";
+
+function createMockDocument(clear, position) {
+ const doc = {
+ getCursor: jest.fn(() => position),
+ };
+ return doc;
+}
+
+function generateDefaults(overrides) {
+ return {
+ editor: {
+ codeMirror: {
+ doc: {},
+ cursorActivity: jest.fn(),
+ on: jest.fn(),
+ },
+ },
+ endPanelCollapsed: false,
+ selectedSource: {
+ ...createSourceObject("foo"),
+ content: null,
+ },
+ ...overrides,
+ };
+}
+
+function render(overrides = {}, position = { line: 0, column: 0 }) {
+ const clear = jest.fn();
+ const props = generateDefaults(overrides);
+
+ const doc = createMockDocument(clear, position);
+ setDocument(props.selectedSource.id, doc);
+
+ const component = shallow(
+ React.createElement(SourceFooter.WrappedComponent, props),
+ {
+ lifecycleExperimental: true,
+ }
+ );
+ return { component, props, clear, doc };
+}
+
+describe("SourceFooter Component", () => {
+ describe("default case", () => {
+ it("should render", () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+ });
+
+ describe("move cursor", () => {
+ it("should render new cursor position", () => {
+ const { component } = render();
+ component.setState({ cursorPosition: { line: 5, column: 10 } });
+
+ expect(component).toMatchSnapshot();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap
new file mode 100644
index 0000000000..a2e47aae58
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap
@@ -0,0 +1,32 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Breakpoints Component should render breakpoints with columns 1`] = `
+<div>
+ <Breakpoint
+ breakpoint={
+ Object {
+ "location": Object {
+ "column": 2,
+ "source": Object {
+ "id": "server1.conn1.child1/source1",
+ },
+ },
+ }
+ }
+ editor={
+ Object {
+ "codeMirror": Object {
+ "setGutterMarker": [MockFunction],
+ },
+ }
+ }
+ key="server1.conn1.child1/source1:undefined:2"
+ selectedSource={
+ Object {
+ "get": [Function],
+ "sourceId": "server1.conn1.child1/source1",
+ }
+ }
+ />
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap
new file mode 100644
index 0000000000..58e86f5009
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap
@@ -0,0 +1,747 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ConditionalPanel should render at location of selected breakpoint 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "options": Object {
+ "condition": undefined,
+ "logValue": undefined,
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea />,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Breakpoint condition, e.g. items.length > 0",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 1,
+ <div>
+ <div
+ class="conditional-breakpoint-panel"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea />
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ }
+ }
+ log={false}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
+
+exports[`ConditionalPanel should render with condition at selected breakpoint location 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "options": Object {
+ "condition": "I'm a condition",
+ "logValue": "not a log",
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea>
+ I'm a condition
+ </textarea>,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Breakpoint condition, e.g. items.length > 0",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 2,
+ <div>
+ <div
+ class="conditional-breakpoint-panel"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea>
+ I'm a condition
+ </textarea>
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ }
+ }
+ log={false}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
+
+exports[`ConditionalPanel should render with logpoint at selected breakpoint location 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "options": Object {
+ "condition": "not a condition",
+ "logValue": "I'm a log",
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea>
+ I'm a log
+ </textarea>,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Log message, e.g. displayName",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 3,
+ <div>
+ <div
+ class="conditional-breakpoint-panel log-point"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea>
+ I'm a log
+ </textarea>
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ }
+ }
+ log={true}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap
new file mode 100644
index 0000000000..a453b034ff
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap
@@ -0,0 +1,93 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SourceFooter Component default case should render 1`] = `
+<div
+ className="source-footer"
+>
+ <div
+ className="source-footer-start"
+ >
+ <div
+ className="commands"
+ >
+ <button
+ aria-label="Ignore source"
+ className="action black-box"
+ key="black-box"
+ onClick={[Function]}
+ title="Ignore source"
+ >
+ <AccessibleImage
+ className="blackBox"
+ />
+ </button>
+ <button
+ className="action prettyPrint"
+ disabled={true}
+ key="prettyPrint"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="prettyPrint"
+ />
+ </button>
+ </div>
+ </div>
+ <div
+ className="source-footer-end"
+ >
+ <PaneToggleButton
+ collapsed={false}
+ horizontal={false}
+ key="toggle"
+ position="end"
+ />
+ </div>
+</div>
+`;
+
+exports[`SourceFooter Component move cursor should render new cursor position 1`] = `
+<div
+ className="source-footer"
+>
+ <div
+ className="source-footer-start"
+ >
+ <div
+ className="commands"
+ >
+ <button
+ aria-label="Ignore source"
+ className="action black-box"
+ key="black-box"
+ onClick={[Function]}
+ title="Ignore source"
+ >
+ <AccessibleImage
+ className="blackBox"
+ />
+ </button>
+ <button
+ className="action prettyPrint"
+ disabled={true}
+ key="prettyPrint"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="prettyPrint"
+ />
+ </button>
+ </div>
+ </div>
+ <div
+ className="source-footer-end"
+ >
+ <PaneToggleButton
+ collapsed={false}
+ horizontal={false}
+ key="toggle"
+ position="end"
+ />
+ </div>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Outline.css b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css
new file mode 100644
index 0000000000..6eb890f2d8
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css
@@ -0,0 +1,158 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+
+.sources-panel .outline {
+ display: flex;
+ height: 100%;
+}
+
+.source-outline-panel {
+ flex: 1;
+ overflow: auto;
+}
+
+.outline {
+ overflow-y: hidden;
+}
+
+.outline > div {
+ width: 100%;
+ position: relative;
+}
+
+.outline-pane-info {
+ padding: 0.5em;
+ width: 100%;
+ font-style: italic;
+ text-align: center;
+ user-select: none;
+ font-size: 12px;
+ overflow: hidden;
+}
+
+.outline-list {
+ margin: 0;
+ padding: 4px 0;
+ position: absolute;
+ top: 25px;
+ bottom: 25px;
+ left: 0;
+ right: 0;
+ list-style-type: none;
+ overflow: auto;
+}
+
+.outline-list__class-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.outline-list__class-list > .outline-list__element {
+ padding-inline-start: 2rem;
+}
+
+.outline-list__class-list .function-signature .function-name {
+ color: var(--theme-highlight-green);
+}
+
+.outline-list .function-signature .paren {
+ color: inherit;
+}
+
+.outline-list__class h2 {
+ font-weight: normal;
+ font-size: 1em;
+ padding: 3px 0;
+ padding-inline-start: 10px;
+ color: var(--blue-55);
+ margin: 0;
+}
+
+.outline-list__class:not(:first-child) h2 {
+ margin-top: 12px;
+}
+
+.outline-list h2:hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+.theme-dark .outline-list h2 {
+ color: var(--theme-highlight-blue);
+}
+
+.outline-list h2 .keyword {
+ color: var(--theme-highlight-red);
+}
+
+.outline-list__class h2.focused {
+ background: var(--theme-selection-background);
+}
+
+.outline-list__class h2.focused,
+.outline-list__class h2.focused .keyword {
+ color: var(--theme-selection-color);
+}
+
+.outline-list__element {
+ padding: 3px 10px 3px 10px;
+ cursor: default;
+ white-space: nowrap;
+}
+
+.outline-list > .outline-list__element {
+ padding-inline-start: 1rem;
+}
+
+.outline-list__element-icon {
+ padding-inline-end: 0.4rem;
+}
+
+.outline-list__element:hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+.outline-list__element.focused {
+ background: var(--theme-selection-background);
+}
+
+.outline-list__element.focused .outline-list__element-icon,
+.outline-list__element.focused .function-signature * {
+ color: var(--theme-selection-color);
+}
+
+.outline-footer {
+ display: flex;
+ box-sizing: border-box;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 25px;
+ background: var(--theme-body-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ opacity: 1;
+ z-index: 1;
+ user-select: none;
+}
+
+.outline-footer button {
+ color: var(--theme-body-color);
+
+ /* Since the buttons are on the bottom left edge, we need to adjust the outline so
+ it's not off-screen */
+ outline-offset: -2px;
+
+ &.active {
+ background: var(--theme-selection-background);
+ color: var(--theme-selection-color);
+
+ &:focus-visible {
+ /* When the button is active, it has a similar background color than the outline color
+ so we put the focus box-shadow inside the element to make the focus indicator visible */
+ box-shadow: inset var(--theme-outline-box-shadow);
+ }
+ }
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Outline.js b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js
new file mode 100644
index 0000000000..79ebf7a38e
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js
@@ -0,0 +1,388 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ ul,
+ li,
+ span,
+ h2,
+ button,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import { containsPosition, positionAfter } from "../../utils/ast";
+import { createLocation } from "../../utils/location";
+
+import actions from "../../actions/index";
+import {
+ getSelectedLocation,
+ getCursorPosition,
+ getSelectedSourceTextContent,
+} from "../../selectors/index";
+
+import OutlineFilter from "./OutlineFilter";
+import PreviewFunction from "../shared/PreviewFunction";
+
+import { isFulfilled } from "../../utils/async-value";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const {
+ score: fuzzaldrinScore,
+} = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.js");
+
+// Set higher to make the fuzzaldrin filter more specific
+const FUZZALDRIN_FILTER_THRESHOLD = 15000;
+
+/**
+ * Check whether the name argument matches the fuzzy filter argument
+ */
+const filterOutlineItem = (name, filter) => {
+ if (!filter) {
+ return true;
+ }
+
+ if (filter.length === 1) {
+ // when filter is a single char just check if it starts with the char
+ return filter.toLowerCase() === name.toLowerCase()[0];
+ }
+ return fuzzaldrinScore(name, filter) > FUZZALDRIN_FILTER_THRESHOLD;
+};
+
+// Checks if an element is visible inside its parent element
+function isVisible(element, parent) {
+ const parentRect = parent.getBoundingClientRect();
+ const elementRect = element.getBoundingClientRect();
+
+ const parentTop = parentRect.top;
+ const parentBottom = parentRect.bottom;
+ const elTop = elementRect.top;
+ const elBottom = elementRect.bottom;
+
+ return parentTop < elTop && parentBottom > elBottom;
+}
+
+export class Outline extends Component {
+ constructor(props) {
+ super(props);
+ this.focusedElRef = null;
+ this.state = { filter: "", focusedItem: null, symbols: null };
+ }
+
+ static get propTypes() {
+ return {
+ alphabetizeOutline: PropTypes.bool.isRequired,
+ cursorPosition: PropTypes.object,
+ flashLineRange: PropTypes.func.isRequired,
+ onAlphabetizeClick: PropTypes.func.isRequired,
+ selectLocation: PropTypes.func.isRequired,
+ selectedLocation: PropTypes.object.isRequired,
+ getFunctionSymbols: PropTypes.func.isRequired,
+ getClassSymbols: PropTypes.func.isRequired,
+ canFetchSymbols: PropTypes.bool,
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.canFetchSymbols) {
+ return;
+ }
+ this.getClassAndFunctionSymbols();
+ }
+
+ componentDidUpdate(prevProps) {
+ const { cursorPosition, selectedLocation, canFetchSymbols } = this.props;
+ if (cursorPosition && cursorPosition !== prevProps.cursorPosition) {
+ this.setFocus(cursorPosition);
+ }
+
+ if (
+ this.focusedElRef &&
+ !isVisible(this.focusedElRef, this.refs.outlineList)
+ ) {
+ this.focusedElRef.scrollIntoView({ block: "center" });
+ }
+
+ // Lets make sure the source text has been loaded and is different
+ if (canFetchSymbols && prevProps.selectedLocation !== selectedLocation) {
+ this.getClassAndFunctionSymbols();
+ }
+ }
+
+ async getClassAndFunctionSymbols() {
+ const { selectedLocation, getFunctionSymbols, getClassSymbols } =
+ this.props;
+
+ const functions = await getFunctionSymbols(selectedLocation);
+ const classes = await getClassSymbols(selectedLocation);
+
+ this.setState({ symbols: { functions, classes } });
+ }
+
+ async setFocus(cursorPosition) {
+ const { symbols } = this.state;
+
+ let classes = [];
+ let functions = [];
+
+ if (symbols) {
+ ({ classes, functions } = symbols);
+ }
+
+ // Find items that enclose the selected location
+ const enclosedItems = [...classes, ...functions].filter(
+ ({ name, location }) => containsPosition(location, cursorPosition)
+ );
+
+ if (!enclosedItems.length) {
+ this.setState({ focusedItem: null });
+ return;
+ }
+
+ // Find the closest item to the selected location to focus
+ const closestItem = enclosedItems.reduce((item, closest) =>
+ positionAfter(item.location, closest.location) ? item : closest
+ );
+
+ this.setState({ focusedItem: closestItem });
+ }
+
+ selectItem(selectedItem) {
+ const { selectedLocation, selectLocation } = this.props;
+ if (!selectedLocation || !selectedItem) {
+ return;
+ }
+
+ selectLocation(
+ createLocation({
+ source: selectedLocation.source,
+ line: selectedItem.location.start.line,
+ column: selectedItem.location.start.column,
+ })
+ );
+
+ this.setState({ focusedItem: selectedItem });
+ }
+
+ onContextMenu(event, func) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const { symbols } = this.state;
+ this.props.showOutlineContextMenu(event, func, symbols);
+ }
+
+ updateFilter = filter => {
+ this.setState({ filter: filter.trim() });
+ };
+
+ renderPlaceholder() {
+ const placeholderMessage = this.props.selectedLocation
+ ? L10N.getStr("outline.noFunctions")
+ : L10N.getStr("outline.noFileSelected");
+ return div(
+ {
+ className: "outline-pane-info",
+ },
+ placeholderMessage
+ );
+ }
+
+ renderLoading() {
+ return div(
+ {
+ className: "outline-pane-info",
+ },
+ L10N.getStr("loadingText")
+ );
+ }
+
+ renderFunction(func) {
+ const { focusedItem } = this.state;
+ const { name, location, parameterNames } = func;
+ const isFocused = focusedItem === func;
+ return li(
+ {
+ key: `${name}:${location.start.line}:${location.start.column}`,
+ className: classnames("outline-list__element", {
+ focused: isFocused,
+ }),
+ ref: el => {
+ if (isFocused) {
+ this.focusedElRef = el;
+ }
+ },
+ onClick: () => this.selectItem(func),
+ onContextMenu: e => this.onContextMenu(e, func),
+ },
+ span(
+ {
+ className: "outline-list__element-icon",
+ },
+ "λ"
+ ),
+ React.createElement(PreviewFunction, {
+ func: {
+ name,
+ parameterNames,
+ },
+ })
+ );
+ }
+
+ renderClassHeader(klass) {
+ return div(
+ null,
+ span(
+ {
+ className: "keyword",
+ },
+ "class"
+ ),
+ " ",
+ klass
+ );
+ }
+
+ renderClassFunctions(klass, functions) {
+ const { symbols } = this.state;
+
+ if (!symbols || klass == null || !functions.length) {
+ return null;
+ }
+
+ const { focusedItem } = this.state;
+ const classFunc = functions.find(func => func.name === klass);
+ const classFunctions = functions.filter(func => func.klass === klass);
+ const classInfo = symbols.classes.find(c => c.name === klass);
+
+ const item = classFunc || classInfo;
+ const isFocused = focusedItem === item;
+
+ return li(
+ {
+ className: "outline-list__class",
+ ref: el => {
+ if (isFocused) {
+ this.focusedElRef = el;
+ }
+ },
+ key: klass,
+ },
+ h2(
+ {
+ className: classnames({
+ focused: isFocused,
+ }),
+ onClick: () => this.selectItem(item),
+ },
+ classFunc
+ ? this.renderFunction(classFunc)
+ : this.renderClassHeader(klass)
+ ),
+ ul(
+ {
+ className: "outline-list__class-list",
+ },
+ classFunctions.map(func => this.renderFunction(func))
+ )
+ );
+ }
+
+ renderFunctions(functions) {
+ const { filter } = this.state;
+ let classes = [...new Set(functions.map(({ klass }) => klass))];
+ const namedFunctions = functions.filter(
+ ({ name, klass }) =>
+ filterOutlineItem(name, filter) && !klass && !classes.includes(name)
+ );
+ const classFunctions = functions.filter(
+ ({ name, klass }) => filterOutlineItem(name, filter) && !!klass
+ );
+
+ if (this.props.alphabetizeOutline) {
+ const sortByName = (a, b) => (a.name < b.name ? -1 : 1);
+ namedFunctions.sort(sortByName);
+ classes = classes.sort();
+ classFunctions.sort(sortByName);
+ }
+ return ul(
+ {
+ ref: "outlineList",
+ className: "outline-list devtools-monospace",
+ dir: "ltr",
+ },
+ namedFunctions.map(func => this.renderFunction(func)),
+ classes.map(klass => this.renderClassFunctions(klass, classFunctions))
+ );
+ }
+
+ renderFooter() {
+ return div(
+ {
+ className: "outline-footer",
+ },
+ button(
+ {
+ onClick: this.props.onAlphabetizeClick,
+ className: this.props.alphabetizeOutline ? "active" : "",
+ },
+ L10N.getStr("outline.sortLabel")
+ )
+ );
+ }
+
+ render() {
+ const { selectedLocation } = this.props;
+ const { filter, symbols } = this.state;
+
+ if (!selectedLocation) {
+ return this.renderPlaceholder();
+ }
+
+ if (!symbols) {
+ return this.renderLoading();
+ }
+
+ const { functions } = symbols;
+
+ if (functions.length === 0) {
+ return this.renderPlaceholder();
+ }
+
+ return div(
+ {
+ className: "outline",
+ },
+ div(
+ null,
+ React.createElement(OutlineFilter, {
+ filter: filter,
+ updateFilter: this.updateFilter,
+ }),
+ this.renderFunctions(functions),
+ this.renderFooter()
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSourceTextContent = getSelectedSourceTextContent(state);
+ return {
+ selectedLocation: getSelectedLocation(state),
+ canFetchSymbols:
+ selectedSourceTextContent && isFulfilled(selectedSourceTextContent),
+ cursorPosition: getCursorPosition(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ selectLocation: actions.selectLocation,
+ showOutlineContextMenu: actions.showOutlineContextMenu,
+ getFunctionSymbols: actions.getFunctionSymbols,
+ getClassSymbols: actions.getClassSymbols,
+})(Outline);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css
new file mode 100644
index 0000000000..787e527490
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.outline-filter {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: 0px;
+}
+
+.outline-filter-input {
+ height: 24px;
+ width: 100%;
+ background-color: var(--theme-sidebar-background);
+ color: var(--theme-body-color);
+ font-size: inherit;
+ user-select: text;
+ outline-offset: -2px;
+}
+
+.outline-filter-input::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js
new file mode 100644
index 0000000000..12f6fed2b7
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.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/>. */
+
+import { Component } from "devtools/client/shared/vendor/react";
+import {
+ form,
+ div,
+ input,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+export default class OutlineFilter extends Component {
+ state = { focused: false };
+
+ static get propTypes() {
+ return {
+ filter: PropTypes.string.isRequired,
+ updateFilter: PropTypes.func.isRequired,
+ };
+ }
+
+ setFocus = shouldFocus => {
+ this.setState({ focused: shouldFocus });
+ };
+
+ onChange = e => {
+ this.props.updateFilter(e.target.value);
+ };
+
+ onKeyDown = e => {
+ if (e.key === "Escape" && this.props.filter !== "") {
+ // use preventDefault to override toggling the split-console which is
+ // also bound to the ESC key
+ e.preventDefault();
+ this.props.updateFilter("");
+ } else if (e.key === "Enter") {
+ // We must prevent the form submission from taking any action
+ // https://github.com/firefox-devtools/debugger/pull/7308
+ e.preventDefault();
+ }
+ };
+
+ render() {
+ const { focused } = this.state;
+ return div(
+ {
+ className: "outline-filter",
+ },
+ form(
+ null,
+ input({
+ className: classnames("outline-filter-input devtools-filterinput", {
+ focused,
+ }),
+ onFocus: () => this.setFocus(true),
+ onBlur: () => this.setFocus(false),
+ placeholder: L10N.getStr("outline.placeholder"),
+ value: this.props.filter,
+ type: "text",
+ onChange: this.onChange,
+ onKeyDown: this.onKeyDown,
+ })
+ )
+ );
+ }
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css
new file mode 100644
index 0000000000..eb11149a79
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css
@@ -0,0 +1,227 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.search-container {
+ position: absolute;
+ top: var(--editor-header-height);
+ left: 0;
+ width: calc(100% - 1px);
+ height: calc(100% - var(--editor-header-height));
+ display: flex;
+ flex-direction: column;
+ z-index: 20;
+ overflow-y: hidden;
+
+ /* Using the same colors as the Netmonitor's --table-selection-background-hover */
+ --search-result-background-hover: rgba(209, 232, 255, 0.8);
+}
+
+.theme-dark .search-container {
+ --search-result-background-hover: rgba(53, 59, 72, 1);
+}
+
+.project-text-search {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ overflow-y: hidden;
+ height: 100%;
+}
+
+.project-text-search .result {
+ display: contents;
+ cursor: default;
+ line-height: 16px;
+ font-size: 11px;
+ font-family: var(--monospace-font-family);
+}
+
+.project-text-search .result:hover > * {
+ background-color: var(--search-result-background-hover);
+}
+
+.project-text-search .result .line-number {
+ grid-column: 1;
+ padding-block: 1px;
+ padding-inline-start: 4px;
+ padding-inline-end: 6px;
+ text-align: end;
+ color: var(--theme-text-color-alt);
+}
+
+.unavailable-source {
+ white-space: pre;
+
+ .tooltip-panel {
+ padding: 1em;
+ }
+}
+
+.project-text-search .result .line-value {
+ grid-column: 2;
+ padding-block: 1px;
+ padding-inline-end: 4px;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ outline-offset: -2px;
+}
+
+.project-text-search .result .query-match {
+ border-bottom: 1px solid var(--theme-contrast-border);
+ color: var(--theme-contrast-color);
+ background-color: var(--theme-contrast-background);
+}
+
+.project-text-search .result.focused .query-match {
+ border-bottom: none;
+ color: var(--theme-selection-background);
+ background-color: var(--theme-selection-color);
+}
+
+.project-text-search .tree-indent {
+ display: none;
+}
+
+.project-search-results-toolbar {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ background-color: var(--theme-accordion-header-background);
+ border-bottom: 1px solid var(--theme-splitter-color);
+ padding: 2px 8px;
+ align-items: center;
+ gap: 4px;
+}
+
+
+.project-text-search .refresh-btn {
+ background-color: transparent;
+ padding: 2px;
+ display: grid;
+ --size: 16px;
+ --highlight-size: 5px;
+ --remain-size: calc(var(--size) - var(--highlight-size));
+ width: var(--size);
+ aspect-ratio: 1;
+ box-sizing: content-box;
+ grid-template-rows: var(--highlight-size) var(--remain-size);
+ grid-template-columns: var(--remain-size) var(--highlight-size);
+
+ &.devtools-button:focus-visible {
+ outline: var(--theme-focus-outline);
+ }
+
+ &.highlight::after {
+ content: "";
+ display: block;
+ grid-row: 1 / 2;
+ grid-column: 2 / 3;
+ height: 5px;
+ width: 5px;
+ background-color: var(--blue-40);
+ border-radius: 100%;
+ outline: 1px solid var(--theme-sidebar-background);
+ z-index: 1;
+ }
+
+ .img {
+ grid-row: 1 / -1;
+ grid-column: 1 / -1;
+ transition: rotate 0.2s;
+ width: 14px; height: 14px;
+
+ .highlight & {
+ rotate: 0.75turn;
+ }
+ }
+}
+
+.project-text-search .no-result-msg {
+ color: var(--theme-text-color-inactive);
+ font-size: 24px;
+ padding: 4px 15px;
+ max-width: 100%;
+ overflow-wrap: break-word;
+ hyphens: auto;
+}
+
+.project-text-search .file-result {
+ grid-column: 1/3;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ min-height: 24px;
+ padding: 2px 4px;
+ font-weight: bold;
+ font-size: 12px;
+ line-height: 16px;
+ cursor: default;
+}
+
+.project-text-search .file-result .img {
+ margin-inline: 2px;
+}
+
+.project-text-search .file-result .img.file {
+ margin-inline-end: 4px;
+}
+
+.project-text-search .file-path {
+ flex: 0 1 auto;
+ padding-inline-end: 4px;
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.project-text-search .file-path:empty {
+ display: none;
+}
+
+.project-text-search .search-field {
+ display: flex;
+ align-self: stretch;
+ flex-grow: 1;
+ width: 100%;
+ border-bottom: none;
+}
+
+.project-text-search .tree {
+ overflow-x: hidden;
+ overflow-y: auto;
+ height: 100%;
+ display: grid;
+ min-width: 100%;
+ white-space: nowrap;
+ user-select: none;
+ align-content: start;
+ /* Align the second column to the search input's text value */
+ grid-template-columns: minmax(40px, auto) 1fr;
+ padding-top: 4px;
+}
+
+/* Fake padding-bottom using a pseudo-element because Gecko doesn't render the
+ padding-bottom in a scroll container */
+.project-text-search .tree::after {
+ content: "";
+ display: block;
+ height: 4px;
+}
+
+.project-text-search .tree .tree-node {
+ display: contents;
+}
+
+/* Focus values */
+
+.project-text-search .file-result.focused,
+.project-text-search .result.focused .line-value,
+.project-text-search .result.focused .line-number {
+ color: var(--theme-selection-color);
+ background-color: var(--theme-selection-background);
+}
+
+.project-text-search .file-result.focused .img {
+ background-color: currentColor;
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js
new file mode 100644
index 0000000000..68b08aed2b
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js
@@ -0,0 +1,480 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ button,
+ div,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../actions/index";
+
+import { getEditor } from "../../utils/editor/index";
+import { searchKeys } from "../../constants";
+
+import { getRelativePath } from "../../utils/sources-tree/utils";
+import { getFormattedSourceId } from "../../utils/source";
+import {
+ getProjectSearchQuery,
+ getNavigateCounter,
+} from "../../selectors/index";
+
+import SearchInput from "../shared/SearchInput";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const { PluralForm } = require("resource://devtools/shared/plural-form.js");
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const Tree = require("resource://devtools/client/shared/components/Tree.js");
+const { debounce } = require("resource://devtools/shared/debounce.js");
+const { throttle } = require("resource://devtools/shared/throttle.js");
+
+const {
+ HTMLTooltip,
+} = require("resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js");
+
+export const statusType = {
+ initial: "INITIAL",
+ fetching: "FETCHING",
+ cancelled: "CANCELLED",
+ done: "DONE",
+ error: "ERROR",
+};
+
+function getFilePath(item, index) {
+ return item.type === "RESULT"
+ ? `${item.location.source.id}-${index || "$"}`
+ : `${item.location.source.id}-${item.location.line}-${
+ item.location.column
+ }-${index || "$"}`;
+}
+
+export class ProjectSearch extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ // We may restore a previous state when changing tabs in the primary panes,
+ // or when restoring primary panes from collapse.
+ query: this.props.query || "",
+
+ inputFocused: false,
+ focusedItem: null,
+ expanded: new Set(),
+ results: [],
+ navigateCounter: null,
+ status: statusType.done,
+ };
+ // Use throttle for updating results in order to prevent delaying showing result until the end of the search
+ this.onUpdatedResults = throttle(this.onUpdatedResults.bind(this), 100);
+ // Use debounce for input processing in order to wait for the end of user input edition before triggerring the search
+ this.doSearch = debounce(this.doSearch.bind(this), 100);
+ this.doSearch();
+ }
+
+ static get propTypes() {
+ return {
+ doSearchForHighlight: PropTypes.func.isRequired,
+ query: PropTypes.string.isRequired,
+ results: PropTypes.array.isRequired,
+ searchSources: PropTypes.func.isRequired,
+ selectSpecificLocationOrSameUrl: PropTypes.func.isRequired,
+ status: PropTypes.oneOf([
+ "INITIAL",
+ "FETCHING",
+ "CANCELED",
+ "DONE",
+ "ERROR",
+ ]).isRequired,
+ modifiers: PropTypes.object,
+ toggleProjectSearchModifier: PropTypes.func,
+ };
+ }
+
+ async doSearch() {
+ // Cancel any previous async ongoing search
+ if (this.searchAbortController) {
+ this.searchAbortController.abort();
+ }
+
+ if (!this.state.query) {
+ this.setState({ status: statusType.done });
+ return;
+ }
+
+ this.setState({
+ status: statusType.fetching,
+ results: [],
+ navigateCounter: this.props.navigateCounter,
+ });
+
+ // Setup an AbortController whose main goal is to be able to cancel the asynchronous
+ // operation done by the `searchSources` action.
+ // This allows allows the React Component to receive partial updates
+ // to render results as they are available.
+ this.searchAbortController = new AbortController();
+
+ await this.props.searchSources(
+ this.state.query,
+ this.onUpdatedResults,
+ this.searchAbortController.signal
+ );
+ }
+
+ onUpdatedResults(results, done, signal) {
+ // debounce may delay the execution after this search has been cancelled
+ if (signal.aborted) {
+ return;
+ }
+
+ this.setState({
+ results,
+ status: done ? statusType.done : statusType.fetching,
+ });
+ }
+
+ selectMatchItem = async matchItem => {
+ const foundMatchingSource =
+ await this.props.selectSpecificLocationOrSameUrl(matchItem.location);
+ // When we reload, or if the source's target has been destroyed,
+ // we may no longer have the source available in the reducer.
+ // In such case `selectSpecificLocationOrSameUrl` will return false.
+ if (!foundMatchingSource) {
+ // When going over results via the key arrows and Enter, we may display many tooltips at once.
+ if (this.tooltip) {
+ this.tooltip.hide();
+ }
+ // Go down to line-number otherwise HTMLTooltip's call to getBoundingClientRect would return (0, 0) position for the tooltip
+ const element = document.querySelector(
+ ".project-text-search .tree-node.focused .result .line-number"
+ );
+ const tooltip = new HTMLTooltip(element.ownerDocument, {
+ className: "unavailable-source",
+ type: "arrow",
+ });
+ tooltip.panel.textContent = L10N.getStr(
+ "projectTextSearch.sourceNoLongerAvailable"
+ );
+ tooltip.setContentSize({ height: "auto" });
+ tooltip.show(element);
+ this.tooltip = tooltip;
+ return;
+ }
+ this.props.doSearchForHighlight(
+ this.state.query,
+ getEditor(),
+ matchItem.location.line,
+ matchItem.location.column
+ );
+ };
+
+ highlightMatches = lineMatch => {
+ const { value, matchIndex, match } = lineMatch;
+ const len = match.length;
+ return span(
+ {
+ className: "line-value",
+ },
+ span(
+ {
+ className: "line-match",
+ key: 0,
+ },
+ value.slice(0, matchIndex)
+ ),
+ span(
+ {
+ className: "query-match",
+ key: 1,
+ },
+ value.substr(matchIndex, len)
+ ),
+ span(
+ {
+ className: "line-match",
+ key: 2,
+ },
+ value.slice(matchIndex + len, value.length)
+ )
+ );
+ };
+
+ getResultCount = () =>
+ this.state.results.reduce((count, file) => count + file.matches.length, 0);
+
+ onKeyDown = e => {
+ if (e.key === "Escape") {
+ return;
+ }
+
+ e.stopPropagation();
+
+ this.setState({ focusedItem: null });
+ this.doSearch();
+ };
+
+ onHistoryScroll = query => {
+ this.setState({ query });
+ this.doSearch();
+ };
+
+ // This can be called by Tree when manually selecting node via arrow keys and Enter.
+ onActivate = item => {
+ if (item && item.type === "MATCH") {
+ this.selectMatchItem(item);
+ }
+ };
+
+ onFocus = item => {
+ if (this.state.focusedItem !== item) {
+ this.setState({
+ focusedItem: item,
+ });
+ }
+ };
+
+ inputOnChange = e => {
+ const inputValue = e.target.value;
+ this.setState({ query: inputValue });
+ this.doSearch();
+ };
+
+ renderFile = (file, focused, expanded) => {
+ const matchesLength = file.matches.length;
+ const matches = ` (${matchesLength} match${matchesLength > 1 ? "es" : ""})`;
+ return div(
+ {
+ className: classnames("file-result", {
+ focused,
+ }),
+ key: file.location.source.id,
+ },
+ React.createElement(AccessibleImage, {
+ className: classnames("arrow", {
+ expanded,
+ }),
+ }),
+ React.createElement(AccessibleImage, {
+ className: "file",
+ }),
+ span(
+ {
+ className: "file-path",
+ },
+ file.location.source.url
+ ? getRelativePath(file.location.source.url)
+ : getFormattedSourceId(file.location.source.id)
+ ),
+ span(
+ {
+ className: "matches-summary",
+ },
+ matches
+ )
+ );
+ };
+
+ renderMatch = (match, focused) => {
+ return div(
+ {
+ className: classnames("result", {
+ focused,
+ }),
+ onClick: () => this.selectMatchItem(match),
+ },
+ span(
+ {
+ className: "line-number",
+ key: match.location.line,
+ },
+ match.location.line
+ ),
+ this.highlightMatches(match)
+ );
+ };
+
+ renderItem = (item, depth, focused, _, expanded) => {
+ if (item.type === "RESULT") {
+ return this.renderFile(item, focused, expanded);
+ }
+ return this.renderMatch(item, focused);
+ };
+
+ renderRefreshButton() {
+ if (!this.state.query) {
+ return null;
+ }
+
+ // Highlight the refresh button when the current search results
+ // are based on the previous document. doSearch will save the "navigate counter"
+ // into state, while props will report the current "navigate counter".
+ // The "navigate counter" is incremented each time we navigate to a new page.
+ const highlight =
+ this.state.navigateCounter != null &&
+ this.state.navigateCounter != this.props.navigateCounter;
+ return button(
+ {
+ className: classnames("refresh-btn devtools-button", {
+ highlight,
+ }),
+ title: highlight
+ ? L10N.getStr("projectTextSearch.refreshButtonTooltipOnNavigation")
+ : L10N.getStr("projectTextSearch.refreshButtonTooltip"),
+ onClick: this.doSearch,
+ },
+ React.createElement(AccessibleImage, {
+ className: "refresh",
+ })
+ );
+ }
+
+ renderResultsToolbar() {
+ if (!this.state.query) {
+ return null;
+ }
+ return div(
+ { className: "project-search-results-toolbar" },
+ span({ className: "results-count" }, this.renderSummary()),
+ this.renderRefreshButton()
+ );
+ }
+
+ renderResults() {
+ const { status, results } = this.state;
+ if (!this.state.query) {
+ return null;
+ }
+ if (results.length) {
+ return React.createElement(Tree, {
+ getRoots: () => results,
+ getChildren: file => file.matches || [],
+ autoExpandAll: true,
+ autoExpandDepth: 1,
+ autoExpandNodeChildrenLimit: 100,
+ getParent: item => null,
+ getPath: getFilePath,
+ renderItem: this.renderItem,
+ focused: this.state.focusedItem,
+ onFocus: this.onFocus,
+ onActivate: this.onActivate,
+ isExpanded: item => {
+ return this.state.expanded.has(item);
+ },
+ onExpand: item => {
+ const { expanded } = this.state;
+ expanded.add(item);
+ this.setState({
+ expanded,
+ });
+ },
+ onCollapse: item => {
+ const { expanded } = this.state;
+ expanded.delete(item);
+ this.setState({
+ expanded,
+ });
+ },
+ getKey: getFilePath,
+ });
+ }
+ const msg =
+ status === statusType.fetching
+ ? L10N.getStr("loadingText")
+ : L10N.getStr("projectTextSearch.noResults");
+ return div(
+ {
+ className: "no-result-msg absolute-center",
+ },
+ msg
+ );
+ }
+
+ renderSummary = () => {
+ if (this.state.query === "") {
+ return "";
+ }
+ const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2");
+ const count = this.getResultCount();
+ if (count === 0) {
+ return "";
+ }
+ return PluralForm.get(count, resultsSummaryString).replace("#1", count);
+ };
+
+ shouldShowErrorEmoji() {
+ return !this.getResultCount() && this.state.status === statusType.done;
+ }
+
+ renderInput() {
+ const { status } = this.state;
+ return React.createElement(SearchInput, {
+ query: this.state.query,
+ count: this.getResultCount(),
+ placeholder: L10N.getStr("projectTextSearch.placeholder"),
+ size: "small",
+ showErrorEmoji: this.shouldShowErrorEmoji(),
+ isLoading: status === statusType.fetching,
+ onChange: this.inputOnChange,
+ onFocus: () =>
+ this.setState({
+ inputFocused: true,
+ }),
+ onBlur: () =>
+ this.setState({
+ inputFocused: false,
+ }),
+ onKeyDown: this.onKeyDown,
+ onHistoryScroll: this.onHistoryScroll,
+ showClose: false,
+ showExcludePatterns: true,
+ excludePatternsLabel: L10N.getStr(
+ "projectTextSearch.excludePatterns.label"
+ ),
+ excludePatternsPlaceholder: L10N.getStr(
+ "projectTextSearch.excludePatterns.placeholder"
+ ),
+ ref: "searchInput",
+ showSearchModifiers: true,
+ searchKey: searchKeys.PROJECT_SEARCH,
+ onToggleSearchModifier: this.doSearch,
+ });
+ }
+
+ render() {
+ return div(
+ {
+ className: "search-container",
+ },
+ div(
+ {
+ className: "project-text-search",
+ },
+ div(
+ {
+ className: "header",
+ },
+ this.renderInput()
+ ),
+ this.renderResultsToolbar(),
+ this.renderResults()
+ )
+ );
+ }
+}
+
+ProjectSearch.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = state => ({
+ query: getProjectSearchQuery(state),
+ navigateCounter: getNavigateCounter(state),
+});
+
+export default connect(mapStateToProps, {
+ searchSources: actions.searchSources,
+ selectSpecificLocationOrSameUrl: actions.selectSpecificLocationOrSameUrl,
+ doSearchForHighlight: actions.doSearchForHighlight,
+})(ProjectSearch);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/Sources.css b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css
new file mode 100644
index 0000000000..68c28655be
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.sources-panel {
+ background-color: var(--theme-sidebar-background);
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+
+ & * {
+ user-select: none;
+ }
+
+ /* Tabs header */
+ & .tabs-navigation {
+ height: var(--editor-header-height) !important;
+
+ & .tabs-menu {
+ /* override margin set by the Tabs component */
+ margin: 0 !important;
+ }
+
+ & .tab {
+ flex: 1;
+ overflow: hidden;
+ display: inline-flex;
+ align-items: center;
+ }
+
+ & [role="tab"] {
+ padding: 4px 8px;
+ flex: 1;
+ }
+ }
+}
+
+
+
+/***********************/
+/* Souces Panel layout */
+/***********************/
+
+.sources-list {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+.sources-list .sources-clear-root-container {
+ grid-area: custom-root;
+}
+
+.sources-list :is(.tree, .no-sources-message) {
+ grid-area: sources-tree-or-empty-message;
+}
+
+/****************/
+/* Custom root */
+/****************/
+
+.sources-clear-root {
+ padding: 4px 8px;
+ width: 100%;
+ text-align: start;
+ white-space: nowrap;
+ color: inherit;
+ display: flex;
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.sources-clear-root .home {
+ background-color: var(--theme-icon-dimmed-color);
+}
+
+.sources-clear-root .breadcrumb {
+ width: 5px;
+ margin: 0 2px 0 6px;
+ vertical-align: bottom;
+ background: var(--theme-text-color-alt);
+}
+
+.sources-clear-root-label {
+ margin-left: 5px;
+ line-height: 16px;
+}
+
+/*****************/
+/* Sources tree */
+/*****************/
+
+.sources-list .tree {
+ flex-grow: 1;
+ padding: 4px 0;
+ user-select: none;
+
+ white-space: nowrap;
+ overflow: auto;
+ min-width: 100%;
+
+ display: grid;
+ grid-template-columns: 1fr;
+ align-content: start;
+
+ line-height: 1.4em;
+}
+
+.sources-list .tree .node {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding-block: 8px;
+ padding-inline: 6px 8px;
+}
+
+.sources-list .tree .tree-node:not(.focused):hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+.sources-list .tree button {
+ display: block;
+}
+
+.sources-list .tree .node {
+ padding: 2px 3px;
+ position: relative;
+}
+
+.sources-list .tree .node.focused {
+ color: var(--theme-selection-color);
+ background-color: var(--theme-selection-background);
+}
+
+html:not([dir="rtl"]) .sources-list .tree .node > div {
+ margin-left: 10px;
+}
+
+html[dir="rtl"] .sources-list .tree .node > div {
+ margin-right: 10px;
+}
+
+.sources-list .tree-node button {
+ position: fixed;
+}
+
+.sources-list .img {
+ margin-inline-end: 4px;
+}
+
+.sources-list .tree .focused .img {
+ --icon-color: #ffffff;
+ background-color: var(--icon-color);
+ fill: var(--icon-color);
+}
+
+/* Use the same width as .img.arrow */
+.sources-list .tree .img.no-arrow {
+ width: 10px;
+ visibility: hidden;
+}
+
+.sources-list .tree .label .suffix {
+ font-style: italic;
+ font-size: 0.9em;
+ color: var(--theme-comment);
+}
+
+.sources-list .tree .focused .label .suffix {
+ color: inherit;
+}
+
+.theme-dark .source-list .node.focused {
+ background-color: var(--theme-tab-toolbar-background);
+}
+
+.sources-list .tree .blackboxed {
+ color: #806414;
+}
+
+.sources-list .img.blackBox {
+ mask-size: 13px;
+ background-color: #806414;
+}
+
+.sources-list .tree .label {
+ display: inline-block;
+ line-height: 16px;
+}
+
+.source-list-footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 5px;
+ justify-content: center;
+ text-align: center;
+ min-height: var(--editor-footer-height);
+ flex-shrink: 0;
+ border-block-start: 1px solid var(--theme-warning-border);
+ user-select: none;
+ padding: 3px 10px;
+ color: var(--theme-warning-color);
+ background-color: var(--theme-warning-background);
+}
+
+.source-list-footer .devtools-togglebutton {
+ background-color: var(--theme-toolbar-hover);
+}
+
+.source-list-footer .devtools-togglebutton:hover {
+ background-color: var(--theme-toolbar-hover);
+ cursor: pointer;
+}
+
+
+/* Removes start margin when a custom root is used */
+.sources-list-custom-root
+ .tree
+ > .tree-node[data-expandable="false"][aria-level="0"] {
+ padding-inline-start: 4px;
+}
+
+.sources-list .tree-node[data-expandable="false"] .tree-indent:last-of-type {
+ margin-inline-end: 0;
+}
+
+
+/*****************/
+/* No Sources */
+/*****************/
+
+.no-sources-message {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-style: italic;
+ text-align: center;
+ padding: 0.5em;
+ font-size: 12px;
+ user-select: none;
+}
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js
new file mode 100644
index 0000000000..286e673706
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js
@@ -0,0 +1,352 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+// Dependencies
+import React, {
+ Component,
+ Fragment,
+} from "devtools/client/shared/vendor/react";
+import {
+ div,
+ button,
+ span,
+ footer,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+// Selectors
+import {
+ getMainThreadHost,
+ getExpandedState,
+ getProjectDirectoryRoot,
+ getProjectDirectoryRootName,
+ getSourcesTreeSources,
+ getFocusedSourceItem,
+ getHideIgnoredSources,
+} from "../../selectors/index";
+
+// Actions
+import actions from "../../actions/index";
+
+// Components
+import SourcesTreeItem from "./SourcesTreeItem";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const Tree = require("resource://devtools/client/shared/components/Tree.js");
+
+function shouldAutoExpand(item, mainThreadHost) {
+ // There is only one case where we want to force auto expand,
+ // when we are on the group of the page's domain.
+ return item.type == "group" && item.groupName === mainThreadHost;
+}
+
+class SourcesTree extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ }
+
+ static get propTypes() {
+ return {
+ mainThreadHost: PropTypes.string.isRequired,
+ expanded: PropTypes.object.isRequired,
+ focusItem: PropTypes.func.isRequired,
+ focused: PropTypes.object,
+ projectRoot: PropTypes.string.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ setExpandedState: PropTypes.func.isRequired,
+ rootItems: PropTypes.object.isRequired,
+ clearProjectDirectoryRoot: PropTypes.func.isRequired,
+ projectRootName: PropTypes.string.isRequired,
+ setHideOrShowIgnoredSources: PropTypes.func.isRequired,
+ hideIgnoredSources: PropTypes.bool.isRequired,
+ };
+ }
+
+ selectSourceItem = item => {
+ this.props.selectSource(item.source, item.sourceActor);
+ };
+
+ onFocus = item => {
+ this.props.focusItem(item);
+ };
+
+ onActivate = item => {
+ if (item.type == "source") {
+ this.selectSourceItem(item);
+ }
+ };
+
+ onExpand = (item, shouldIncludeChildren) => {
+ this.setExpanded(item, true, shouldIncludeChildren);
+ };
+
+ onCollapse = (item, shouldIncludeChildren) => {
+ this.setExpanded(item, false, shouldIncludeChildren);
+ };
+
+ setExpanded = (item, isExpanded, shouldIncludeChildren) => {
+ // Note that setExpandedState relies on us to clone this Set
+ // which is going to be store as-is in the reducer.
+ const expanded = new Set(this.props.expanded);
+
+ let changed = false;
+ const expandItem = i => {
+ const key = this.getKey(i);
+ if (isExpanded) {
+ changed |= !expanded.has(key);
+ expanded.add(key);
+ } else {
+ changed |= expanded.has(key);
+ expanded.delete(key);
+ }
+ };
+ expandItem(item);
+
+ if (shouldIncludeChildren) {
+ let parents = [item];
+ while (parents.length) {
+ const children = [];
+ for (const parent of parents) {
+ for (const child of this.getChildren(parent)) {
+ expandItem(child);
+ children.push(child);
+ }
+ }
+ parents = children;
+ }
+ }
+ if (changed) {
+ this.props.setExpandedState(expanded);
+ }
+ };
+
+ isEmpty() {
+ return !this.getRoots().length;
+ }
+
+ renderEmptyElement(message) {
+ return div(
+ {
+ key: "empty",
+ className: "no-sources-message",
+ },
+ message
+ );
+ }
+
+ getRoots = () => {
+ return this.props.rootItems;
+ };
+
+ getKey = item => {
+ // As this is used as React key in Tree component,
+ // we need to update the key when switching to a new project root
+ // otherwise these items won't be updated and will have a buggy padding start.
+ const { projectRoot } = this.props;
+ if (projectRoot) {
+ return projectRoot + item.uniquePath;
+ }
+ return item.uniquePath;
+ };
+
+ getChildren = item => {
+ // This is the precial magic that coalesce "empty" folders,
+ // i.e folders which have only one sub-folder as children.
+ function skipEmptyDirectories(directory) {
+ if (directory.type != "directory") {
+ return directory;
+ }
+ if (
+ directory.children.length == 1 &&
+ directory.children[0].type == "directory"
+ ) {
+ return skipEmptyDirectories(directory.children[0]);
+ }
+ return directory;
+ }
+ if (item.type == "thread") {
+ return item.children;
+ } else if (item.type == "group" || item.type == "directory") {
+ return item.children.map(skipEmptyDirectories);
+ }
+ return [];
+ };
+
+ getParent = item => {
+ if (item.type == "thread") {
+ return null;
+ }
+ const { rootItems } = this.props;
+ // This is the second magic which skip empty folders
+ // (See getChildren comment)
+ function skipEmptyDirectories(directory) {
+ if (
+ directory.type == "group" ||
+ directory.type == "thread" ||
+ rootItems.includes(directory)
+ ) {
+ return directory;
+ }
+ if (
+ directory.children.length == 1 &&
+ directory.children[0].type == "directory"
+ ) {
+ return skipEmptyDirectories(directory.parent);
+ }
+ return directory;
+ }
+ return skipEmptyDirectories(item.parent);
+ };
+
+ renderProjectRootHeader() {
+ const { projectRootName } = this.props;
+
+ if (!projectRootName) {
+ return null;
+ }
+ return div(
+ {
+ key: "root",
+ className: "sources-clear-root-container",
+ },
+ button(
+ {
+ className: "sources-clear-root",
+ onClick: () => this.props.clearProjectDirectoryRoot(),
+ title: L10N.getStr("removeDirectoryRoot.label"),
+ },
+ React.createElement(AccessibleImage, {
+ className: "home",
+ }),
+ React.createElement(AccessibleImage, {
+ className: "breadcrumb",
+ }),
+ span(
+ {
+ className: "sources-clear-root-label",
+ },
+ projectRootName
+ )
+ )
+ );
+ }
+
+ renderItem = (item, depth, focused, _, expanded) => {
+ const { mainThreadHost } = this.props;
+ return React.createElement(SourcesTreeItem, {
+ item,
+ depth,
+ focused,
+ autoExpand: shouldAutoExpand(item, mainThreadHost),
+ expanded,
+ focusItem: this.onFocus,
+ selectSourceItem: this.selectSourceItem,
+ setExpanded: this.setExpanded,
+ getParent: this.getParent,
+ });
+ };
+
+ renderTree() {
+ const { expanded, focused } = this.props;
+
+ const treeProps = {
+ autoExpandAll: false,
+ autoExpandDepth: 1,
+ expanded,
+ focused,
+ getChildren: this.getChildren,
+ getParent: this.getParent,
+ getKey: this.getKey,
+ getRoots: this.getRoots,
+ onCollapse: this.onCollapse,
+ onExpand: this.onExpand,
+ onFocus: this.onFocus,
+ isExpanded: item => {
+ return this.props.expanded.has(this.getKey(item));
+ },
+ onActivate: this.onActivate,
+ renderItem: this.renderItem,
+ preventBlur: true,
+ };
+ return React.createElement(Tree, treeProps);
+ }
+
+ renderPane(child) {
+ const { projectRoot } = this.props;
+ return div(
+ {
+ key: "pane",
+ className: classnames("sources-pane", {
+ "sources-list-custom-root": !!projectRoot,
+ }),
+ },
+ child
+ );
+ }
+
+ renderFooter() {
+ if (this.props.hideIgnoredSources) {
+ return footer(
+ {
+ className: "source-list-footer",
+ },
+ L10N.getStr("ignoredSourcesHidden"),
+ button(
+ {
+ className: "devtools-togglebutton",
+ onClick: () => this.props.setHideOrShowIgnoredSources(false),
+ title: L10N.getStr("showIgnoredSources.tooltip.label"),
+ },
+ L10N.getStr("showIgnoredSources")
+ )
+ );
+ }
+ return null;
+ }
+
+ render() {
+ const { projectRoot } = this.props;
+ return div(
+ {
+ key: "pane",
+ className: classnames("sources-list", {
+ "sources-list-custom-root": !!projectRoot,
+ }),
+ },
+ this.isEmpty()
+ ? this.renderEmptyElement(L10N.getStr("noSourcesText"))
+ : React.createElement(
+ Fragment,
+ null,
+ this.renderProjectRootHeader(),
+ this.renderTree(),
+ this.renderFooter()
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ mainThreadHost: getMainThreadHost(state),
+ expanded: getExpandedState(state),
+ focused: getFocusedSourceItem(state),
+ projectRoot: getProjectDirectoryRoot(state),
+ rootItems: getSourcesTreeSources(state),
+ projectRootName: getProjectDirectoryRootName(state),
+ hideIgnoredSources: getHideIgnoredSources(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ selectSource: actions.selectSource,
+ setExpandedState: actions.setExpandedState,
+ focusItem: actions.focusItem,
+ clearProjectDirectoryRoot: actions.clearProjectDirectoryRoot,
+ setHideOrShowIgnoredSources: actions.setHideOrShowIgnoredSources,
+})(SourcesTree);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js
new file mode 100644
index 0000000000..fd5ceca46d
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js
@@ -0,0 +1,249 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div, span } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import SourceIcon from "../shared/SourceIcon";
+import AccessibleImage from "../shared/AccessibleImage";
+
+import {
+ getGeneratedSourceByURL,
+ isSourceOverridden,
+ getHideIgnoredSources,
+} from "../../selectors/index";
+import actions from "../../actions/index";
+
+import { sourceTypes } from "../../utils/source";
+import { createLocation } from "../../utils/location";
+import { safeDecodeItemName } from "../../utils/sources-tree/utils";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class SourceTreeItem extends Component {
+ static get propTypes() {
+ return {
+ autoExpand: PropTypes.bool.isRequired,
+ depth: PropTypes.bool.isRequired,
+ expanded: PropTypes.bool.isRequired,
+ focusItem: PropTypes.func.isRequired,
+ focused: PropTypes.bool.isRequired,
+ hasMatchingGeneratedSource: PropTypes.bool.isRequired,
+ item: PropTypes.object.isRequired,
+ selectSourceItem: PropTypes.func.isRequired,
+ setExpanded: PropTypes.func.isRequired,
+ getParent: PropTypes.func.isRequired,
+ isOverridden: PropTypes.bool,
+ hideIgnoredSources: PropTypes.bool,
+ };
+ }
+
+ componentDidMount() {
+ const { autoExpand, item } = this.props;
+ if (autoExpand) {
+ this.props.setExpanded(item, true, false);
+ }
+ }
+
+ onClick = e => {
+ const { item, focusItem, selectSourceItem } = this.props;
+
+ focusItem(item);
+ if (item.type == "source") {
+ selectSourceItem(item);
+ }
+ };
+
+ onContextMenu = event => {
+ event.stopPropagation();
+ event.preventDefault();
+ this.props.showSourceTreeItemContextMenu(
+ event,
+ this.props.item,
+ this.props.depth,
+ this.props.setExpanded,
+ this.renderItemName()
+ );
+ };
+
+ renderItemArrow() {
+ const { item, expanded } = this.props;
+ return item.type != "source"
+ ? React.createElement(AccessibleImage, {
+ className: classnames("arrow", {
+ expanded,
+ }),
+ })
+ : span({
+ className: "img no-arrow",
+ });
+ }
+
+ renderIcon(item) {
+ if (item.type == "thread") {
+ const icon = item.thread.targetType.includes("worker")
+ ? "worker"
+ : "window";
+ return React.createElement(AccessibleImage, {
+ className: classnames(icon),
+ });
+ }
+ if (item.type == "group") {
+ if (item.groupName === "Webpack") {
+ return React.createElement(AccessibleImage, {
+ className: "webpack",
+ });
+ } else if (item.groupName === "Angular") {
+ return React.createElement(AccessibleImage, {
+ className: "angular",
+ });
+ }
+ // Check if the group relates to an extension.
+ // This happens when a webextension injects a content script.
+ if (item.isForExtensionSource) {
+ return React.createElement(AccessibleImage, {
+ className: "extension",
+ });
+ }
+ return React.createElement(AccessibleImage, {
+ className: "globe-small",
+ });
+ }
+ if (item.type == "directory") {
+ return React.createElement(AccessibleImage, {
+ className: "folder",
+ });
+ }
+ if (item.type == "source") {
+ const { source, sourceActor } = item;
+ return React.createElement(SourceIcon, {
+ location: createLocation({
+ source,
+ sourceActor,
+ }),
+ modifier: icon => {
+ // In the SourceTree, extension files should use the file-extension based icon,
+ // whereas we use the extension icon in other Components (eg. source tabs and breakpoints pane).
+ if (icon === "extension") {
+ return sourceTypes[source.displayURL.fileExtension] || "javascript";
+ }
+ return icon + (this.props.isOverridden ? " override" : "");
+ },
+ });
+ }
+ return null;
+ }
+ renderItemName() {
+ const { item } = this.props;
+
+ if (item.type == "thread") {
+ const { thread } = item;
+ return (
+ thread.name +
+ (thread.serviceWorkerStatus ? ` (${thread.serviceWorkerStatus})` : "")
+ );
+ }
+ if (item.type == "group") {
+ return safeDecodeItemName(item.groupName);
+ }
+ if (item.type == "directory") {
+ const parentItem = this.props.getParent(item);
+ return safeDecodeItemName(
+ item.path.replace(parentItem.path, "").replace(/^\//, "")
+ );
+ }
+ if (item.type == "source") {
+ const { displayURL } = item.source;
+ const name =
+ displayURL.filename + (displayURL.search ? displayURL.search : "");
+ return safeDecodeItemName(name);
+ }
+
+ return null;
+ }
+
+ renderItemTooltip() {
+ const { item } = this.props;
+
+ if (item.type == "thread") {
+ return item.thread.name;
+ }
+ if (item.type == "group") {
+ return item.groupName;
+ }
+ if (item.type == "directory") {
+ return item.path;
+ }
+ if (item.type == "source") {
+ return item.source.url;
+ }
+
+ return null;
+ }
+
+ render() {
+ const { item, focused, hasMatchingGeneratedSource, hideIgnoredSources } =
+ this.props;
+
+ if (hideIgnoredSources && item.isBlackBoxed) {
+ return null;
+ }
+ const suffix = hasMatchingGeneratedSource
+ ? span(
+ {
+ className: "suffix",
+ },
+ L10N.getStr("sourceFooter.mappedSuffix")
+ )
+ : null;
+ return div(
+ {
+ className: classnames("node", {
+ focused,
+ blackboxed: item.type == "source" && item.isBlackBoxed,
+ }),
+ key: item.path,
+ onClick: this.onClick,
+ onContextMenu: this.onContextMenu,
+ title: this.renderItemTooltip(),
+ },
+ this.renderItemArrow(),
+ this.renderIcon(item),
+ span(
+ {
+ className: "label",
+ },
+ this.renderItemName(),
+ suffix
+ )
+ );
+ }
+}
+
+function getHasMatchingGeneratedSource(state, source) {
+ if (!source || !source.isOriginal) {
+ return false;
+ }
+
+ return !!getGeneratedSourceByURL(state, source.url);
+}
+
+const mapStateToProps = (state, props) => {
+ const { item } = props;
+ if (item.type == "source") {
+ const { source } = item;
+ return {
+ hasMatchingGeneratedSource: getHasMatchingGeneratedSource(state, source),
+ isOverridden: isSourceOverridden(state, source),
+ hideIgnoredSources: getHideIgnoredSources(state),
+ };
+ }
+ return {};
+};
+
+export default connect(mapStateToProps, {
+ showSourceTreeItemContextMenu: actions.showSourceTreeItemContextMenu,
+})(SourceTreeItem);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/index.js b/devtools/client/debugger/src/components/PrimaryPanes/index.js
new file mode 100644
index 0000000000..a8f6bc9a33
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/index.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import actions from "../../actions/index";
+import { getSelectedPrimaryPaneTab } from "../../selectors/index";
+import { prefs } from "../../utils/prefs";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { primaryPaneTabs } from "../../constants";
+
+import Outline from "./Outline";
+import SourcesTree from "./SourcesTree";
+import ProjectSearch from "./ProjectSearch";
+
+const {
+ TabPanel,
+ Tabs,
+} = require("resource://devtools/client/shared/components/tabs/Tabs.js");
+
+const tabs = [
+ primaryPaneTabs.SOURCES,
+ primaryPaneTabs.OUTLINE,
+ primaryPaneTabs.PROJECT_SEARCH,
+];
+
+class PrimaryPanes extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ alphabetizeOutline: prefs.alphabetizeOutline,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ projectRootName: PropTypes.string.isRequired,
+ selectedTab: PropTypes.oneOf(tabs).isRequired,
+ setPrimaryPaneTab: PropTypes.func.isRequired,
+ setActiveSearch: PropTypes.func.isRequired,
+ closeActiveSearch: PropTypes.func.isRequired,
+ };
+ }
+
+ onAlphabetizeClick = () => {
+ const alphabetizeOutline = !prefs.alphabetizeOutline;
+ prefs.alphabetizeOutline = alphabetizeOutline;
+ this.setState({ alphabetizeOutline });
+ };
+
+ onActivateTab = index => {
+ const tab = tabs.at(index);
+ this.props.setPrimaryPaneTab(tab);
+ if (tab == primaryPaneTabs.PROJECT_SEARCH) {
+ this.props.setActiveSearch(tab);
+ } else {
+ this.props.closeActiveSearch();
+ }
+ };
+
+ render() {
+ const { selectedTab } = this.props;
+ return React.createElement(
+ "aside",
+ {
+ className: "tab-panel sources-panel",
+ },
+ React.createElement(
+ Tabs,
+ {
+ activeTab: tabs.indexOf(selectedTab),
+ onAfterChange: this.onActivateTab,
+ },
+ React.createElement(
+ TabPanel,
+ {
+ id: "sources-tab",
+ key: `sources-tab${
+ selectedTab === primaryPaneTabs.SOURCES ? "-selected" : ""
+ }`,
+ className: "tab sources-tab",
+ title: L10N.getStr("sources.header"),
+ },
+ React.createElement(SourcesTree, null)
+ ),
+ React.createElement(
+ TabPanel,
+ {
+ id: "outline-tab",
+ key: `outline-tab${
+ selectedTab === primaryPaneTabs.OUTLINE ? "-selected" : ""
+ }`,
+ className: "tab outline-tab",
+ title: L10N.getStr("outline.header"),
+ },
+ React.createElement(Outline, {
+ alphabetizeOutline: this.state.alphabetizeOutline,
+ onAlphabetizeClick: this.onAlphabetizeClick,
+ })
+ ),
+ React.createElement(
+ TabPanel,
+ {
+ id: "search-tab",
+ key: `search-tab${
+ selectedTab === primaryPaneTabs.PROJECT_SEARCH ? "-selected" : ""
+ }`,
+ className: "tab search-tab",
+ title: L10N.getStr("search.header"),
+ },
+ React.createElement(ProjectSearch, null)
+ )
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ selectedTab: getSelectedPrimaryPaneTab(state),
+ };
+};
+
+const connector = connect(mapStateToProps, {
+ setPrimaryPaneTab: actions.setPrimaryPaneTab,
+ setActiveSearch: actions.setActiveSearch,
+ closeActiveSearch: actions.closeActiveSearch,
+});
+
+export default connector(PrimaryPanes);
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/moz.build b/devtools/client/debugger/src/components/PrimaryPanes/moz.build
new file mode 100644
index 0000000000..fc73b7bee7
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/moz.build
@@ -0,0 +1,15 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "index.js",
+ "Outline.js",
+ "OutlineFilter.js",
+ "ProjectSearch.js",
+ "SourcesTree.js",
+ "SourcesTreeItem.js",
+)
diff --git a/devtools/client/debugger/src/components/QuickOpenModal.css b/devtools/client/debugger/src/components/QuickOpenModal.css
new file mode 100644
index 0000000000..5a2627b99f
--- /dev/null
+++ b/devtools/client/debugger/src/components/QuickOpenModal.css
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.result-item .title .highlight {
+ font-weight: bold;
+ background-color: transparent;
+}
+
+.selected .highlight {
+ color: white;
+}
+
+.result-item .subtitle .highlight {
+ color: var(--grey-90);
+ font-weight: 500;
+ background-color: transparent;
+}
+
+.theme-dark .result-item .title .highlight,
+.theme-dark .result-item .subtitle .highlight {
+ color: white;
+}
+
+.loading-indicator {
+ padding: 5px 0 5px 0;
+ text-align: center;
+}
diff --git a/devtools/client/debugger/src/components/QuickOpenModal.js b/devtools/client/debugger/src/components/QuickOpenModal.js
new file mode 100644
index 0000000000..aa3d4f73b6
--- /dev/null
+++ b/devtools/client/debugger/src/components/QuickOpenModal.js
@@ -0,0 +1,508 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { basename } from "../utils/path";
+import { createLocation } from "../utils/location";
+
+const fuzzyAldrin = require("resource://devtools/client/shared/vendor/fuzzaldrin-plus.js");
+const { throttle } = require("resource://devtools/shared/throttle.js");
+
+import actions from "../actions/index";
+import {
+ getDisplayedSourcesList,
+ getQuickOpenQuery,
+ getQuickOpenType,
+ getSelectedLocation,
+ getSettledSourceTextContent,
+ getSourceTabs,
+ getBlackBoxRanges,
+ getProjectDirectoryRoot,
+} from "../selectors/index";
+import { memoizeLast } from "../utils/memoizeLast";
+import { searchKeys } from "../constants";
+import {
+ formatSymbol,
+ parseLineColumn,
+ formatShortcutResults,
+ formatSourceForList,
+} from "../utils/quick-open";
+import Modal from "./shared/Modal";
+import SearchInput from "./shared/SearchInput";
+import ResultList from "./shared/ResultList";
+
+const maxResults = 100;
+
+const SIZE_BIG = { size: "big" };
+const SIZE_DEFAULT = {};
+
+function filter(values, query, key = "value") {
+ const preparedQuery = fuzzyAldrin.prepareQuery(query);
+
+ return fuzzyAldrin.filter(values, query, {
+ key,
+ maxResults,
+ preparedQuery,
+ });
+}
+
+export class QuickOpenModal extends Component {
+ // Put it on the class so it can be retrieved in tests
+ static UPDATE_RESULTS_THROTTLE = 100;
+
+ constructor(props) {
+ super(props);
+ this.state = { results: null, selectedIndex: 0 };
+ }
+
+ static get propTypes() {
+ return {
+ closeQuickOpen: PropTypes.func.isRequired,
+ displayedSources: PropTypes.array.isRequired,
+ blackBoxRanges: PropTypes.object.isRequired,
+ highlightLineRange: PropTypes.func.isRequired,
+ clearHighlightLineRange: PropTypes.func.isRequired,
+ query: PropTypes.string.isRequired,
+ searchType: PropTypes.oneOf([
+ "functions",
+ "goto",
+ "gotoSource",
+ "other",
+ "shortcuts",
+ "sources",
+ "variables",
+ ]).isRequired,
+ selectSpecificLocation: PropTypes.func.isRequired,
+ selectedContentLoaded: PropTypes.bool,
+ selectedLocation: PropTypes.object,
+ setQuickOpenQuery: PropTypes.func.isRequired,
+ openedTabUrls: PropTypes.array.isRequired,
+ toggleShortcutsModal: PropTypes.func.isRequired,
+ projectDirectoryRoot: PropTypes.string,
+ getFunctionSymbols: PropTypes.func.isRequired,
+ };
+ }
+
+ setResults(results) {
+ if (results) {
+ results = results.slice(0, maxResults);
+ }
+ this.setState({ results });
+ }
+
+ componentDidMount() {
+ const { query, shortcutsModalEnabled, toggleShortcutsModal } = this.props;
+
+ this.updateResults(query);
+
+ if (shortcutsModalEnabled) {
+ toggleShortcutsModal();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const queryChanged = prevProps.query !== this.props.query;
+
+ if (queryChanged) {
+ this.updateResults(this.props.query);
+ }
+ }
+
+ closeModal = () => {
+ this.props.closeQuickOpen();
+ };
+
+ dropGoto = query => {
+ const index = query.indexOf(":");
+ return index !== -1 ? query.slice(0, index) : query;
+ };
+
+ formatSources = memoizeLast(
+ (displayedSources, openedTabUrls, blackBoxRanges, projectDirectoryRoot) => {
+ // Note that we should format all displayed sources,
+ // the actual filtering will only be done late from `searchSources()`
+ return displayedSources.map(source => {
+ const isBlackBoxed = !!blackBoxRanges[source.url];
+ const hasTabOpened = openedTabUrls.includes(source.url);
+ return formatSourceForList(
+ source,
+ hasTabOpened,
+ isBlackBoxed,
+ projectDirectoryRoot
+ );
+ });
+ }
+ );
+
+ searchSources = query => {
+ const {
+ displayedSources,
+ openedTabUrls,
+ blackBoxRanges,
+ projectDirectoryRoot,
+ } = this.props;
+
+ const sources = this.formatSources(
+ displayedSources,
+ openedTabUrls,
+ blackBoxRanges,
+ projectDirectoryRoot
+ );
+ const results =
+ query == "" ? sources : filter(sources, this.dropGoto(query));
+ return this.setResults(results);
+ };
+
+ searchSymbols = async query => {
+ const { getFunctionSymbols, selectedLocation } = this.props;
+ if (!selectedLocation) {
+ return this.setResults([]);
+ }
+ let results = await getFunctionSymbols(selectedLocation, maxResults);
+
+ if (query === "@" || query === "#") {
+ results = results.map(formatSymbol);
+ return this.setResults(results);
+ }
+ results = filter(results, query.slice(1), "name");
+ results = results.map(formatSymbol);
+ return this.setResults(results);
+ };
+
+ searchShortcuts = query => {
+ const results = formatShortcutResults();
+ if (query == "?") {
+ this.setResults(results);
+ } else {
+ this.setResults(filter(results, query.slice(1)));
+ }
+ };
+
+ /**
+ * This method is called when we just opened the modal and the query input is empty
+ */
+ showTopSources = () => {
+ const { openedTabUrls, blackBoxRanges, projectDirectoryRoot } = this.props;
+ let { displayedSources } = this.props;
+
+ // If there is some tabs opened, only show tab's sources.
+ // Otherwise, we display all visible sources (per SourceTree definition),
+ // setResults will restrict the number of results to a maximum limit.
+ if (openedTabUrls.length) {
+ displayedSources = displayedSources.filter(
+ source => !!source.url && openedTabUrls.includes(source.url)
+ );
+ }
+
+ this.setResults(
+ this.formatSources(
+ displayedSources,
+ openedTabUrls,
+ blackBoxRanges,
+ projectDirectoryRoot
+ )
+ );
+ };
+
+ updateResults = throttle(query => {
+ if (this.isGotoQuery()) {
+ return;
+ }
+
+ if (query == "" && !this.isShortcutQuery()) {
+ this.showTopSources();
+ return;
+ }
+
+ if (this.isSymbolSearch()) {
+ this.searchSymbols(query);
+ return;
+ }
+
+ if (this.isShortcutQuery()) {
+ this.searchShortcuts(query);
+ return;
+ }
+
+ this.searchSources(query);
+ }, QuickOpenModal.UPDATE_RESULTS_THROTTLE);
+
+ setModifier = item => {
+ if (["@", "#", ":"].includes(item.id)) {
+ this.props.setQuickOpenQuery(item.id);
+ }
+ };
+
+ selectResultItem = (e, item) => {
+ if (item == null) {
+ return;
+ }
+
+ if (this.isShortcutQuery()) {
+ this.setModifier(item);
+ return;
+ }
+
+ if (this.isGotoSourceQuery()) {
+ const location = parseLineColumn(this.props.query);
+ this.gotoLocation({ ...location, source: item.source });
+ return;
+ }
+
+ if (this.isSymbolSearch()) {
+ this.gotoLocation({
+ line:
+ item.location && item.location.start ? item.location.start.line : 0,
+ });
+ return;
+ }
+
+ this.gotoLocation({ source: item.source, line: 0 });
+ };
+
+ onSelectResultItem = item => {
+ const { selectedLocation, highlightLineRange, clearHighlightLineRange } =
+ this.props;
+ if (
+ selectedLocation == null ||
+ !this.isSymbolSearch() ||
+ !this.isFunctionQuery()
+ ) {
+ return;
+ }
+
+ if (item.location) {
+ highlightLineRange({
+ start: item.location.start.line,
+ end: item.location.end.line,
+ sourceId: selectedLocation.source.id,
+ });
+ } else {
+ clearHighlightLineRange();
+ }
+ };
+
+ traverseResults = e => {
+ const direction = e.key === "ArrowUp" ? -1 : 1;
+ const { selectedIndex, results } = this.state;
+ const resultCount = this.getResultCount();
+ const index = selectedIndex + direction;
+ const nextIndex = (index + resultCount) % resultCount || 0;
+
+ this.setState({ selectedIndex: nextIndex });
+
+ if (results != null) {
+ this.onSelectResultItem(results[nextIndex]);
+ }
+ };
+
+ gotoLocation = location => {
+ const { selectSpecificLocation, selectedLocation } = this.props;
+
+ if (location != null) {
+ selectSpecificLocation(
+ createLocation({
+ source: location.source || selectedLocation?.source,
+ line: location.line,
+ column: location.column,
+ })
+ );
+ this.closeModal();
+ }
+ };
+
+ onChange = e => {
+ const { selectedLocation, selectedContentLoaded, setQuickOpenQuery } =
+ this.props;
+ setQuickOpenQuery(e.target.value);
+ const noSource = !selectedLocation || !selectedContentLoaded;
+ if ((noSource && this.isSymbolSearch()) || this.isGotoQuery()) {
+ return;
+ }
+
+ // Wait for the next tick so that reducer updates are complete.
+ const targetValue = e.target.value;
+ setTimeout(() => this.updateResults(targetValue), 0);
+ };
+
+ onKeyDown = e => {
+ const { query } = this.props;
+ const { results, selectedIndex } = this.state;
+ const isGoToQuery = this.isGotoQuery();
+
+ if (!results && !isGoToQuery) {
+ return;
+ }
+
+ if (e.key === "Enter") {
+ if (isGoToQuery) {
+ const location = parseLineColumn(query);
+ this.gotoLocation(location);
+ return;
+ }
+
+ if (results) {
+ this.selectResultItem(e, results[selectedIndex]);
+ return;
+ }
+ }
+
+ if (e.key === "Tab") {
+ this.closeModal();
+ return;
+ }
+
+ if (["ArrowUp", "ArrowDown"].includes(e.key)) {
+ e.preventDefault();
+ this.traverseResults(e);
+ }
+ };
+
+ getResultCount = () => {
+ const { results } = this.state;
+ return results && results.length ? results.length : 0;
+ };
+
+ // Query helpers
+ isFunctionQuery = () => this.props.searchType === "functions";
+ isSymbolSearch = () => this.isFunctionQuery();
+ isGotoQuery = () => this.props.searchType === "goto";
+ isGotoSourceQuery = () => this.props.searchType === "gotoSource";
+ isShortcutQuery = () => this.props.searchType === "shortcuts";
+ isSourcesQuery = () => this.props.searchType === "sources";
+ isSourceSearch = () => this.isSourcesQuery() || this.isGotoSourceQuery();
+
+ /* eslint-disable react/no-danger */
+ renderHighlight(candidateString, query, name) {
+ const options = {
+ wrap: {
+ tagOpen: '<mark class="highlight">',
+ tagClose: "</mark>",
+ },
+ };
+ const html = fuzzyAldrin.wrap(candidateString, query, options);
+ return div({
+ dangerouslySetInnerHTML: {
+ __html: html,
+ },
+ });
+ }
+
+ highlightMatching = (query, results) => {
+ let newQuery = query;
+ if (newQuery === "") {
+ return results;
+ }
+ newQuery = query.replace(/[@:#?]/gi, " ");
+
+ return results.map(result => {
+ if (typeof result.title == "string") {
+ return {
+ ...result,
+ title: this.renderHighlight(
+ result.title,
+ basename(newQuery),
+ "title"
+ ),
+ };
+ }
+ return result;
+ });
+ };
+
+ shouldShowErrorEmoji() {
+ const { query } = this.props;
+ if (this.isGotoQuery()) {
+ return !/^:\d*$/.test(query);
+ }
+ return !!query && !this.getResultCount();
+ }
+
+ getSummaryMessage() {
+ let summaryMsg = "";
+ if (this.isGotoQuery()) {
+ summaryMsg = L10N.getStr("shortcuts.gotoLine");
+ } else if (this.isFunctionQuery() && !this.state.results) {
+ summaryMsg = L10N.getStr("loadingText");
+ }
+ return summaryMsg;
+ }
+
+ render() {
+ const { query } = this.props;
+ const { selectedIndex, results } = this.state;
+
+ const items = this.highlightMatching(query, results || []);
+ const expanded = !!items && !!items.length;
+ return React.createElement(
+ Modal,
+ {
+ handleClose: this.closeModal,
+ },
+ React.createElement(SearchInput, {
+ query: query,
+ hasPrefix: true,
+ count: this.getResultCount(),
+ placeholder: L10N.getStr("sourceSearch.search2"),
+ summaryMsg: this.getSummaryMessage(),
+ showErrorEmoji: this.shouldShowErrorEmoji(),
+ isLoading: false,
+ onChange: this.onChange,
+ onKeyDown: this.onKeyDown,
+ handleClose: this.closeModal,
+ expanded: expanded,
+ showClose: false,
+ searchKey: searchKeys.QUICKOPEN_SEARCH,
+ showExcludePatterns: false,
+ showSearchModifiers: false,
+ selectedItemId:
+ expanded && items[selectedIndex] ? items[selectedIndex].id : "",
+ ...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT),
+ }),
+ results &&
+ React.createElement(ResultList, {
+ key: "results",
+ items: items,
+ selected: selectedIndex,
+ selectItem: this.selectResultItem,
+ ref: "resultList",
+ expanded: expanded,
+ ...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT),
+ })
+ );
+ }
+}
+
+/* istanbul ignore next: ignoring testing of redux connection stuff */
+function mapStateToProps(state) {
+ const selectedLocation = getSelectedLocation(state);
+ const displayedSources = getDisplayedSourcesList(state);
+ const tabs = getSourceTabs(state);
+ const openedTabUrls = [...new Set(tabs.map(tab => tab.url))];
+
+ return {
+ displayedSources,
+ blackBoxRanges: getBlackBoxRanges(state),
+ projectDirectoryRoot: getProjectDirectoryRoot(state),
+ selectedLocation,
+ selectedContentLoaded: selectedLocation
+ ? !!getSettledSourceTextContent(state, selectedLocation)
+ : undefined,
+ query: getQuickOpenQuery(state),
+ searchType: getQuickOpenType(state),
+ openedTabUrls,
+ };
+}
+
+export default connect(mapStateToProps, {
+ selectSpecificLocation: actions.selectSpecificLocation,
+ setQuickOpenQuery: actions.setQuickOpenQuery,
+ highlightLineRange: actions.highlightLineRange,
+ clearHighlightLineRange: actions.clearHighlightLineRange,
+ closeQuickOpen: actions.closeQuickOpen,
+ getFunctionSymbols: actions.getFunctionSymbols,
+})(QuickOpenModal);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js
new file mode 100644
index 0000000000..c55088e411
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js
@@ -0,0 +1,235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ input,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { createSelector } from "devtools/client/shared/vendor/reselect";
+import actions from "../../../actions/index";
+
+import { CloseButton } from "../../shared/Button/index";
+
+import {
+ getSelectedText,
+ makeBreakpointId,
+} from "../../../utils/breakpoint/index";
+import { getSelectedLocation } from "../../../utils/selected-location";
+import { isLineBlackboxed } from "../../../utils/source";
+
+import {
+ getSelectedFrame,
+ getSelectedSource,
+ getCurrentThread,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+ getBlackBoxRanges,
+} from "../../../selectors/index";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class Breakpoint extends PureComponent {
+ static get propTypes() {
+ return {
+ breakpoint: PropTypes.object.isRequired,
+ disableBreakpoint: PropTypes.func.isRequired,
+ editor: PropTypes.object.isRequired,
+ enableBreakpoint: PropTypes.func.isRequired,
+ frame: PropTypes.object,
+ openConditionalPanel: PropTypes.func.isRequired,
+ removeBreakpoint: PropTypes.func.isRequired,
+ selectSpecificLocation: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object,
+ source: PropTypes.object.isRequired,
+ blackboxedRangesForSource: PropTypes.array.isRequired,
+ checkSourceOnIgnoreList: PropTypes.func.isRequired,
+ isBreakpointLineBlackboxed: PropTypes.bool,
+ showBreakpointContextMenu: PropTypes.func.isRequired,
+ };
+ }
+
+ onContextMenu = event => {
+ event.preventDefault();
+
+ this.props.showBreakpointContextMenu(
+ event,
+ this.props.breakpoint,
+ this.props.source
+ );
+ };
+
+ get selectedLocation() {
+ const { breakpoint, selectedSource } = this.props;
+ return getSelectedLocation(breakpoint, selectedSource);
+ }
+
+ stopClicks = event => event.stopPropagation();
+
+ onDoubleClick = () => {
+ const { breakpoint, openConditionalPanel } = this.props;
+ if (breakpoint.options.condition) {
+ openConditionalPanel(this.selectedLocation);
+ } else if (breakpoint.options.logValue) {
+ openConditionalPanel(this.selectedLocation, true);
+ }
+ };
+
+ selectBreakpoint = event => {
+ event.preventDefault();
+ const { selectSpecificLocation } = this.props;
+ selectSpecificLocation(this.selectedLocation);
+ };
+
+ removeBreakpoint = event => {
+ const { removeBreakpoint, breakpoint } = this.props;
+ event.stopPropagation();
+ removeBreakpoint(breakpoint);
+ };
+
+ handleBreakpointCheckbox = () => {
+ const { breakpoint, enableBreakpoint, disableBreakpoint } = this.props;
+ if (breakpoint.disabled) {
+ enableBreakpoint(breakpoint);
+ } else {
+ disableBreakpoint(breakpoint);
+ }
+ };
+
+ isCurrentlyPausedAtBreakpoint() {
+ const { frame } = this.props;
+ if (!frame) {
+ return false;
+ }
+
+ const bpId = makeBreakpointId(this.selectedLocation);
+ const frameId = makeBreakpointId(frame.selectedLocation);
+ return bpId == frameId;
+ }
+
+ getBreakpointLocation() {
+ const { source } = this.props;
+ const { column, line } = this.selectedLocation;
+
+ const isWasm = source?.isWasm;
+ // column is 0-based everywhere, but we want to display 1-based to the user.
+ const columnVal = column ? `:${column + 1}` : "";
+ const bpLocation = isWasm
+ ? `0x${line.toString(16).toUpperCase()}`
+ : `${line}${columnVal}`;
+
+ return bpLocation;
+ }
+
+ getBreakpointText() {
+ const { breakpoint, selectedSource } = this.props;
+ const { condition, logValue } = breakpoint.options;
+ return logValue || condition || getSelectedText(breakpoint, selectedSource);
+ }
+
+ highlightText(text = "", editor) {
+ const node = document.createElement("div");
+ editor.CodeMirror.runMode(text, "application/javascript", node);
+ return { __html: node.innerHTML };
+ }
+
+ render() {
+ const { breakpoint, editor, isBreakpointLineBlackboxed } = this.props;
+ const text = this.getBreakpointText();
+ const labelId = `${breakpoint.id}-label`;
+ return div(
+ {
+ className: classnames({
+ breakpoint,
+ paused: this.isCurrentlyPausedAtBreakpoint(),
+ disabled: breakpoint.disabled,
+ "is-conditional": !!breakpoint.options.condition,
+ "is-log": !!breakpoint.options.logValue,
+ }),
+ onClick: this.selectBreakpoint,
+ onDoubleClick: this.onDoubleClick,
+ onContextMenu: this.onContextMenu,
+ },
+ input({
+ id: breakpoint.id,
+ type: "checkbox",
+ className: "breakpoint-checkbox",
+ checked: !breakpoint.disabled,
+ disabled: isBreakpointLineBlackboxed,
+ onChange: this.handleBreakpointCheckbox,
+ onClick: this.stopClicks,
+ "aria-labelledby": labelId,
+ }),
+ span(
+ {
+ id: labelId,
+ className: "breakpoint-label cm-s-mozilla devtools-monospace",
+ onClick: this.selectBreakpoint,
+ title: text,
+ },
+ span({
+ dangerouslySetInnerHTML: this.highlightText(text, editor),
+ })
+ ),
+ div(
+ {
+ className: "breakpoint-line-close",
+ },
+ div(
+ {
+ className: "breakpoint-line devtools-monospace",
+ },
+ this.getBreakpointLocation()
+ ),
+ React.createElement(CloseButton, {
+ handleClick: this.removeBreakpoint,
+ tooltip: L10N.getStr("breakpoints.removeBreakpointTooltip"),
+ })
+ )
+ );
+ }
+}
+
+const getFormattedFrame = createSelector(
+ getSelectedSource,
+ getSelectedFrame,
+ (selectedSource, frame) => {
+ if (!frame) {
+ return null;
+ }
+
+ return {
+ ...frame,
+ selectedLocation: getSelectedLocation(frame, selectedSource),
+ };
+ }
+);
+
+const mapStateToProps = (state, props) => {
+ const blackboxedRangesForSource = getBlackBoxRanges(state)[props.source.url];
+ const isSourceOnIgnoreList =
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, props.source);
+ return {
+ selectedSource: getSelectedSource(state),
+ isBreakpointLineBlackboxed: isLineBlackboxed(
+ blackboxedRangesForSource,
+ props.breakpoint.location.line,
+ isSourceOnIgnoreList
+ ),
+ frame: getFormattedFrame(state, getCurrentThread(state)),
+ };
+};
+
+export default connect(mapStateToProps, {
+ enableBreakpoint: actions.enableBreakpoint,
+ removeBreakpoint: actions.removeBreakpoint,
+ disableBreakpoint: actions.disableBreakpoint,
+ selectSpecificLocation: actions.selectSpecificLocation,
+ openConditionalPanel: actions.openConditionalPanel,
+ showBreakpointContextMenu: actions.showBreakpointContextMenu,
+})(Breakpoint);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js
new file mode 100644
index 0000000000..78cc530cff
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import { div, span } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../../actions/index";
+
+import {
+ getTruncatedFileName,
+ getDisplayPath,
+ getSourceQueryString,
+ getFileURL,
+} from "../../../utils/source";
+import { createLocation } from "../../../utils/location";
+import { getFirstSourceActorForGeneratedSource } from "../../../selectors/index";
+
+import SourceIcon from "../../shared/SourceIcon";
+
+class BreakpointHeading extends PureComponent {
+ static get propTypes() {
+ return {
+ sources: PropTypes.array.isRequired,
+ source: PropTypes.object.isRequired,
+ firstSourceActor: PropTypes.object,
+ selectSource: PropTypes.func.isRequired,
+ showBreakpointHeadingContextMenu: PropTypes.func.isRequired,
+ };
+ }
+ onContextMenu = event => {
+ event.preventDefault();
+
+ this.props.showBreakpointHeadingContextMenu(event, this.props.source);
+ };
+
+ render() {
+ const { sources, source, selectSource } = this.props;
+
+ const path = getDisplayPath(source, sources);
+ const query = getSourceQueryString(source);
+ return div(
+ {
+ className: "breakpoint-heading",
+ title: getFileURL(source, false),
+ onClick: () => selectSource(source),
+ onContextMenu: this.onContextMenu,
+ },
+ React.createElement(
+ SourceIcon,
+ // Breakpoints are displayed per source and may relate to many source actors.
+ // Arbitrarily pick the first source actor to compute the matching source icon
+ // The source actor is used to pick one specific source text content and guess
+ // the related framework icon.
+ {
+ location: createLocation({
+ source,
+ sourceActor: this.props.firstSourceActor,
+ }),
+ modifier: icon =>
+ ["file", "javascript"].includes(icon) ? null : icon,
+ }
+ ),
+ div(
+ {
+ className: "filename",
+ },
+ getTruncatedFileName(source, query),
+ path && span(null, `../${path}/..`)
+ )
+ );
+ }
+}
+
+const mapStateToProps = (state, { source }) => ({
+ firstSourceActor: getFirstSourceActorForGeneratedSource(state, source.id),
+});
+
+export default connect(mapStateToProps, {
+ selectSource: actions.selectSource,
+ showBreakpointHeadingContextMenu: actions.showBreakpointHeadingContextMenu,
+})(BreakpointHeading);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css
new file mode 100644
index 0000000000..c05dd0b53f
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css
@@ -0,0 +1,235 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.breakpoints-pane > ._content {
+ overflow-x: auto;
+}
+
+.breakpoints-options *,
+.breakpoints-list * {
+ user-select: none;
+}
+
+.breakpoints-list {
+ padding: 4px 0;
+}
+
+.breakpoints-list .breakpoint-heading {
+ text-overflow: ellipsis;
+ width: 100%;
+ font-size: 12px;
+ line-height: 16px;
+}
+
+.breakpoint-heading:not(:first-child) {
+ margin-top: 2px;
+}
+
+.breakpoints-list .breakpoint-heading .filename {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.breakpoints-list .breakpoint-heading .filename span {
+ opacity: 0.7;
+ padding-left: 4px;
+}
+
+.breakpoints-list .breakpoint-heading,
+.breakpoints-list .breakpoint {
+ color: var(--theme-text-color-strong);
+ position: relative;
+ cursor: pointer;
+}
+
+.breakpoints-list .breakpoint-heading,
+.breakpoints-list .breakpoint,
+.breakpoints-options > * {
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ padding-top: 2px;
+ padding-bottom: 2px;
+ padding-inline-start: 16px;
+ padding-inline-end: 12px;
+}
+
+.breakpoints-exceptions-caught {
+ padding-bottom: 3px;
+ padding-top: 3px;
+ padding-inline-start: 36px;
+}
+
+.breakpoints-options {
+ padding-top: 4px;
+ padding-bottom: 4px;
+}
+
+.xhr-breakpoints-pane .breakpoints-options {
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.breakpoints-options:not(.empty) {
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.breakpoints-options input {
+ padding-inline-start: 2px;
+ margin-top: 0px;
+ margin-bottom: 0px;
+ margin-inline-start: 0;
+ margin-inline-end: 2px;
+ vertical-align: text-bottom;
+}
+
+.breakpoint-exceptions-label {
+ line-height: 14px;
+ padding-inline-end: 8px;
+ cursor: default;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.breakpoints-list .breakpoint,
+.breakpoints-list .breakpoint-heading,
+.breakpoints-options {
+ border-inline-start: 4px solid transparent
+}
+
+html .breakpoints-list .breakpoint.is-conditional {
+ border-inline-start-color: var(--theme-graphs-yellow);
+}
+
+html .breakpoints-list .breakpoint.is-log {
+ border-inline-start-color: var(--theme-graphs-purple);
+}
+
+html .breakpoints-list .breakpoint.paused {
+ background-color: var(--theme-toolbar-background-alt);
+ border-color: var(--breakpoint-active-color);
+}
+
+.breakpoints-list .breakpoint:hover {
+ background-color: var(--search-overlays-semitransparent);
+}
+
+.breakpoint-line-close {
+ margin-inline-start: 4px;
+}
+
+.breakpoints-list .breakpoint .breakpoint-line {
+ font-size: 11px;
+ color: var(--theme-comment);
+ min-width: 16px;
+ text-align: end;
+ padding-top: 1px;
+ padding-bottom: 1px;
+}
+
+.breakpoints-list .breakpoint:hover .breakpoint-line,
+.breakpoints-list .breakpoint-line-close:focus-within .breakpoint-line {
+ color: transparent;
+}
+
+.breakpoints-list .breakpoint.paused:hover {
+ border-color: var(--breakpoint-active-color-hover);
+}
+
+.breakpoints-list .breakpoint-label {
+ display: inline-block;
+ cursor: pointer;
+ flex-grow: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ font-size: 11px;
+}
+
+.breakpoints-list .breakpoint-label span,
+.breakpoint-line-close {
+ display: inline;
+ line-height: 14px;
+}
+
+.breakpoint-checkbox {
+ margin-inline-start: 0px;
+ margin-top: 0px;
+ margin-bottom: 0px;
+ vertical-align: text-bottom;
+}
+
+.breakpoint-label .location {
+ width: 100%;
+ display: inline-block;
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ padding: 1px 0;
+ vertical-align: bottom;
+}
+
+.breakpoints-list .pause-indicator {
+ flex: 0 1 content;
+ order: 3;
+}
+
+.breakpoint .close-btn {
+ position: absolute;
+ /* hide button outside of row until hovered or focused */
+ top: -100px;
+}
+
+[dir="ltr"] .breakpoint .close-btn {
+ right: 12px;
+}
+
+[dir="rtl"] .breakpoint .close-btn {
+ left: 12px;
+}
+
+/* Reveal the remove button on hover/focus */
+.breakpoint:hover .close-btn,
+.breakpoint .close-btn:focus {
+ top: calc(50% - 8px);
+}
+
+/* Hide the line number when revealing the remove button (since they're overlayed) */
+.breakpoint-line-close:focus-within .breakpoint-line,
+.breakpoint:hover .breakpoint-line {
+ visibility: hidden;
+}
+
+.CodeMirror.cm-s-mozilla-breakpoint {
+ cursor: pointer;
+}
+
+.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-lines {
+ padding: 0;
+}
+
+.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-sizer {
+ min-width: initial !important;
+}
+
+.breakpoints-list .breakpoint .CodeMirror.cm-s-mozilla-breakpoint {
+ transition: opacity 0.15s linear;
+}
+
+.breakpoints-list .breakpoint.disabled .CodeMirror.cm-s-mozilla-breakpoint {
+ opacity: 0.5;
+}
+
+.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-line span[role="presentation"] {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-block;
+}
+
+.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-code,
+.CodeMirror.cm-s-mozilla-breakpoint .CodeMirror-scroll {
+ pointer-events: none;
+}
+
+.CodeMirror.cm-s-mozilla-breakpoint {
+ padding-top: 1px;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js
new file mode 100644
index 0000000000..31ff3f44a3
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+import {
+ div,
+ input,
+ label,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+export default function ExceptionOption({
+ className,
+ isChecked = false,
+ label: inputLabel,
+ onChange,
+}) {
+ return label(
+ {
+ className,
+ },
+ input({
+ type: "checkbox",
+ checked: isChecked,
+ onChange: onChange,
+ }),
+ div(
+ {
+ className: "breakpoint-exceptions-label",
+ },
+ inputLabel
+ )
+ );
+}
+
+ExceptionOption.propTypes = {
+ className: PropTypes.string.isRequired,
+ isChecked: PropTypes.bool.isRequired,
+ label: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+};
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js
new file mode 100644
index 0000000000..0f5d6f7ae3
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import ExceptionOption from "./ExceptionOption";
+
+import Breakpoint from "./Breakpoint";
+import BreakpointHeading from "./BreakpointHeading";
+
+import actions from "../../../actions/index";
+import { getSelectedLocation } from "../../../utils/selected-location";
+import { createHeadlessEditor } from "../../../utils/editor/create-editor";
+
+import { makeBreakpointId } from "../../../utils/breakpoint/index";
+
+import {
+ getSelectedSource,
+ getBreakpointSources,
+ getShouldPauseOnDebuggerStatement,
+ getShouldPauseOnExceptions,
+ getShouldPauseOnCaughtExceptions,
+} from "../../../selectors/index";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class Breakpoints extends Component {
+ static get propTypes() {
+ return {
+ breakpointSources: PropTypes.array.isRequired,
+ pauseOnExceptions: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object,
+ shouldPauseOnDebuggerStatement: PropTypes.bool.isRequired,
+ shouldPauseOnCaughtExceptions: PropTypes.bool.isRequired,
+ shouldPauseOnExceptions: PropTypes.bool.isRequired,
+ };
+ }
+
+ componentWillUnmount() {
+ this.removeEditor();
+ }
+
+ getEditor() {
+ if (!this.headlessEditor) {
+ this.headlessEditor = createHeadlessEditor();
+ }
+ return this.headlessEditor;
+ }
+
+ removeEditor() {
+ if (!this.headlessEditor) {
+ return;
+ }
+ this.headlessEditor.destroy();
+ this.headlessEditor = null;
+ }
+
+ togglePauseOnDebuggerStatement = () => {
+ this.props.pauseOnDebuggerStatement(
+ !this.props.shouldPauseOnDebuggerStatement
+ );
+ };
+
+ togglePauseOnException = () => {
+ this.props.pauseOnExceptions(!this.props.shouldPauseOnExceptions, false);
+ };
+
+ togglePauseOnCaughtException = () => {
+ this.props.pauseOnExceptions(
+ true,
+ !this.props.shouldPauseOnCaughtExceptions
+ );
+ };
+
+ renderExceptionsOptions() {
+ const {
+ breakpointSources,
+ shouldPauseOnDebuggerStatement,
+ shouldPauseOnExceptions,
+ shouldPauseOnCaughtExceptions,
+ } = this.props;
+
+ const isEmpty = !breakpointSources.length;
+ return div(
+ {
+ className: classnames("breakpoints-options", {
+ empty: isEmpty,
+ }),
+ },
+ React.createElement(ExceptionOption, {
+ className: "breakpoints-debugger-statement",
+ label: L10N.getStr("pauseOnDebuggerStatement"),
+ isChecked: shouldPauseOnDebuggerStatement,
+ onChange: this.togglePauseOnDebuggerStatement,
+ }),
+ React.createElement(ExceptionOption, {
+ className: "breakpoints-exceptions",
+ label: L10N.getStr("pauseOnExceptionsItem2"),
+ isChecked: shouldPauseOnExceptions,
+ onChange: this.togglePauseOnException,
+ }),
+ shouldPauseOnExceptions &&
+ React.createElement(ExceptionOption, {
+ className: "breakpoints-exceptions-caught",
+ label: L10N.getStr("pauseOnCaughtExceptionsItem"),
+ isChecked: shouldPauseOnCaughtExceptions,
+ onChange: this.togglePauseOnCaughtException,
+ })
+ );
+ }
+
+ renderBreakpoints() {
+ const { breakpointSources, selectedSource } = this.props;
+ if (!breakpointSources.length) {
+ return null;
+ }
+
+ const editor = this.getEditor();
+ const sources = breakpointSources.map(({ source }) => source);
+ return div(
+ {
+ className: "pane breakpoints-list",
+ },
+ breakpointSources.map(({ source, breakpoints }) => {
+ return [
+ React.createElement(BreakpointHeading, {
+ key: source.id,
+ source,
+ sources,
+ }),
+ breakpoints.map(breakpoint =>
+ React.createElement(Breakpoint, {
+ breakpoint,
+ source,
+ editor,
+ key: makeBreakpointId(
+ getSelectedLocation(breakpoint, selectedSource)
+ ),
+ })
+ ),
+ ];
+ })
+ );
+ }
+
+ render() {
+ return div(
+ {
+ className: "pane",
+ },
+ this.renderExceptionsOptions(),
+ this.renderBreakpoints()
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ breakpointSources: getBreakpointSources(state),
+ selectedSource: getSelectedSource(state),
+ shouldPauseOnDebuggerStatement: getShouldPauseOnDebuggerStatement(state),
+ shouldPauseOnExceptions: getShouldPauseOnExceptions(state),
+ shouldPauseOnCaughtExceptions: getShouldPauseOnCaughtExceptions(state),
+});
+
+export default connect(mapStateToProps, {
+ pauseOnDebuggerStatement: actions.pauseOnDebuggerStatement,
+ pauseOnExceptions: actions.pauseOnExceptions,
+})(Breakpoints);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build
new file mode 100644
index 0000000000..85716d122c
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "Breakpoint.js",
+ "BreakpointHeading.js",
+ "ExceptionOption.js",
+ "index.js",
+)
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js
new file mode 100644
index 0000000000..51f0b1e948
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/ExceptionOption.spec.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import ExceptionOption from "../ExceptionOption";
+
+describe("ExceptionOption renders", () => {
+ it("with values", () => {
+ const component = shallow(
+ React.createElement(ExceptionOption, {
+ label: "testLabel",
+ isChecked: true,
+ onChange: () => null,
+ className: "testClassName",
+ })
+ );
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap
new file mode 100644
index 0000000000..3ed80783b6
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExceptionOption renders with values 1`] = `
+<label
+ className="testClassName"
+>
+ <input
+ checked={true}
+ onChange={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="breakpoint-exceptions-label"
+ >
+ testLabel
+ </div>
+</label>
+`;
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css
new file mode 100644
index 0000000000..68bd0bfcdd
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.command-bar {
+ flex: 0 0 29px;
+ border-bottom: 1px solid var(--theme-splitter-color);
+ display: flex;
+ overflow: hidden;
+ z-index: 1;
+ background-color: var(--theme-toolbar-background);
+}
+
+html[dir="rtl"] .command-bar {
+ border-right: 1px solid var(--theme-splitter-color);
+}
+
+.command-bar .filler {
+ flex-grow: 1;
+}
+
+.command-bar .step-position {
+ color: var(--theme-text-color-inactive);
+ padding-top: 8px;
+ margin-inline-end: 4px;
+}
+
+.command-bar .divider {
+ width: 1px;
+ background: var(--theme-splitter-color);
+ height: 10px;
+ margin: 11px 6px 0 6px;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js
new file mode 100644
index 0000000000..deae156a40
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js
@@ -0,0 +1,502 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div, button } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { features, prefs } from "../../utils/prefs";
+import {
+ getIsWaitingOnBreak,
+ getSkipPausing,
+ getCurrentThread,
+ isTopFrameSelected,
+ getIsCurrentThreadPaused,
+ getIsJavascriptTracingEnabled,
+ getIsThreadCurrentlyTracing,
+ getJavascriptTracingLogMethod,
+ getJavascriptTracingValues,
+ getJavascriptTracingOnNextInteraction,
+ getJavascriptTracingOnNextLoad,
+ getJavascriptTracingFunctionReturn,
+} from "../../selectors/index";
+import { formatKeyShortcut } from "../../utils/text";
+import actions from "../../actions/index";
+import { debugBtn } from "../shared/Button/CommandBarButton";
+import AccessibleImage from "../shared/AccessibleImage";
+import { showMenu } from "../../context-menu/menu";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const MenuButton = require("resource://devtools/client/shared/components/menu/MenuButton.js");
+const MenuItem = require("resource://devtools/client/shared/components/menu/MenuItem.js");
+const MenuList = require("resource://devtools/client/shared/components/menu/MenuList.js");
+
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+// NOTE: the "resume" command will call either the resume or breakOnNext action
+// depending on whether or not the debugger is paused or running
+const COMMANDS = ["resume", "stepOver", "stepIn", "stepOut"];
+
+const KEYS = {
+ WINNT: {
+ resume: "F8",
+ stepOver: "F10",
+ stepIn: "F11",
+ stepOut: "Shift+F11",
+ trace: "Ctrl+Shift+5",
+ },
+ Darwin: {
+ resume: "Cmd+\\",
+ stepOver: "Cmd+'",
+ stepIn: "Cmd+;",
+ stepOut: "Cmd+Shift+:",
+ stepOutDisplay: "Cmd+Shift+;",
+ trace: "Ctrl+Shift+5",
+ },
+ Linux: {
+ resume: "F8",
+ stepOver: "F10",
+ stepIn: "F11",
+ stepOut: "Shift+F11",
+ trace: "Ctrl+Shift+5",
+ },
+};
+
+const LOG_METHODS = {
+ CONSOLE: "console",
+ STDOUT: "stdout",
+};
+
+function getKey(action) {
+ return getKeyForOS(Services.appinfo.OS, action);
+}
+
+function getKeyForOS(os, action) {
+ const osActions = KEYS[os] || KEYS.Linux;
+ return osActions[action];
+}
+
+function formatKey(action) {
+ const key = getKey(`${action}Display`) || getKey(action);
+
+ // On MacOS, we bind both Windows and MacOS/Darwin key shortcuts
+ // Display them both, but only when they are different
+ if (isMacOS) {
+ const winKey =
+ getKeyForOS("WINNT", `${action}Display`) || getKeyForOS("WINNT", action);
+ if (key != winKey) {
+ return formatKeyShortcut([key, winKey].join(" "));
+ }
+ }
+ return formatKeyShortcut(key);
+}
+
+class CommandBar extends Component {
+ constructor() {
+ super();
+
+ this.state = {};
+ }
+ static get propTypes() {
+ return {
+ breakOnNext: PropTypes.func.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ isTracingEnabled: PropTypes.bool.isRequired,
+ isWaitingOnBreak: PropTypes.bool.isRequired,
+ javascriptEnabled: PropTypes.bool.isRequired,
+ trace: PropTypes.func.isRequired,
+ resume: PropTypes.func.isRequired,
+ skipPausing: PropTypes.bool.isRequired,
+ stepIn: PropTypes.func.isRequired,
+ stepOut: PropTypes.func.isRequired,
+ stepOver: PropTypes.func.isRequired,
+ toggleEditorWrapping: PropTypes.func.isRequired,
+ toggleInlinePreview: PropTypes.func.isRequired,
+ toggleJavaScriptEnabled: PropTypes.func.isRequired,
+ toggleSkipPausing: PropTypes.any.isRequired,
+ toggleSourceMapsEnabled: PropTypes.func.isRequired,
+ topFrameSelected: PropTypes.bool.isRequired,
+ toggleTracing: PropTypes.func.isRequired,
+ logMethod: PropTypes.string.isRequired,
+ logValues: PropTypes.bool.isRequired,
+ traceOnNextInteraction: PropTypes.bool.isRequired,
+ setJavascriptTracingLogMethod: PropTypes.func.isRequired,
+ setHideOrShowIgnoredSources: PropTypes.func.isRequired,
+ toggleSourceMapIgnoreList: PropTypes.func.isRequired,
+ };
+ }
+
+ componentWillUnmount() {
+ const { shortcuts } = this.context;
+
+ COMMANDS.forEach(action => shortcuts.off(getKey(action)));
+
+ if (isMacOS) {
+ COMMANDS.forEach(action => shortcuts.off(getKeyForOS("WINNT", action)));
+ }
+ }
+
+ componentDidMount() {
+ const { shortcuts } = this.context;
+
+ COMMANDS.forEach(action =>
+ shortcuts.on(getKey(action), e => this.handleEvent(e, action))
+ );
+
+ if (isMacOS) {
+ // The Mac supports both the Windows Function keys
+ // as well as the Mac non-Function keys
+ COMMANDS.forEach(action =>
+ shortcuts.on(getKeyForOS("WINNT", action), e =>
+ this.handleEvent(e, action)
+ )
+ );
+ }
+ }
+
+ handleEvent(e, action) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (action === "resume") {
+ this.props.isPaused ? this.props.resume() : this.props.breakOnNext();
+ } else {
+ this.props[action]();
+ }
+ }
+
+ renderStepButtons() {
+ const { isPaused, topFrameSelected } = this.props;
+ const className = isPaused ? "active" : "disabled";
+ const isDisabled = !isPaused;
+
+ return [
+ this.renderPauseButton(),
+ debugBtn(
+ () => this.props.stepOver(),
+ "stepOver",
+ className,
+ L10N.getFormatStr("stepOverTooltip", formatKey("stepOver")),
+ isDisabled
+ ),
+ debugBtn(
+ () => this.props.stepIn(),
+ "stepIn",
+ className,
+ L10N.getFormatStr("stepInTooltip", formatKey("stepIn")),
+ isDisabled || !topFrameSelected
+ ),
+ debugBtn(
+ () => this.props.stepOut(),
+ "stepOut",
+ className,
+ L10N.getFormatStr("stepOutTooltip", formatKey("stepOut")),
+ isDisabled
+ ),
+ ];
+ }
+
+ resume() {
+ this.props.resume();
+ }
+
+ renderTraceButton() {
+ if (!features.javascriptTracing) {
+ return null;
+ }
+
+ // The button is highlighted in blue as soon as the user requested to start the trace
+ const isActive = this.props.isTracingEnabled;
+ // But it will only be active once the tracer actually started.
+ // This may come later when using "on next user interaction" feature.
+ const isPending = isActive && !this.props.isTracingActive;
+
+ let className = "";
+ if (isPending) {
+ className = "pending";
+ } else if (isActive) {
+ className = "active";
+ }
+ // Display a button which:
+ // - on left click, would toggle on/off javascript tracing
+ // - on right click, would display a context menu to configure the tracer settings
+ return button({
+ className: `devtools-button command-bar-button debugger-trace-menu-button ${className}`,
+ title: this.props.isTracingEnabled
+ ? L10N.getFormatStr("stopTraceButtonTooltip2", formatKey("trace"))
+ : L10N.getFormatStr(
+ "startTraceButtonTooltip2",
+ formatKey("trace"),
+ this.props.logMethod
+ ),
+ onClick: event => {
+ this.props.toggleTracing();
+ },
+ onContextMenu: event => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ // Avoid showing the menu to avoid having to support changing tracing config "live"
+ if (this.props.isTracingEnabled) {
+ return;
+ }
+ const items = [
+ {
+ id: "debugger-trace-menu-item-console",
+ label: L10N.getStr("traceInWebConsole"),
+ checked: this.props.logMethod == LOG_METHODS.CONSOLE,
+ type: "radio",
+ click: () => {
+ this.props.setJavascriptTracingLogMethod(LOG_METHODS.CONSOLE);
+ },
+ },
+ {
+ id: "debugger-trace-menu-item-stdout",
+ label: L10N.getStr("traceInStdout"),
+ type: "radio",
+ checked: this.props.logMethod == LOG_METHODS.STDOUT,
+ click: () => {
+ this.props.setJavascriptTracingLogMethod(LOG_METHODS.STDOUT);
+ },
+ },
+ { type: "separator" },
+ {
+ id: "debugger-trace-menu-item-next-interaction",
+ label: L10N.getStr("traceOnNextInteraction"),
+ type: "checkbox",
+ checked: this.props.traceOnNextInteraction,
+ click: () => {
+ this.props.toggleJavascriptTracingOnNextInteraction();
+ },
+ },
+ {
+ id: "debugger-trace-menu-item-next-load",
+ label: L10N.getStr("traceOnNextLoad"),
+ type: "checkbox",
+ checked: this.props.traceOnNextLoad,
+ click: () => {
+ this.props.toggleJavascriptTracingOnNextLoad();
+ },
+ },
+ { type: "separator" },
+ {
+ id: "debugger-trace-menu-item-log-values",
+ label: L10N.getStr("traceValues"),
+ type: "checkbox",
+ checked: this.props.logValues,
+ click: () => {
+ this.props.toggleJavascriptTracingValues();
+ },
+ },
+ {
+ id: "debugger-trace-menu-item-function-return",
+ label: L10N.getStr("traceFunctionReturn"),
+ type: "checkbox",
+ checked: this.props.traceFunctionReturn,
+ click: () => {
+ this.props.toggleJavascriptTracingFunctionReturn();
+ },
+ },
+ ];
+ showMenu(event, items);
+ },
+ });
+ }
+ renderPauseButton() {
+ const { breakOnNext, isWaitingOnBreak } = this.props;
+
+ if (this.props.isPaused) {
+ return debugBtn(
+ () => this.resume(),
+ "resume",
+ "active",
+ L10N.getFormatStr("resumeButtonTooltip", formatKey("resume"))
+ );
+ }
+
+ if (isWaitingOnBreak) {
+ return debugBtn(
+ null,
+ "pause",
+ "disabled",
+ L10N.getStr("pausePendingButtonTooltip"),
+ true
+ );
+ }
+
+ return debugBtn(
+ () => breakOnNext(),
+ "pause",
+ "active",
+ L10N.getFormatStr("pauseButtonTooltip", formatKey("resume"))
+ );
+ }
+
+ renderSkipPausingButton() {
+ const { skipPausing, toggleSkipPausing } = this.props;
+ return button(
+ {
+ className: classnames(
+ "command-bar-button",
+ "command-bar-skip-pausing",
+ {
+ active: skipPausing,
+ }
+ ),
+ title: skipPausing
+ ? L10N.getStr("undoSkipPausingTooltip.label")
+ : L10N.getStr("skipPausingTooltip.label"),
+ onClick: toggleSkipPausing,
+ },
+ React.createElement(AccessibleImage, {
+ className: skipPausing ? "enable-pausing" : "disable-pausing",
+ })
+ );
+ }
+
+ renderSettingsButton() {
+ const { toolboxDoc } = this.context;
+ return React.createElement(
+ MenuButton,
+ {
+ menuId: "debugger-settings-menu-button",
+ toolboxDoc: toolboxDoc,
+ className:
+ "devtools-button command-bar-button debugger-settings-menu-button",
+ title: L10N.getStr("settings.button.label"),
+ },
+ () => this.renderSettingsMenuItems()
+ );
+ }
+
+ renderSettingsMenuItems() {
+ return React.createElement(
+ MenuList,
+ {
+ id: "debugger-settings-menu-list",
+ },
+ React.createElement(MenuItem, {
+ key: "debugger-settings-menu-item-disable-javascript",
+ className: "menu-item debugger-settings-menu-item-disable-javascript",
+ checked: !this.props.javascriptEnabled,
+ label: L10N.getStr("settings.disableJavaScript.label"),
+ tooltip: L10N.getStr("settings.disableJavaScript.tooltip"),
+ onClick: () => {
+ this.props.toggleJavaScriptEnabled(!this.props.javascriptEnabled);
+ },
+ }),
+ React.createElement(MenuItem, {
+ key: "debugger-settings-menu-item-disable-inline-previews",
+ checked: features.inlinePreview,
+ label: L10N.getStr("inlinePreview.toggle.label"),
+ tooltip: L10N.getStr("inlinePreview.toggle.tooltip"),
+ onClick: () => this.props.toggleInlinePreview(!features.inlinePreview),
+ }),
+ React.createElement(MenuItem, {
+ key: "debugger-settings-menu-item-disable-wrap-lines",
+ checked: prefs.editorWrapping,
+ label: L10N.getStr("editorWrapping.toggle.label"),
+ tooltip: L10N.getStr("editorWrapping.toggle.tooltip"),
+ onClick: () => this.props.toggleEditorWrapping(!prefs.editorWrapping),
+ }),
+ React.createElement(MenuItem, {
+ key: "debugger-settings-menu-item-disable-sourcemaps",
+ checked: prefs.clientSourceMapsEnabled,
+ label: L10N.getStr("settings.toggleSourceMaps.label"),
+ tooltip: L10N.getStr("settings.toggleSourceMaps.tooltip"),
+ onClick: () =>
+ this.props.toggleSourceMapsEnabled(!prefs.clientSourceMapsEnabled),
+ }),
+ React.createElement(MenuItem, {
+ key: "debugger-settings-menu-item-hide-ignored-sources",
+ className: "menu-item debugger-settings-menu-item-hide-ignored-sources",
+ checked: prefs.hideIgnoredSources,
+ label: L10N.getStr("settings.hideIgnoredSources.label"),
+ tooltip: L10N.getStr("settings.hideIgnoredSources.tooltip"),
+ onClick: () =>
+ this.props.setHideOrShowIgnoredSources(!prefs.hideIgnoredSources),
+ }),
+ React.createElement(MenuItem, {
+ key: "debugger-settings-menu-item-enable-sourcemap-ignore-list",
+ className:
+ "menu-item debugger-settings-menu-item-enable-sourcemap-ignore-list",
+ checked: prefs.sourceMapIgnoreListEnabled,
+ label: L10N.getStr("settings.enableSourceMapIgnoreList.label"),
+ tooltip: L10N.getStr("settings.enableSourceMapIgnoreList.tooltip"),
+ onClick: () =>
+ this.props.toggleSourceMapIgnoreList(
+ !prefs.sourceMapIgnoreListEnabled
+ ),
+ })
+ );
+ }
+
+ render() {
+ return div(
+ {
+ className: classnames("command-bar", {
+ vertical: !this.props.horizontal,
+ }),
+ },
+ this.renderStepButtons(),
+ div({
+ className: "filler",
+ }),
+ this.renderTraceButton(),
+ this.renderSkipPausingButton(),
+ div({
+ className: "devtools-separator",
+ }),
+ this.renderSettingsButton()
+ );
+ }
+}
+
+CommandBar.contextTypes = {
+ shortcuts: PropTypes.object,
+ toolboxDoc: PropTypes.object,
+};
+
+const mapStateToProps = state => ({
+ isWaitingOnBreak: getIsWaitingOnBreak(state, getCurrentThread(state)),
+ skipPausing: getSkipPausing(state),
+ topFrameSelected: isTopFrameSelected(state, getCurrentThread(state)),
+ javascriptEnabled: state.ui.javascriptEnabled,
+ isPaused: getIsCurrentThreadPaused(state),
+ isTracingEnabled: getIsJavascriptTracingEnabled(
+ state,
+ getCurrentThread(state)
+ ),
+ isTracingActive: getIsThreadCurrentlyTracing(state, getCurrentThread(state)),
+ logMethod: getJavascriptTracingLogMethod(state),
+ logValues: getJavascriptTracingValues(state),
+ traceOnNextInteraction: getJavascriptTracingOnNextInteraction(state),
+ traceOnNextLoad: getJavascriptTracingOnNextLoad(state),
+ traceFunctionReturn: getJavascriptTracingFunctionReturn(state),
+});
+
+export default connect(mapStateToProps, {
+ toggleTracing: actions.toggleTracing,
+ setJavascriptTracingLogMethod: actions.setJavascriptTracingLogMethod,
+ toggleJavascriptTracingValues: actions.toggleJavascriptTracingValues,
+ toggleJavascriptTracingOnNextInteraction:
+ actions.toggleJavascriptTracingOnNextInteraction,
+ toggleJavascriptTracingOnNextLoad: actions.toggleJavascriptTracingOnNextLoad,
+ toggleJavascriptTracingFunctionReturn:
+ actions.toggleJavascriptTracingFunctionReturn,
+ resume: actions.resume,
+ stepIn: actions.stepIn,
+ stepOut: actions.stepOut,
+ stepOver: actions.stepOver,
+ breakOnNext: actions.breakOnNext,
+ pauseOnExceptions: actions.pauseOnExceptions,
+ toggleSkipPausing: actions.toggleSkipPausing,
+ toggleInlinePreview: actions.toggleInlinePreview,
+ toggleEditorWrapping: actions.toggleEditorWrapping,
+ toggleSourceMapsEnabled: actions.toggleSourceMapsEnabled,
+ toggleJavaScriptEnabled: actions.toggleJavaScriptEnabled,
+ setHideOrShowIgnoredSources: actions.setHideOrShowIgnoredSources,
+ toggleSourceMapIgnoreList: actions.toggleSourceMapIgnoreList,
+})(CommandBar);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css
new file mode 100644
index 0000000000..b525783984
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+ .dom-mutation-empty {
+ padding: 6px 20px;
+ text-align: center;
+ font-style: italic;
+ color: var(--theme-body-color);
+ white-space: normal;
+ }
+
+ .dom-mutation-empty a {
+ text-decoration: underline;
+ color: var(--theme-toolbar-selected-color);
+ cursor: pointer;
+ }
+
+.dom-mutation-list * {
+ user-select: none;
+}
+
+.dom-mutation-list {
+ padding: 4px 0;
+ list-style-type: none;
+}
+
+.dom-mutation-list li {
+ position: relative;
+
+ display: flex;
+ align-items: start;
+ overflow: hidden;
+ padding-top: 2px;
+ padding-bottom: 2px;
+ padding-inline-start: 20px;
+ padding-inline-end: 12px;
+}
+
+.dom-mutation-list input {
+ margin: 2px 3px;
+
+ padding-inline-start: 2px;
+ margin-top: 0px;
+ margin-bottom: 0px;
+ margin-inline-start: 0;
+ margin-inline-end: 2px;
+ vertical-align: text-bottom;
+}
+
+.dom-mutation-info {
+ flex-grow: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ margin-inline-end: 20px;
+}
+
+.dom-mutation-list .close-btn {
+ position: absolute;
+ /* hide button outside of row until hovered or focused */
+ top: -100px;
+}
+
+/* Reveal the remove button on hover/focus */
+.dom-mutation-list li:hover .close-btn,
+.dom-mutation-list li .close-btn:focus {
+ top: calc(50% - 8px);
+}
+
+[dir="ltr"] .dom-mutation-list .close-btn {
+ right: 12px;
+}
+
+[dir="rtl"] .dom-mutation-list .close-btn {
+ left: 12px;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js
new file mode 100644
index 0000000000..642ba21505
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js
@@ -0,0 +1,189 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ input,
+ li,
+ ul,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { Rep },
+ MODE,
+} = Reps;
+import { translateNodeFrontToGrip } from "devtools/client/inspector/shared/utils";
+
+import {
+ deleteDOMMutationBreakpoint,
+ toggleDOMMutationBreakpointState,
+} from "devtools/client/framework/actions/index";
+
+import actions from "../../actions/index";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import { CloseButton } from "../shared/Button/index";
+
+const localizationTerms = {
+ subtree: L10N.getStr("domMutationTypes.subtree"),
+ attribute: L10N.getStr("domMutationTypes.attribute"),
+ removal: L10N.getStr("domMutationTypes.removal"),
+};
+
+class DOMMutationBreakpointsContents extends Component {
+ static get propTypes() {
+ return {
+ breakpoints: PropTypes.array.isRequired,
+ deleteBreakpoint: PropTypes.func.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ openInspector: PropTypes.func.isRequired,
+ setSkipPausing: PropTypes.func.isRequired,
+ toggleBreakpoint: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ };
+ }
+
+ handleBreakpoint(breakpointId, shouldEnable) {
+ const { toggleBreakpoint, setSkipPausing } = this.props;
+
+ // The user has enabled a mutation breakpoint so we should no
+ // longer skip pausing
+ if (shouldEnable) {
+ setSkipPausing(false);
+ }
+ toggleBreakpoint(breakpointId, shouldEnable);
+ }
+
+ renderItem(breakpoint) {
+ const {
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ deleteBreakpoint,
+ } = this.props;
+ const { enabled, id: breakpointId, nodeFront, mutationType } = breakpoint;
+
+ return li(
+ {
+ key: breakpoint.id,
+ },
+ input({
+ type: "checkbox",
+ checked: enabled,
+ onChange: () => this.handleBreakpoint(breakpointId, !enabled),
+ }),
+ div(
+ {
+ className: "dom-mutation-info",
+ },
+ div(
+ {
+ className: "dom-mutation-label",
+ },
+ Rep({
+ object: translateNodeFrontToGrip(nodeFront),
+ mode: MODE.TINY,
+ onDOMNodeClick: () => openElementInInspector(nodeFront),
+ onInspectIconClick: () => openElementInInspector(nodeFront),
+ onDOMNodeMouseOver: () => highlightDomElement(nodeFront),
+ onDOMNodeMouseOut: () => unHighlightDomElement(),
+ })
+ ),
+ div(
+ {
+ className: "dom-mutation-type",
+ },
+ localizationTerms[mutationType] || mutationType
+ )
+ ),
+ React.createElement(CloseButton, {
+ handleClick: () => deleteBreakpoint(nodeFront, mutationType),
+ })
+ );
+ }
+
+ /* eslint-disable react/no-danger */
+ renderEmpty() {
+ const { openInspector } = this.props;
+ const text = L10N.getFormatStr(
+ "noDomMutationBreakpoints",
+ `<a>${L10N.getStr("inspectorTool")}</a>`
+ );
+ return div(
+ {
+ className: "dom-mutation-empty",
+ },
+ div({
+ onClick: () => openInspector(),
+ dangerouslySetInnerHTML: {
+ __html: text,
+ },
+ })
+ );
+ }
+
+ render() {
+ const { breakpoints } = this.props;
+
+ if (breakpoints.length === 0) {
+ return this.renderEmpty();
+ }
+ return ul(
+ {
+ className: "dom-mutation-list",
+ },
+ breakpoints.map(breakpoint => this.renderItem(breakpoint))
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ breakpoints: state.domMutationBreakpoints.breakpoints,
+});
+
+const DOMMutationBreakpointsPanel = connect(
+ mapStateToProps,
+ {
+ deleteBreakpoint: deleteDOMMutationBreakpoint,
+ toggleBreakpoint: toggleDOMMutationBreakpointState,
+ },
+ undefined,
+ { storeKey: "toolbox-store" }
+)(DOMMutationBreakpointsContents);
+
+class DomMutationBreakpoints extends Component {
+ static get propTypes() {
+ return {
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ openInspector: PropTypes.func.isRequired,
+ setSkipPausing: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ };
+ }
+
+ render() {
+ return React.createElement(DOMMutationBreakpointsPanel, {
+ openElementInInspector: this.props.openElementInInspector,
+ highlightDomElement: this.props.highlightDomElement,
+ unHighlightDomElement: this.props.unHighlightDomElement,
+ setSkipPausing: this.props.setSkipPausing,
+ openInspector: this.props.openInspector,
+ });
+ }
+}
+
+export default connect(undefined, {
+ // the debugger-specific action bound to the debugger store
+ // since there is no `storeKey`
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+ setSkipPausing: actions.setSkipPausing,
+ openInspector: actions.openInspector,
+})(DomMutationBreakpoints);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css
new file mode 100644
index 0000000000..eadc3a917b
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.event-listeners-content {
+ padding-block: 4px;
+}
+
+.event-listeners-content ul {
+ padding: 0;
+ list-style-type: none;
+}
+
+.event-listeners-content button:hover,
+.event-listeners-content button:focus {
+ background: none;
+}
+
+.event-listener-group {
+ user-select: none;
+}
+
+.event-listener-header {
+ display: flex;
+ align-items: center;
+}
+
+.event-listener-expand {
+ border: none;
+ background: none;
+ padding: 4px 5px;
+ line-height: 12px;
+ outline-offset: -2px;
+}
+
+.event-listener-expand:hover {
+ background: transparent;
+}
+
+.event-listener-group input[type="checkbox"] {
+ margin: 0;
+ margin-inline-end: 4px;
+}
+
+.event-listener-label {
+ display: flex;
+ align-items: center;
+ padding-inline-end: 10px;
+}
+
+.event-listener-category {
+ padding: 3px 0;
+ line-height: 14px;
+}
+
+.event-listeners-content .arrow {
+ margin-inline-end: 0;
+}
+
+.event-listeners-content .arrow.expanded {
+ transform: rotate(0deg);
+}
+
+.event-listeners-content .arrow.expanded:dir(rtl) {
+ transform: rotate(90deg);
+}
+
+.event-listeners-list {
+ border-block-start: 1px;
+ padding-inline: 18px 20px;
+}
+
+.event-listener-event {
+ display: flex;
+ align-items: center;
+}
+
+.event-listeners-list .event-listener-event {
+ margin-inline-start: 40px;
+}
+
+.event-search-results-list .event-listener-event {
+ padding-inline: 20px;
+}
+
+.event-listener-name {
+ line-height: 14px;
+ padding: 3px 0;
+}
+
+.event-listener-event input {
+ margin-inline: 0 4px;
+ margin-block: 0;
+}
+
+.event-search-container {
+ display: flex;
+ border: 1px solid transparent;
+ border-block-end: 1px solid var(--theme-splitter-color);
+}
+
+.event-search-form {
+ display: flex;
+ flex-grow: 1;
+}
+
+.event-search-input {
+ flex-grow: 1;
+ margin: 0;
+ font-size: inherit;
+ background-color: var(--theme-sidebar-background);
+ border: 0;
+ height: 24px;
+ color: var(--theme-body-color);
+ background-image: url("chrome://devtools/skin/images/filter-small.svg");
+ background-position-x: 4px;
+ background-position-y: 50%;
+ background-repeat: no-repeat;
+ background-size: 12px;
+ -moz-context-properties: fill;
+ fill: var(--theme-icon-dimmed-color);
+ text-align: match-parent;
+ outline-offset: -1px;
+}
+
+:root:dir(ltr) .event-search-input {
+ /* Be explicit about left/right direction to prevent the text/placeholder
+ * from overlapping the background image when the user changes the text
+ * direction manually (e.g. via Ctrl+Shift). */
+ padding-left: 19px;
+ padding-right: 12px;
+}
+
+:root:dir(rtl) .event-search-input {
+ background-position-x: right 4px;
+ padding-right: 19px;
+ padding-left: 12px;
+}
+
+.category-label {
+ color: var(--theme-comment);
+}
+
+.event-search-input::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
+
+.event-search-container:focus-within {
+ border: 1px solid var(--theme-highlight-blue);
+}
+
+.devtools-searchinput-clear {
+ margin-inline-end: 8px;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js
new file mode 100644
index 0000000000..ce7eabf89d
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js
@@ -0,0 +1,339 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ input,
+ li,
+ ul,
+ span,
+ button,
+ form,
+ label,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../actions/index";
+import {
+ getActiveEventListeners,
+ getEventListenerBreakpointTypes,
+ getEventListenerExpanded,
+} from "../../selectors/index";
+
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class EventListeners extends Component {
+ state = {
+ searchText: "",
+ focused: false,
+ };
+
+ static get propTypes() {
+ return {
+ activeEventListeners: PropTypes.array.isRequired,
+ addEventListenerExpanded: PropTypes.func.isRequired,
+ addEventListeners: PropTypes.func.isRequired,
+ categories: PropTypes.array.isRequired,
+ expandedCategories: PropTypes.array.isRequired,
+ removeEventListenerExpanded: PropTypes.func.isRequired,
+ removeEventListeners: PropTypes.func.isRequired,
+ };
+ }
+
+ hasMatch(eventOrCategoryName, searchText) {
+ const lowercaseEventOrCategoryName = eventOrCategoryName.toLowerCase();
+ const lowercaseSearchText = searchText.toLowerCase();
+
+ return lowercaseEventOrCategoryName.includes(lowercaseSearchText);
+ }
+
+ getSearchResults() {
+ const { searchText } = this.state;
+ const { categories } = this.props;
+ const searchResults = categories.reduce((results, cat, index) => {
+ const category = categories[index];
+
+ if (this.hasMatch(category.name, searchText)) {
+ results[category.name] = category.events;
+ } else {
+ results[category.name] = category.events.filter(event =>
+ this.hasMatch(event.name, searchText)
+ );
+ }
+
+ return results;
+ }, {});
+
+ return searchResults;
+ }
+
+ onCategoryToggle(category) {
+ const {
+ expandedCategories,
+ removeEventListenerExpanded,
+ addEventListenerExpanded,
+ } = this.props;
+
+ if (expandedCategories.includes(category)) {
+ removeEventListenerExpanded(category);
+ } else {
+ addEventListenerExpanded(category);
+ }
+ }
+
+ onCategoryClick(category, isChecked) {
+ const { addEventListeners, removeEventListeners } = this.props;
+ const eventsIds = category.events.map(event => event.id);
+
+ if (isChecked) {
+ addEventListeners(eventsIds);
+ } else {
+ removeEventListeners(eventsIds);
+ }
+ }
+
+ onEventTypeClick(eventId, isChecked) {
+ const { addEventListeners, removeEventListeners } = this.props;
+ if (isChecked) {
+ addEventListeners([eventId]);
+ } else {
+ removeEventListeners([eventId]);
+ }
+ }
+
+ onInputChange = event => {
+ this.setState({ searchText: event.currentTarget.value });
+ };
+
+ onKeyDown = event => {
+ if (event.key === "Escape") {
+ this.setState({ searchText: "" });
+ }
+ };
+
+ onFocus = event => {
+ this.setState({ focused: true });
+ };
+
+ onBlur = event => {
+ this.setState({ focused: false });
+ };
+
+ renderSearchInput() {
+ const { focused, searchText } = this.state;
+ const placeholder = L10N.getStr("eventListenersHeader1.placeholder");
+ return form(
+ {
+ className: "event-search-form",
+ onSubmit: e => e.preventDefault(),
+ },
+ input({
+ className: classnames("event-search-input", {
+ focused,
+ }),
+ placeholder: placeholder,
+ value: searchText,
+ onChange: this.onInputChange,
+ onKeyDown: this.onKeyDown,
+ onFocus: this.onFocus,
+ onBlur: this.onBlur,
+ })
+ );
+ }
+
+ renderClearSearchButton() {
+ const { searchText } = this.state;
+
+ if (!searchText) {
+ return null;
+ }
+ return button({
+ onClick: () =>
+ this.setState({
+ searchText: "",
+ }),
+ className: "devtools-searchinput-clear",
+ });
+ }
+
+ renderCategoriesList() {
+ const { categories } = this.props;
+ return ul(
+ {
+ className: "event-listeners-list",
+ },
+ categories.map((category, index) => {
+ return li(
+ {
+ className: "event-listener-group",
+ key: index,
+ },
+ this.renderCategoryHeading(category),
+ this.renderCategoryListing(category)
+ );
+ })
+ );
+ }
+
+ renderSearchResultsList() {
+ const searchResults = this.getSearchResults();
+ return ul(
+ {
+ className: "event-search-results-list",
+ },
+ Object.keys(searchResults).map(category => {
+ return searchResults[category].map(event => {
+ return this.renderListenerEvent(event, category);
+ });
+ })
+ );
+ }
+
+ renderCategoryHeading(category) {
+ const { activeEventListeners, expandedCategories } = this.props;
+ const { events } = category;
+
+ const expanded = expandedCategories.includes(category.name);
+ const checked = events.every(({ id }) => activeEventListeners.includes(id));
+ const indeterminate =
+ !checked && events.some(({ id }) => activeEventListeners.includes(id));
+
+ return div(
+ {
+ className: "event-listener-header",
+ },
+ button(
+ {
+ className: "event-listener-expand",
+ onClick: () => this.onCategoryToggle(category.name),
+ },
+ React.createElement(AccessibleImage, {
+ className: classnames("arrow", {
+ expanded,
+ }),
+ })
+ ),
+ label(
+ {
+ className: "event-listener-label",
+ },
+ input({
+ type: "checkbox",
+ value: category.name,
+ onChange: e => {
+ this.onCategoryClick(
+ category,
+ // Clicking an indeterminate checkbox should always have the
+ // effect of disabling any selected items.
+ indeterminate ? false : e.target.checked
+ );
+ },
+ checked: checked,
+ ref: el => el && (el.indeterminate = indeterminate),
+ }),
+ span(
+ {
+ className: "event-listener-category",
+ },
+ category.name
+ )
+ )
+ );
+ }
+
+ renderCategoryListing(category) {
+ const { expandedCategories } = this.props;
+
+ const expanded = expandedCategories.includes(category.name);
+ if (!expanded) {
+ return null;
+ }
+ return ul(
+ null,
+ category.events.map(event => {
+ return this.renderListenerEvent(event, category.name);
+ })
+ );
+ }
+
+ renderCategory(category) {
+ return span(
+ {
+ className: "category-label",
+ },
+ category,
+ " \u25B8 "
+ );
+ }
+
+ renderListenerEvent(event, category) {
+ const { activeEventListeners } = this.props;
+ const { searchText } = this.state;
+ return li(
+ {
+ className: "event-listener-event",
+ key: event.id,
+ },
+ label(
+ {
+ className: "event-listener-label",
+ },
+ input({
+ type: "checkbox",
+ value: event.id,
+ onChange: e => this.onEventTypeClick(event.id, e.target.checked),
+ checked: activeEventListeners.includes(event.id),
+ }),
+ span(
+ {
+ className: "event-listener-name",
+ },
+ searchText ? this.renderCategory(category) : null,
+ event.name
+ )
+ )
+ );
+ }
+
+ render() {
+ const { searchText } = this.state;
+ return div(
+ {
+ className: "event-listeners",
+ },
+ div(
+ {
+ className: "event-search-container",
+ },
+ this.renderSearchInput(),
+ this.renderClearSearchButton()
+ ),
+ div(
+ {
+ className: "event-listeners-content",
+ },
+ searchText
+ ? this.renderSearchResultsList()
+ : this.renderCategoriesList()
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ activeEventListeners: getActiveEventListeners(state),
+ categories: getEventListenerBreakpointTypes(state),
+ expandedCategories: getEventListenerExpanded(state),
+});
+
+export default connect(mapStateToProps, {
+ addEventListeners: actions.addEventListenerBreakpoints,
+ removeEventListeners: actions.removeEventListenerBreakpoints,
+ addEventListenerExpanded: actions.addEventListenerExpanded,
+ removeEventListenerExpanded: actions.removeEventListenerExpanded,
+})(EventListeners);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css
new file mode 100644
index 0000000000..5dcf2622b5
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.expression-input-form {
+ width: 100%;
+}
+
+.input-expression {
+ width: 100%;
+ margin: 0;
+ font-size: inherit;
+ border: 1px;
+ background-color: var(--theme-sidebar-background);
+ height: 24px;
+ padding-inline-start: 19px;
+ padding-inline-end: 12px;
+ color: var(--theme-body-color);
+ outline: 0;
+}
+
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 20%,
+ 60% {
+ transform: translateX(-10px);
+ }
+ 40%,
+ 80% {
+ transform: translateX(10px);
+ }
+}
+
+.input-expression::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
+
+.input-expression:focus {
+ cursor: text;
+}
+
+.expressions-list .expression-input-container {
+ height: var(--expression-item-height);
+}
+
+.expressions-list .input-expression {
+ /* Prevent vertical bounce when editing an existing Watch Expression */
+ height: 100%;
+}
+
+.expressions-list {
+ /* TODO: add normalize */
+ margin: 0;
+ padding: 4px 0px;
+ overflow-x: auto;
+}
+
+.expression-input-container {
+ display: flex;
+ border: 1px solid transparent;
+}
+
+.expression-input-container.focused {
+ border: 1px solid var(--theme-highlight-blue);
+}
+
+:root.theme-dark .expression-input-container.focused {
+ border: 1px solid var(--blue-50);
+}
+
+.expression-container {
+ padding-top: 3px;
+ padding-bottom: 3px;
+ padding-inline-start: 20px;
+ padding-inline-end: 12px;
+ width: 100%;
+ color: var(--theme-body-color);
+ background-color: var(--theme-body-background);
+ display: block;
+ position: relative;
+ overflow: hidden;
+}
+
+.expression-container > .tree {
+ width: 100%;
+ overflow: hidden;
+}
+
+.expression-container .tree .tree-node[aria-level="1"] {
+ padding-top: 0px;
+ /* keep line-height at 14px to prevent row from shifting upon expansion */
+ line-height: 14px;
+}
+
+.expression-container .tree-node[aria-level="1"] .object-label {
+ font-family: var(--monospace-font-family);
+}
+
+:root.theme-light .expression-container:hover {
+ background-color: var(--search-overlays-semitransparent);
+}
+
+:root.theme-dark .expression-container:hover {
+ background-color: var(--search-overlays-semitransparent);
+}
+
+.tree .tree-node:not(.focused):hover {
+ background-color: transparent;
+}
+
+.expression-container__close-btn {
+ position: absolute;
+ /* hiding button outside of row until hovered or focused */
+ top: -100px;
+}
+
+.expression-container:hover .expression-container__close-btn,
+.expression-container:focus-within .expression-container__close-btn,
+.expression-container__close-btn:focus-within {
+ top: 0;
+}
+
+.expression-content .object-node {
+ padding-inline-start: 0px;
+ cursor: default;
+}
+
+.expressions-list .tree.object-inspector .node.object-node {
+ max-width: calc(100% - 20px);
+ min-width: 0;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.expression-container__close-btn {
+ max-height: 16px;
+ padding-inline-start: 4px;
+}
+
+[dir="ltr"] .expression-container__close-btn {
+ right: 0;
+}
+
+[dir="rtl"] .expression-container__close-btn {
+ left: 0;
+}
+
+.expression-content {
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+ position: relative;
+}
+
+.expression-content .tree {
+ overflow: hidden;
+ flex-grow: 1;
+ line-height: 15px;
+}
+
+.expression-content .tree-node[data-expandable="false"][aria-level="1"] {
+ padding-inline-start: 0px;
+}
+
+.input-expression:not(:placeholder-shown) {
+ font-family: var(--monospace-font-family);
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js
new file mode 100644
index 0000000000..be05c7327c
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js
@@ -0,0 +1,486 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ input,
+ li,
+ ul,
+ form,
+ datalist,
+ option,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { features } from "../../utils/prefs";
+import AccessibleImage from "../shared/AccessibleImage";
+
+import { objectInspector } from "devtools/client/shared/components/reps/index";
+
+import actions from "../../actions/index";
+import {
+ getExpressions,
+ getAutocompleteMatchset,
+ getSelectedSource,
+ isMapScopesEnabled,
+ getIsCurrentThreadPaused,
+ getSelectedFrame,
+ getOriginalFrameScope,
+ getCurrentThread,
+} from "../../selectors/index";
+import { getExpressionResultGripAndFront } from "../../utils/expressions";
+
+import { CloseButton } from "../shared/Button/index";
+
+const { debounce } = require("resource://devtools/shared/debounce.js");
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+const { ObjectInspector } = objectInspector;
+
+class Expressions extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ editing: false,
+ editIndex: -1,
+ inputValue: "",
+ focused: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ addExpression: PropTypes.func.isRequired,
+ autocomplete: PropTypes.func.isRequired,
+ autocompleteMatches: PropTypes.array,
+ clearAutocomplete: PropTypes.func.isRequired,
+ deleteExpression: PropTypes.func.isRequired,
+ expressions: PropTypes.array.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ onExpressionAdded: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ openLink: PropTypes.any.isRequired,
+ showInput: PropTypes.bool.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ updateExpression: PropTypes.func.isRequired,
+ isOriginalVariableMappingDisabled: PropTypes.bool,
+ isLoadingOriginalVariables: PropTypes.bool,
+ };
+ }
+
+ componentDidMount() {
+ const { showInput } = this.props;
+
+ // Ensures that the input is focused when the "+"
+ // is clicked while the panel is collapsed
+ if (showInput && this._input) {
+ this._input.focus();
+ }
+ }
+
+ clear = () => {
+ this.setState(() => ({
+ editing: false,
+ editIndex: -1,
+ inputValue: "",
+ focused: false,
+ }));
+ };
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ if (this.state.editing) {
+ this.clear();
+ }
+
+ // Ensures that the add watch expression input
+ // is no longer visible when the new watch expression is rendered
+ if (this.props.expressions.length < nextProps.expressions.length) {
+ this.hideInput();
+ }
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ const { editing, inputValue, focused } = this.state;
+ const {
+ expressions,
+ showInput,
+ autocompleteMatches,
+ isLoadingOriginalVariables,
+ isOriginalVariableMappingDisabled,
+ } = this.props;
+
+ return (
+ autocompleteMatches !== nextProps.autocompleteMatches ||
+ expressions !== nextProps.expressions ||
+ isLoadingOriginalVariables !== nextProps.isLoadingOriginalVariables ||
+ isOriginalVariableMappingDisabled !==
+ nextProps.isOriginalVariableMappingDisabled ||
+ editing !== nextState.editing ||
+ inputValue !== nextState.inputValue ||
+ nextProps.showInput !== showInput ||
+ focused !== nextState.focused
+ );
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const _input = this._input;
+
+ if (!_input) {
+ return;
+ }
+
+ if (!prevState.editing && this.state.editing) {
+ _input.setSelectionRange(0, _input.value.length);
+ _input.focus();
+ } else if (this.props.showInput && !this.state.focused) {
+ _input.focus();
+ }
+ }
+
+ editExpression(expression, index) {
+ this.setState({
+ inputValue: expression.input,
+ editing: true,
+ editIndex: index,
+ });
+ }
+
+ deleteExpression(e, expression) {
+ e.stopPropagation();
+ const { deleteExpression } = this.props;
+ deleteExpression(expression);
+ }
+
+ handleChange = e => {
+ const { target } = e;
+ if (features.autocompleteExpression) {
+ this.findAutocompleteMatches(target.value, target.selectionStart);
+ }
+ this.setState({ inputValue: target.value });
+ };
+
+ findAutocompleteMatches = debounce((value, selectionStart) => {
+ const { autocomplete } = this.props;
+ autocomplete(value, selectionStart);
+ }, 250);
+
+ handleKeyDown = e => {
+ if (e.key === "Escape") {
+ this.clear();
+ }
+ };
+
+ hideInput = () => {
+ this.setState({ focused: false });
+ this.props.onExpressionAdded();
+ };
+
+ createElement = element => {
+ return document.createElement(element);
+ };
+
+ onFocus = () => {
+ this.setState({ focused: true });
+ };
+
+ onBlur() {
+ this.clear();
+ this.hideInput();
+ }
+
+ handleExistingSubmit = async (e, expression) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.props.updateExpression(this.state.inputValue, expression);
+ };
+
+ handleNewSubmit = async e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ await this.props.addExpression(this.state.inputValue);
+ this.setState({
+ editing: false,
+ editIndex: -1,
+ inputValue: "",
+ });
+
+ this.props.clearAutocomplete();
+ };
+
+ renderExpressionsNotification() {
+ const { isOriginalVariableMappingDisabled, isLoadingOriginalVariables } =
+ this.props;
+
+ if (isOriginalVariableMappingDisabled) {
+ return div(
+ {
+ className: "pane-info no-original-scopes-info",
+ "aria-role": "status",
+ },
+ span(
+ { className: "info icon" },
+ React.createElement(AccessibleImage, { className: "sourcemap" })
+ ),
+ span(
+ { className: "message" },
+ L10N.getStr("expressions.noOriginalScopes")
+ )
+ );
+ }
+
+ if (isLoadingOriginalVariables) {
+ return div(
+ { className: "pane-info" },
+ span(
+ { className: "info icon" },
+ React.createElement(AccessibleImage, { className: "loader" })
+ ),
+ span(
+ { className: "message" },
+ L10N.getStr("scopes.loadingOriginalScopes")
+ )
+ );
+ }
+ return null;
+ }
+
+ renderExpression = (expression, index) => {
+ const {
+ openLink,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const { editing, editIndex } = this.state;
+ const { input: _input, updating } = expression;
+ const isEditingExpr = editing && editIndex === index;
+ if (isEditingExpr) {
+ return this.renderExpressionEditInput(expression);
+ }
+
+ if (updating) {
+ return null;
+ }
+
+ const { expressionResultGrip, expressionResultFront } =
+ getExpressionResultGripAndFront(expression);
+
+ const root = {
+ name: expression.input,
+ path: _input,
+ contents: {
+ value: expressionResultGrip,
+ front: expressionResultFront,
+ },
+ };
+
+ return li(
+ {
+ className: "expression-container",
+ key: _input,
+ title: expression.input,
+ },
+ div(
+ {
+ className: "expression-content",
+ },
+ React.createElement(ObjectInspector, {
+ roots: [root],
+ autoExpandDepth: 0,
+ disableWrap: true,
+ openLink: openLink,
+ createElement: this.createElement,
+ onDoubleClick: (items, { depth }) => {
+ if (depth === 0) {
+ this.editExpression(expression, index);
+ }
+ },
+ onDOMNodeClick: grip => openElementInInspector(grip),
+ onInspectIconClick: grip => openElementInInspector(grip),
+ onDOMNodeMouseOver: grip => highlightDomElement(grip),
+ onDOMNodeMouseOut: grip => unHighlightDomElement(grip),
+ shouldRenderTooltip: true,
+ mayUseCustomFormatter: true,
+ }),
+ div(
+ {
+ className: "expression-container__close-btn",
+ },
+ React.createElement(CloseButton, {
+ handleClick: e => this.deleteExpression(e, expression),
+ tooltip: L10N.getStr("expressions.remove.tooltip"),
+ })
+ )
+ )
+ );
+ };
+
+ renderExpressions() {
+ const { expressions, showInput } = this.props;
+ return React.createElement(
+ React.Fragment,
+ null,
+ ul(
+ {
+ className: "pane expressions-list",
+ },
+ expressions.map(this.renderExpression)
+ ),
+ showInput && this.renderNewExpressionInput()
+ );
+ }
+
+ renderAutoCompleteMatches() {
+ if (!features.autocompleteExpression) {
+ return null;
+ }
+ const { autocompleteMatches } = this.props;
+ if (autocompleteMatches) {
+ return datalist(
+ {
+ id: "autocomplete-matches",
+ },
+ autocompleteMatches.map((match, index) => {
+ return option({
+ key: index,
+ value: match,
+ });
+ })
+ );
+ }
+ return datalist({
+ id: "autocomplete-matches",
+ });
+ }
+
+ renderNewExpressionInput() {
+ const { editing, inputValue, focused } = this.state;
+ return form(
+ {
+ className: classnames(
+ "expression-input-container expression-input-form",
+ { focused }
+ ),
+ onSubmit: this.handleNewSubmit,
+ },
+ input({
+ className: "input-expression",
+ type: "text",
+ placeholder: L10N.getStr("expressions.placeholder"),
+ onChange: this.handleChange,
+ onBlur: this.hideInput,
+ onKeyDown: this.handleKeyDown,
+ onFocus: this.onFocus,
+ value: !editing ? inputValue : "",
+ ref: c => (this._input = c),
+ ...(features.autocompleteExpression && {
+ list: "autocomplete-matches",
+ }),
+ }),
+ this.renderAutoCompleteMatches(),
+ input({
+ type: "submit",
+ style: {
+ display: "none",
+ },
+ })
+ );
+ }
+
+ renderExpressionEditInput(expression) {
+ const { inputValue, editing, focused } = this.state;
+ return form(
+ {
+ key: expression.input,
+ className: classnames(
+ "expression-input-container expression-input-form",
+ {
+ focused,
+ }
+ ),
+ onSubmit: e => this.handleExistingSubmit(e, expression),
+ },
+ input({
+ className: "input-expression",
+ type: "text",
+ onChange: this.handleChange,
+ onBlur: this.clear,
+ onKeyDown: this.handleKeyDown,
+ onFocus: this.onFocus,
+ value: editing ? inputValue : expression.input,
+ ref: c => (this._input = c),
+ ...(features.autocompleteExpression && {
+ list: "autocomplete-matches",
+ }),
+ }),
+ this.renderAutoCompleteMatches(),
+ input({
+ type: "submit",
+ style: {
+ display: "none",
+ },
+ })
+ );
+ }
+
+ render() {
+ const { expressions } = this.props;
+
+ return div(
+ { className: "pane" },
+ this.renderExpressionsNotification(),
+ expressions.length === 0
+ ? this.renderNewExpressionInput()
+ : this.renderExpressions()
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedFrame = getSelectedFrame(state, getCurrentThread(state));
+ const selectedSource = getSelectedSource(state);
+ const isPaused = getIsCurrentThreadPaused(state);
+ const mapScopesEnabled = isMapScopesEnabled(state);
+ const expressions = getExpressions(state);
+
+ const selectedSourceIsNonPrettyPrintedOriginal =
+ selectedSource?.isOriginal && !selectedSource?.isPrettyPrinted;
+
+ let isOriginalVariableMappingDisabled, isLoadingOriginalVariables;
+
+ if (selectedSourceIsNonPrettyPrintedOriginal) {
+ isOriginalVariableMappingDisabled = isPaused && !mapScopesEnabled;
+ isLoadingOriginalVariables =
+ isPaused &&
+ mapScopesEnabled &&
+ !expressions.length &&
+ !getOriginalFrameScope(state, selectedFrame)?.scope;
+ }
+
+ return {
+ isOriginalVariableMappingDisabled,
+ isLoadingOriginalVariables,
+ autocompleteMatches: getAutocompleteMatchset(state),
+ expressions,
+ };
+};
+
+export default connect(mapStateToProps, {
+ autocomplete: actions.autocomplete,
+ clearAutocomplete: actions.clearAutocomplete,
+ addExpression: actions.addExpression,
+ updateExpression: actions.updateExpression,
+ deleteExpression: actions.deleteExpression,
+ openLink: actions.openLink,
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+})(Expressions);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js
new file mode 100644
index 0000000000..0c81d8afb4
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js
@@ -0,0 +1,232 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component, memo } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import AccessibleImage from "../../shared/AccessibleImage";
+import { formatDisplayName } from "../../../utils/pause/frames/index";
+import { getFilename, getFileURL } from "../../../utils/source";
+import FrameIndent from "./FrameIndent";
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+function FrameTitle({ frame, options = {}, l10n }) {
+ const displayName = formatDisplayName(frame, options, l10n);
+ return React.createElement(
+ "span",
+ {
+ className: "title",
+ },
+ displayName
+ );
+}
+
+FrameTitle.propTypes = {
+ frame: PropTypes.object.isRequired,
+ options: PropTypes.object.isRequired,
+ l10n: PropTypes.object.isRequired,
+ showFrameContextMenu: PropTypes.func.isRequired,
+};
+
+function getFrameLocation(frame, shouldDisplayOriginalLocation) {
+ if (shouldDisplayOriginalLocation) {
+ return frame.location;
+ }
+ return frame.generatedLocation || frame.location;
+}
+const FrameLocation = memo(
+ ({ frame, displayFullUrl = false, shouldDisplayOriginalLocation }) => {
+ if (frame.library) {
+ return React.createElement(
+ "span",
+ {
+ className: "location",
+ },
+ frame.library,
+ React.createElement(AccessibleImage, {
+ className: `annotation-logo ${frame.library.toLowerCase()}`,
+ })
+ );
+ }
+ const location = getFrameLocation(frame, shouldDisplayOriginalLocation);
+ const filename = displayFullUrl
+ ? getFileURL(location.source, false)
+ : getFilename(location.source);
+ return React.createElement(
+ "span",
+ {
+ className: "location",
+ title: location.source.url,
+ },
+ React.createElement(
+ "span",
+ {
+ className: "filename",
+ },
+ filename
+ ),
+ ":",
+ React.createElement(
+ "span",
+ {
+ className: "line",
+ },
+ location.line
+ )
+ );
+ }
+);
+FrameLocation.displayName = "FrameLocation";
+
+FrameLocation.propTypes = {
+ frame: PropTypes.object.isRequired,
+ displayFullUrl: PropTypes.bool.isRequired,
+};
+
+export default class FrameComponent extends Component {
+ static defaultProps = {
+ hideLocation: false,
+ shouldMapDisplayName: true,
+ disableContextMenu: false,
+ };
+
+ static get propTypes() {
+ return {
+ disableContextMenu: PropTypes.bool.isRequired,
+ displayFullUrl: PropTypes.bool.isRequired,
+ frame: PropTypes.object.isRequired,
+ getFrameTitle: PropTypes.func,
+ hideLocation: PropTypes.bool.isRequired,
+ isInGroup: PropTypes.bool,
+ panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired,
+ selectFrame: PropTypes.func.isRequired,
+ selectedFrame: PropTypes.object,
+ shouldMapDisplayName: PropTypes.bool.isRequired,
+ shouldDisplayOriginalLocation: PropTypes.bool.isRequired,
+ showFrameContextMenu: PropTypes.func.isRequired,
+ };
+ }
+
+ get isSelectable() {
+ return this.props.panel == "webconsole";
+ }
+
+ get isDebugger() {
+ return this.props.panel == "debugger";
+ }
+
+ onContextMenu(event) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const { frame } = this.props;
+ this.props.showFrameContextMenu(event, frame);
+ }
+
+ onMouseDown(e, frame, selectedFrame) {
+ if (e.button !== 0) {
+ return;
+ }
+
+ this.props.selectFrame(frame);
+ }
+
+ onKeyUp(event, frame, selectedFrame) {
+ if (event.key != "Enter") {
+ return;
+ }
+
+ this.props.selectFrame(frame);
+ }
+
+ render() {
+ const {
+ frame,
+ selectedFrame,
+ hideLocation,
+ shouldMapDisplayName,
+ displayFullUrl,
+ getFrameTitle,
+ disableContextMenu,
+ shouldDisplayOriginalLocation,
+ isInGroup,
+ } = this.props;
+ const { l10n } = this.context;
+
+ const className = classnames("frame", {
+ selected: selectedFrame && selectedFrame.id === frame.id,
+ });
+
+ const location = getFrameLocation(frame, shouldDisplayOriginalLocation);
+ const title = getFrameTitle
+ ? getFrameTitle(`${getFileURL(location.source, false)}:${location.line}`)
+ : undefined;
+ return React.createElement(
+ "div",
+ {
+ role: "listitem",
+ key: frame.id,
+ className: className,
+ onMouseDown: e => this.onMouseDown(e, frame, selectedFrame),
+ onKeyUp: e => this.onKeyUp(e, frame, selectedFrame),
+ onContextMenu: disableContextMenu ? null : e => this.onContextMenu(e),
+ tabIndex: 0,
+ title: title,
+ },
+ frame.asyncCause &&
+ React.createElement(
+ "span",
+ {
+ className: "location-async-cause",
+ },
+ this.isSelectable && React.createElement(FrameIndent, null),
+ this.isDebugger
+ ? React.createElement(
+ "span",
+ {
+ className: "async-label",
+ },
+ frame.asyncCause
+ )
+ : l10n.getFormatStr("stacktrace.asyncStack", frame.asyncCause),
+ this.isSelectable &&
+ React.createElement("br", {
+ className: "clipboard-only",
+ })
+ ),
+ this.isSelectable &&
+ React.createElement(FrameIndent, {
+ indentLevel: isInGroup ? 2 : 1,
+ }),
+ React.createElement(FrameTitle, {
+ frame,
+ options: {
+ shouldMapDisplayName,
+ },
+ l10n,
+ }),
+ !hideLocation &&
+ React.createElement(
+ "span",
+ {
+ className: "clipboard-only",
+ },
+ " "
+ ),
+ !hideLocation &&
+ React.createElement(FrameLocation, {
+ frame,
+ displayFullUrl,
+ shouldDisplayOriginalLocation,
+ }),
+ this.isSelectable &&
+ React.createElement("br", {
+ className: "clipboard-only",
+ })
+ );
+ }
+}
+
+FrameComponent.displayName = "Frame";
+FrameComponent.contextTypes = { l10n: PropTypes.object };
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js
new file mode 100644
index 0000000000..7eee92ffd1
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+
+export default function FrameIndent({ indentLevel = 1 } = {}) {
+ // \xA0 represents the non breakable space &nbsp;
+ const indentWidth = 4 * indentLevel;
+ const nonBreakableSpaces = "\xA0".repeat(indentWidth);
+ return React.createElement(
+ "span",
+ {
+ className: "frame-indent clipboard-only",
+ },
+ nonBreakableSpaces
+ );
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css
new file mode 100644
index 0000000000..5f57f97e51
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frames.css
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.frames [role="list"] {
+ list-style: none;
+ margin: 0;
+ padding: 4px 0;
+}
+
+.frames [role="list"] [role="listitem"] {
+ padding-bottom: 2px;
+ overflow: hidden;
+ display: flex;
+ justify-content: space-between;
+ column-gap: 0.5em;
+ flex-direction: row;
+ align-items: center;
+ margin: 0;
+ max-width: 100%;
+ flex-wrap: wrap;
+}
+
+.frames [role="list"] [role="listitem"] * {
+ user-select: none;
+}
+
+.frames .badge {
+ flex-shrink: 0;
+ margin-inline-end: 10px;
+}
+
+.frames .location {
+ font-weight: normal;
+ margin: 0;
+ flex-grow: 1;
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ /* Trick to get the ellipsis at the start of the string */
+ text-overflow: ellipsis;
+ direction: rtl;
+}
+
+.call-stack-pane:dir(ltr) .frames .location {
+ padding-right: 10px;
+ text-align: right;
+}
+
+.call-stack-pane:dir(rtl) .frames .location {
+ padding-left: 10px;
+ text-align: left;
+}
+
+.call-stack-pane .location-async-cause {
+ color: var(--theme-comment);
+}
+
+.theme-light .frames .location {
+ color: var(--theme-comment);
+}
+
+:root.theme-dark .frames .location {
+ color: var(--theme-body-color);
+ opacity: 0.6;
+}
+
+.frames .title {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-inline-start: 10px;
+}
+
+.frames-group .title {
+ padding-inline-start: 40px;
+}
+
+.frames [role="list"] [role="listitem"]:hover,
+.frames [role="list"] [role="listitem"]:focus {
+ background-color: var(--theme-toolbar-background-alt);
+}
+
+.frames [role="list"] [role="listitem"]:hover .location-async-cause,
+.frames [role="list"] [role="listitem"]:focus .location-async-cause,
+.frames [role="list"] [role="listitem"]:hover .async-label,
+.frames [role="list"] [role="listitem"]:focus .async-label {
+ background-color: var(--theme-body-background);
+}
+
+.theme-dark .frames [role="list"] [role="listitem"]:focus,
+.theme-dark .frames [role="list"] [role="listitem"]:focus .async-label,
+.theme-dark .frames [role="list"] [role="listitem"]:focus .async-label {
+ background-color: var(--theme-tab-toolbar-background);
+}
+
+.frames [role="list"] [role="listitem"].selected,
+.frames [role="list"] [role="listitem"].selected .async-label {
+ background-color: var(--theme-selection-background);
+ color: white;
+}
+
+.frames [role="list"] [role="listitem"].selected i.annotation-logo svg path {
+ fill: white;
+}
+
+:root.theme-light .frames [role="list"] [role="listitem"].selected .location,
+:root.theme-dark .frames [role="list"] [role="listitem"].selected .location {
+ color: white;
+}
+
+.frames .show-more-container {
+ display: flex;
+ min-height: 24px;
+ padding: 4px 0;
+}
+
+.frames .show-more {
+ text-align: center;
+ padding: 8px 0px;
+ margin: 7px 10px 7px 7px;
+ border: 1px solid var(--theme-splitter-color);
+ background-color: var(--theme-tab-toolbar-background);
+ width: 100%;
+ font-size: inherit;
+ color: inherit;
+}
+
+.frames .show-more:hover {
+ background-color: var(--theme-toolbar-background-hover);
+}
+
+.frames .img.annotation-logo {
+ margin-inline-end: 4px;
+ background-color: currentColor;
+}
+
+/*
+ * We also show the library icon in locations, which are forced to RTL.
+ */
+.frames .location .img.annotation-logo {
+ margin-inline-start: 4px;
+}
+
+/* Some elements are added to the DOM only to be printed into the clipboard
+ when the user copy some elements. We don't want those elements to mess with
+ the layout so we put them outside of the screen
+*/
+.frames .clipboard-only {
+ position: absolute;
+ left: -9999px;
+}
+
+.call-stack-pane [role="listitem"] .location-async-cause {
+ height: 20px;
+ line-height: 20px;
+ color: var(--theme-icon-dimmed-color);
+ display: block;
+ z-index: 4;
+ position: relative;
+ padding-inline-start: 17px;
+ width: 100%;
+ pointer-events: none;
+}
+
+.frames-group .location-async-cause {
+ padding-inline-start: 47px;
+}
+
+.call-stack-pane [role="listitem"] .location-async-cause::after {
+ content: " ";
+ position: absolute;
+ left: 0;
+ z-index: -1;
+ height: 30px;
+ top: 50%;
+ width: 100%;
+ border-top: 1px solid var(--theme-tab-toolbar-background);;
+}
+
+.call-stack-pane .async-label {
+ z-index: 1;
+ background-color: var(--theme-sidebar-background);
+ padding: 0 3px;
+ display: inline-block;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css
new file mode 100644
index 0000000000..14dbea9954
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.css
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.frames-group .group,
+.frames-group .group .location {
+ font-weight: 500;
+ cursor: default;
+ /*
+ * direction:rtl is set in Frames.css to overflow the location text from the
+ * start. Here we need to reset it in order to display the framework icon
+ * after the framework name.
+ */
+ direction: ltr;
+}
+
+.frames-group.expanded .group,
+.frames-group.expanded .group .location {
+ color: var(--theme-highlight-blue);
+}
+
+.frames-group .frames-list {
+ border-top: 1px solid var(--theme-splitter-color);
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.frames-group.expanded .badge {
+ color: var(--theme-highlight-blue);
+}
+
+.frames-group .img.arrow {
+ margin-inline-start: -1px;
+ margin-inline-end: 4px;
+}
+
+.frames-group .group-description {
+ padding-inline-start: 6px;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js
new file mode 100644
index 0000000000..ab9f7073a7
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js
@@ -0,0 +1,191 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { getLibraryFromUrl } from "../../../utils/pause/frames/index";
+
+import AccessibleImage from "../../shared/AccessibleImage";
+import FrameComponent from "./Frame";
+import Badge from "../../shared/Badge";
+import FrameIndent from "./FrameIndent";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+function FrameLocation({ frame, expanded }) {
+ const library = frame.library || getLibraryFromUrl(frame);
+ if (!library) {
+ return null;
+ }
+ const arrowClassName = classnames("arrow", {
+ expanded,
+ });
+ return React.createElement(
+ "span",
+ {
+ className: "group-description",
+ },
+ React.createElement(AccessibleImage, {
+ className: arrowClassName,
+ }),
+ React.createElement(AccessibleImage, {
+ className: `annotation-logo ${library.toLowerCase()}`,
+ }),
+ React.createElement(
+ "span",
+ {
+ className: "group-description-name",
+ },
+ library
+ )
+ );
+}
+
+FrameLocation.propTypes = {
+ expanded: PropTypes.any.isRequired,
+ frame: PropTypes.object.isRequired,
+};
+
+FrameLocation.displayName = "FrameLocation";
+
+export default class Group extends Component {
+ constructor(...args) {
+ super(...args);
+ this.state = { expanded: false };
+ }
+
+ static get propTypes() {
+ return {
+ disableContextMenu: PropTypes.bool.isRequired,
+ displayFullUrl: PropTypes.bool.isRequired,
+ getFrameTitle: PropTypes.func,
+ group: PropTypes.array.isRequired,
+ panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired,
+ selectFrame: PropTypes.func.isRequired,
+ selectLocation: PropTypes.func,
+ selectedFrame: PropTypes.object,
+ showFrameContextMenu: PropTypes.func.isRequired,
+ };
+ }
+
+ get isSelectable() {
+ return this.props.panel == "webconsole";
+ }
+
+ onContextMenu(event) {
+ const { group } = this.props;
+ const frame = group[0];
+ this.props.showFrameContextMenu(event, frame, true);
+ }
+
+ toggleFrames = event => {
+ event.stopPropagation();
+ this.setState(prevState => ({ expanded: !prevState.expanded }));
+ };
+
+ renderFrames() {
+ const {
+ group,
+ selectFrame,
+ selectLocation,
+ selectedFrame,
+ displayFullUrl,
+ getFrameTitle,
+ disableContextMenu,
+ panel,
+ showFrameContextMenu,
+ } = this.props;
+
+ const { expanded } = this.state;
+ if (!expanded) {
+ return null;
+ }
+
+ return React.createElement(
+ "div",
+ {
+ className: "frames-list",
+ },
+ group.map(frame =>
+ React.createElement(FrameComponent, {
+ frame: frame,
+ showFrameContextMenu: showFrameContextMenu,
+ hideLocation: true,
+ key: frame.id,
+ selectedFrame: selectedFrame,
+ selectFrame: selectFrame,
+ selectLocation: selectLocation,
+ shouldMapDisplayName: false,
+ displayFullUrl: displayFullUrl,
+ getFrameTitle: getFrameTitle,
+ disableContextMenu: disableContextMenu,
+ panel: panel,
+ isInGroup: true,
+ })
+ )
+ );
+ }
+
+ renderDescription() {
+ const { l10n } = this.context;
+ const { group } = this.props;
+ const { expanded } = this.state;
+
+ const frame = group[0];
+ const l10NEntry = expanded
+ ? "callStack.group.collapseTooltip"
+ : "callStack.group.expandTooltip";
+ const title = l10n.getFormatStr(l10NEntry, frame.library);
+
+ return React.createElement(
+ "div",
+ {
+ role: "listitem",
+ key: frame.id,
+ className: "group",
+ onClick: this.toggleFrames,
+ tabIndex: 0,
+ title,
+ },
+ this.isSelectable && React.createElement(FrameIndent, null),
+ React.createElement(FrameLocation, {
+ frame,
+ expanded,
+ }),
+ this.isSelectable &&
+ React.createElement(
+ "span",
+ {
+ className: "clipboard-only",
+ },
+ " "
+ ),
+ React.createElement(Badge, { badgeText: this.props.group.length }),
+ this.isSelectable &&
+ React.createElement("br", {
+ className: "clipboard-only",
+ })
+ );
+ }
+
+ render() {
+ const { expanded } = this.state;
+ const { disableContextMenu } = this.props;
+ return React.createElement(
+ "div",
+ {
+ className: classnames("frames-group", {
+ expanded,
+ }),
+ onContextMenu: disableContextMenu ? null : e => this.onContextMenu(e),
+ },
+ this.renderDescription(),
+ this.renderFrames()
+ );
+ }
+}
+
+Group.displayName = "Group";
+Group.contextTypes = { l10n: PropTypes.object };
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js
new file mode 100644
index 0000000000..d83b413a01
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import FrameComponent from "./Frame";
+import Group from "./Group";
+
+import actions from "../../../actions/index";
+import { collapseFrames } from "../../../utils/pause/frames/index";
+
+import {
+ getFrameworkGroupingState,
+ getSelectedFrame,
+ getCurrentThreadFrames,
+ getCurrentThread,
+ getShouldSelectOriginalLocation,
+} from "../../../selectors/index";
+
+const NUM_FRAMES_SHOWN = 7;
+
+class Frames extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ showAllFrames: !!props.disableFrameTruncate,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ disableContextMenu: PropTypes.bool.isRequired,
+ disableFrameTruncate: PropTypes.bool.isRequired,
+ displayFullUrl: PropTypes.bool.isRequired,
+ frames: PropTypes.array.isRequired,
+ frameworkGroupingOn: PropTypes.bool.isRequired,
+ getFrameTitle: PropTypes.func,
+ panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired,
+ selectFrame: PropTypes.func.isRequired,
+ selectLocation: PropTypes.func,
+ selectedFrame: PropTypes.object,
+ showFrameContextMenu: PropTypes.func,
+ shouldDisplayOriginalLocation: PropTypes.bool,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ const {
+ frames,
+ selectedFrame,
+ frameworkGroupingOn,
+ shouldDisplayOriginalLocation,
+ } = this.props;
+ const { showAllFrames } = this.state;
+ return (
+ frames !== nextProps.frames ||
+ selectedFrame !== nextProps.selectedFrame ||
+ showAllFrames !== nextState.showAllFrames ||
+ frameworkGroupingOn !== nextProps.frameworkGroupingOn ||
+ shouldDisplayOriginalLocation !== nextProps.shouldDisplayOriginalLocation
+ );
+ }
+
+ toggleFramesDisplay = () => {
+ this.setState(prevState => ({
+ showAllFrames: !prevState.showAllFrames,
+ }));
+ };
+
+ collapseFrames(frames) {
+ const { frameworkGroupingOn } = this.props;
+ if (!frameworkGroupingOn) {
+ return frames;
+ }
+
+ return collapseFrames(frames);
+ }
+
+ truncateFrames(frames) {
+ const numFramesToShow = this.state.showAllFrames
+ ? frames.length
+ : NUM_FRAMES_SHOWN;
+
+ return frames.slice(0, numFramesToShow);
+ }
+
+ renderFrames(frames) {
+ const {
+ selectFrame,
+ selectLocation,
+ selectedFrame,
+ displayFullUrl,
+ getFrameTitle,
+ disableContextMenu,
+ panel,
+ shouldDisplayOriginalLocation,
+ showFrameContextMenu,
+ } = this.props;
+
+ const framesOrGroups = this.truncateFrames(this.collapseFrames(frames));
+
+ // We're not using a <ul> because it adds new lines before and after when
+ // the user copies the trace. Needed for the console which has several
+ // places where we don't want to have those new lines.
+ return React.createElement(
+ "div",
+ {
+ role: "list",
+ },
+ framesOrGroups.map(frameOrGroup =>
+ frameOrGroup.id
+ ? React.createElement(FrameComponent, {
+ frame: frameOrGroup,
+ showFrameContextMenu: showFrameContextMenu,
+ selectFrame: selectFrame,
+ selectLocation: selectLocation,
+ selectedFrame: selectedFrame,
+ shouldDisplayOriginalLocation: shouldDisplayOriginalLocation,
+ key: String(frameOrGroup.id),
+ displayFullUrl: displayFullUrl,
+ getFrameTitle: getFrameTitle,
+ disableContextMenu: disableContextMenu,
+ panel: panel,
+ })
+ : React.createElement(Group, {
+ group: frameOrGroup,
+ showFrameContextMenu: showFrameContextMenu,
+ selectFrame: selectFrame,
+ selectLocation: selectLocation,
+ selectedFrame: selectedFrame,
+ key: frameOrGroup[0].id,
+ displayFullUrl: displayFullUrl,
+ getFrameTitle: getFrameTitle,
+ disableContextMenu: disableContextMenu,
+ panel: panel,
+ })
+ )
+ );
+ }
+
+ renderToggleButton(frames) {
+ const { l10n } = this.context;
+ const buttonMessage = this.state.showAllFrames
+ ? l10n.getStr("callStack.collapse")
+ : l10n.getStr("callStack.expand");
+
+ frames = this.collapseFrames(frames);
+ if (frames.length <= NUM_FRAMES_SHOWN) {
+ return null;
+ }
+ return React.createElement(
+ "div",
+ {
+ className: "show-more-container",
+ },
+ React.createElement(
+ "button",
+ {
+ className: "show-more",
+ onClick: this.toggleFramesDisplay,
+ },
+ buttonMessage
+ )
+ );
+ }
+
+ render() {
+ const { frames, disableFrameTruncate } = this.props;
+
+ if (!frames) {
+ return React.createElement(
+ "div",
+ {
+ className: "pane frames",
+ },
+ React.createElement(
+ "div",
+ {
+ className: "pane-info empty",
+ },
+ L10N.getStr("callStack.notPaused")
+ )
+ );
+ }
+ return React.createElement(
+ "div",
+ {
+ className: "pane frames",
+ },
+ this.renderFrames(frames),
+ disableFrameTruncate ? null : this.renderToggleButton(frames)
+ );
+ }
+}
+
+Frames.contextTypes = { l10n: PropTypes.object };
+
+const mapStateToProps = state => ({
+ frames: getCurrentThreadFrames(state),
+ frameworkGroupingOn: getFrameworkGroupingState(state),
+ selectedFrame: getSelectedFrame(state, getCurrentThread(state)),
+ shouldDisplayOriginalLocation: getShouldSelectOriginalLocation(state),
+ disableFrameTruncate: false,
+ disableContextMenu: false,
+ displayFullUrl: false,
+});
+
+export default connect(mapStateToProps, {
+ selectFrame: actions.selectFrame,
+ selectLocation: actions.selectLocation,
+ showFrameContextMenu: actions.showFrameContextMenu,
+})(Frames);
+
+// Export the non-connected component in order to use it outside of the debugger
+// panel (e.g. console, netmonitor, …).
+export { Frames };
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build
new file mode 100644
index 0000000000..54c188ed98
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build
@@ -0,0 +1,13 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "Frame.js",
+ "FrameIndent.js",
+ "Group.js",
+ "index.js",
+)
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js
new file mode 100644
index 0000000000..e0dfc58a98
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.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/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow, mount } from "enzyme";
+import Frame from "../Frame.js";
+import { makeMockFrame, makeMockSource } from "../../../../utils/test-mockup";
+
+function frameProperties(frame, selectedFrame, overrides = {}) {
+ return {
+ frame,
+ selectedFrame,
+ copyStackTrace: jest.fn(),
+ contextTypes: {},
+ selectFrame: jest.fn(),
+ selectLocation: jest.fn(),
+ toggleBlackBox: jest.fn(),
+ displayFullUrl: false,
+ frameworkGroupingOn: false,
+ panel: "webconsole",
+ toggleFrameworkGrouping: null,
+ restart: jest.fn(),
+ ...overrides,
+ };
+}
+
+function render(frameToSelect = {}, overrides = {}, propsOverrides = {}) {
+ const source = makeMockSource("foo-view.js");
+ const defaultFrame = makeMockFrame("1", source, undefined, 10, "renderFoo");
+
+ const frame = { ...defaultFrame, ...overrides };
+ const selectedFrame = { ...frame, ...frameToSelect };
+
+ const props = frameProperties(frame, selectedFrame, propsOverrides);
+ const component = shallow(React.createElement(Frame, props));
+ return { component, props };
+}
+
+describe("Frame", () => {
+ it("user frame", () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("user frame (not selected)", () => {
+ const { component } = render({ id: "2" });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("library frame", () => {
+ const source = makeMockSource("backbone.js");
+ const backboneFrame = {
+ ...makeMockFrame("3", source, undefined, 12, "updateEvents"),
+ library: "backbone",
+ };
+
+ const { component } = render({ id: "3" }, backboneFrame);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("filename only", () => {
+ const source = makeMockSource(
+ "https://firefox.com/assets/src/js/foo-view.js"
+ );
+ const frame = makeMockFrame("1", source, undefined, 10, "renderFoo");
+
+ const props = frameProperties(frame, null);
+ const component = mount(React.createElement(Frame, props));
+ expect(component.text()).toBe("    renderFoo foo-view.js:10");
+ });
+
+ it("full URL", () => {
+ const url = `https://${"a".repeat(100)}.com/assets/src/js/foo-view.js`;
+ const source = makeMockSource(url);
+ const frame = makeMockFrame("1", source, undefined, 10, "renderFoo");
+
+ const props = frameProperties(frame, null, { displayFullUrl: true });
+ const component = mount(React.createElement(Frame, props));
+ expect(component.text()).toBe(`    renderFoo ${url}:10`);
+ });
+
+ it("renders asyncCause", () => {
+ const url = `https://example.com/async.js`;
+ const source = makeMockSource(url);
+ const frame = makeMockFrame("1", source, undefined, 10, "timeoutFn");
+ frame.asyncCause = "setTimeout handler";
+
+ const props = frameProperties(frame);
+ const component = mount(React.createElement(Frame, props), {
+ context: { l10n: L10N },
+ });
+ expect(component.find(".location-async-cause").text()).toBe(
+ `    (Async: setTimeout handler)`
+ );
+ });
+
+ it("getFrameTitle", () => {
+ const url = `https://${"a".repeat(100)}.com/assets/src/js/foo-view.js`;
+ const source = makeMockSource(url);
+ const frame = makeMockFrame("1", source, undefined, 10, "renderFoo");
+
+ const props = frameProperties(frame, null, {
+ getFrameTitle: x => `Jump to ${x}`,
+ });
+ const component = shallow(React.createElement(Frame, props));
+ expect(component.prop("title")).toBe(`Jump to ${url}:10`);
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js
new file mode 100644
index 0000000000..240e455f75
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js
@@ -0,0 +1,270 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { mount, shallow } from "enzyme";
+import Frames from "../index.js";
+
+function render(overrides = {}) {
+ const defaultProps = {
+ frames: null,
+ selectedFrame: null,
+ frameworkGroupingOn: false,
+ toggleFrameworkGrouping: jest.fn(),
+ contextTypes: {},
+ selectFrame: jest.fn(),
+ toggleBlackBox: jest.fn(),
+ };
+
+ const props = { ...defaultProps, ...overrides };
+ const component = shallow(
+ React.createElement(Frames.WrappedComponent, props),
+ {
+ context: {
+ l10n: L10N,
+ },
+ }
+ );
+
+ return component;
+}
+
+describe("Frames", () => {
+ describe("Supports different number of frames", () => {
+ it("empty frames", () => {
+ const component = render();
+ expect(component).toMatchSnapshot();
+ expect(component.find(".show-more").exists()).toBeFalsy();
+ });
+
+ it("one frame", () => {
+ const frames = [{ id: 1 }];
+ const selectedFrame = frames[0];
+ const component = render({ frames, selectedFrame });
+
+ expect(component.find(".show-more").exists()).toBeFalsy();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("toggling the show more button", () => {
+ const frames = [
+ { id: 1 },
+ { id: 2 },
+ { id: 3 },
+ { id: 4 },
+ { id: 5 },
+ { id: 6 },
+ { id: 7 },
+ { id: 8 },
+ { id: 9 },
+ { id: 10 },
+ ];
+
+ const selectedFrame = frames[0];
+ const component = render({ selectedFrame, frames });
+
+ const getToggleBtn = () => component.find(".show-more");
+ const getFrames = () => component.find("Frame");
+
+ expect(getToggleBtn().text()).toEqual("Expand rows");
+ expect(getFrames()).toHaveLength(7);
+
+ getToggleBtn().simulate("click");
+ expect(getToggleBtn().text()).toEqual("Collapse rows");
+ expect(getFrames()).toHaveLength(10);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("disable frame truncation", () => {
+ const framesNumber = 20;
+ const frames = Array.from({ length: framesNumber }, (_, i) => ({
+ id: i + 1,
+ }));
+
+ const component = render({
+ frames,
+ disableFrameTruncate: true,
+ });
+
+ const getToggleBtn = () => component.find(".show-more");
+ const getFrames = () => component.find("Frame");
+
+ expect(getToggleBtn().exists()).toBeFalsy();
+ expect(getFrames()).toHaveLength(framesNumber);
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it("shows the full URL", () => {
+ const frames = [
+ {
+ id: 1,
+ displayName: "renderFoo",
+ location: {
+ source: {
+ url: "http://myfile.com/mahscripts.js",
+ },
+ line: 55,
+ },
+ },
+ ];
+
+ const component = mount(
+ <Frames.WrappedComponent
+ frames={frames}
+ disableFrameTruncate={true}
+ displayFullUrl={true}
+ />
+ );
+ expect(component.text()).toBe(
+ "renderFoo http://myfile.com/mahscripts.js:55"
+ );
+ });
+
+ it("passes the getFrameTitle prop to the Frame component", () => {
+ const frames = [
+ {
+ id: 1,
+ displayName: "renderFoo",
+ location: {
+ source: {
+ url: "http://myfile.com/mahscripts.js",
+ },
+ line: 55,
+ },
+ },
+ ];
+ const getFrameTitle = () => {};
+ const component = render({ frames, getFrameTitle });
+
+ expect(component.find("Frame").prop("getFrameTitle")).toBe(getFrameTitle);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("passes the getFrameTitle prop to the Group component", () => {
+ const frames = [
+ {
+ id: 1,
+ displayName: "renderFoo",
+ location: {
+ source: {
+ url: "http://myfile.com/mahscripts.js",
+ },
+ line: 55,
+ },
+ },
+ {
+ id: 2,
+ library: "back",
+ displayName: "a",
+ location: {
+ source: {
+ url: "http://myfile.com/back.js",
+ },
+ line: 55,
+ },
+ },
+ {
+ id: 3,
+ library: "back",
+ displayName: "b",
+ location: {
+ source: {
+ url: "http://myfile.com/back.js",
+ },
+ line: 55,
+ },
+ },
+ ];
+ const getFrameTitle = () => {};
+ const component = render({
+ frames,
+ getFrameTitle,
+ frameworkGroupingOn: true,
+ });
+
+ expect(component.find("Group").prop("getFrameTitle")).toBe(getFrameTitle);
+ });
+ });
+
+ describe("Library Frames", () => {
+ it("toggling framework frames", () => {
+ const frames = [
+ { id: 1, location: { source: {} } },
+ { id: 2, library: "back", location: { source: {} } },
+ { id: 3, library: "back", location: { source: {} } },
+ { id: 8, location: { source: {} } },
+ ];
+
+ const selectedFrame = frames[0];
+ const frameworkGroupingOn = false;
+ const component = render({ frames, frameworkGroupingOn, selectedFrame });
+
+ expect(component.find("Frame")).toHaveLength(4);
+ expect(component).toMatchSnapshot();
+
+ component.setProps({ frameworkGroupingOn: true });
+
+ expect(component.find("Frame")).toHaveLength(2);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("groups all the Webpack-related frames", () => {
+ const frames = [
+ { id: "1-appFrame", location: { source: {} } },
+ {
+ id: "2-webpackBootstrapFrame",
+ location: {
+ source: {
+ url: "webpack:///webpack/bootstrap 01d88449ca6e9335a66f",
+ },
+ },
+ },
+ {
+ id: "3-webpackBundleFrame",
+ location: { source: { url: "https://foo.com/bundle.js" } },
+ },
+ {
+ id: "4-webpackBootstrapFrame",
+ location: {
+ source: {
+ url: "webpack:///webpack/bootstrap 01d88449ca6e9335a66f",
+ },
+ },
+ },
+ {
+ id: "5-webpackBundleFrame",
+ location: { source: { url: "https://foo.com/bundle.js" } },
+ },
+ ];
+ const selectedFrame = frames[0];
+ const frameworkGroupingOn = true;
+ const component = render({ frames, frameworkGroupingOn, selectedFrame });
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it("selectable framework frames", () => {
+ const frames = [
+ { id: 1, location: { source: {} } },
+ { id: 2, library: "back", location: { source: {} } },
+ { id: 3, library: "back", location: { source: {} } },
+ { id: 8, location: { source: {} } },
+ ];
+
+ const selectedFrame = frames[0];
+
+ const component = render({
+ frames,
+ frameworkGroupingOn: false,
+ selectedFrame,
+ selectable: true,
+ });
+ expect(component).toMatchSnapshot();
+
+ component.setProps({ frameworkGroupingOn: true });
+ expect(component).toMatchSnapshot();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js
new file mode 100644
index 0000000000..8d08fa0aed
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import Group from "../Group.js";
+import { makeMockFrame, makeMockSource } from "../../../../utils/test-mockup";
+
+function render(overrides = {}) {
+ const frame = { ...makeMockFrame(), displayName: "foo", library: "Back" };
+ const defaultProps = {
+ group: [frame],
+ selectedFrame: frame,
+ frameworkGroupingOn: true,
+ toggleFrameworkGrouping: jest.fn(),
+ selectFrame: jest.fn(),
+ selectLocation: jest.fn(),
+ copyStackTrace: jest.fn(),
+ toggleBlackBox: jest.fn(),
+ disableContextMenu: false,
+ displayFullUrl: false,
+ panel: "webconsole",
+ restart: jest.fn(),
+ };
+
+ const props = { ...defaultProps, ...overrides };
+ const component = shallow(React.createElement(Group, props), {
+ context: { l10n: L10N },
+ });
+ return { component, props };
+}
+
+describe("Group", () => {
+ it("displays a group", () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("passes the getFrameTitle prop to the Frame components", () => {
+ const mahscripts = makeMockSource("http://myfile.com/mahscripts.js");
+ const back = makeMockSource("http://myfile.com/back.js");
+ const group = [
+ {
+ ...makeMockFrame("1", mahscripts, undefined, 55, "renderFoo"),
+ library: "Back",
+ },
+ {
+ ...makeMockFrame("2", back, undefined, 55, "a"),
+ library: "Back",
+ },
+ {
+ ...makeMockFrame("3", back, undefined, 55, "b"),
+ library: "Back",
+ },
+ ];
+ const getFrameTitle = () => {};
+ const { component } = render({ group, getFrameTitle });
+
+ component.setState({ expanded: true });
+
+ const frameComponents = component.find("Frame");
+ expect(frameComponents).toHaveLength(3);
+ frameComponents.forEach(node => {
+ expect(node.prop("getFrameTitle")).toBe(getFrameTitle);
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("renders group with anonymous functions", () => {
+ const mahscripts = makeMockSource("http://myfile.com/mahscripts.js");
+ const back = makeMockSource("http://myfile.com/back.js");
+ const group = [
+ {
+ ...makeMockFrame("1", mahscripts, undefined, 55),
+ library: "Back",
+ },
+ {
+ ...makeMockFrame("2", back, undefined, 55),
+ library: "Back",
+ },
+ {
+ ...makeMockFrame("3", back, undefined, 55),
+ library: "Back",
+ },
+ ];
+
+ const { component } = render({ group });
+ expect(component).toMatchSnapshot();
+ component.setState({ expanded: true });
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap
new file mode 100644
index 0000000000..90a5b1f906
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap
@@ -0,0 +1,1172 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Frame getFrameTitle 1`] = `
+<div
+ className="frame"
+ key="1"
+ onContextMenu={[Function]}
+ onKeyUp={[Function]}
+ onMouseDown={[Function]}
+ role="listitem"
+ tabIndex={0}
+ title="Jump to https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js:10"
+>
+ <FrameIndent
+ indentLevel={1}
+ />
+ <FrameTitle
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "renderFoo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ options={
+ Object {
+ "shouldMapDisplayName": true,
+ }
+ }
+ />
+ <span
+ className="clipboard-only"
+ >
+
+ </span>
+ <FrameLocation
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "renderFoo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com",
+ "path": "/assets/src/js/foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com/assets/src/js/foo-view.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ />
+ <br
+ className="clipboard-only"
+ />
+</div>
+`;
+
+exports[`Frame library frame 1`] = `
+<div
+ className="frame selected"
+ key="3"
+ onContextMenu={[Function]}
+ onKeyUp={[Function]}
+ onMouseDown={[Function]}
+ role="listitem"
+ tabIndex={0}
+>
+ <FrameIndent
+ indentLevel={1}
+ />
+ <FrameTitle
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "updateEvents",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 12,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "3",
+ "index": 0,
+ "library": "backbone",
+ "location": Object {
+ "column": undefined,
+ "line": 12,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ options={
+ Object {
+ "shouldMapDisplayName": true,
+ }
+ }
+ />
+ <span
+ className="clipboard-only"
+ >
+
+ </span>
+ <FrameLocation
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "updateEvents",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 12,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "3",
+ "index": 0,
+ "library": "backbone",
+ "location": Object {
+ "column": undefined,
+ "line": 12,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "backbone.js",
+ "group": "",
+ "path": "backbone.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "backbone.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ />
+ <br
+ className="clipboard-only"
+ />
+</div>
+`;
+
+exports[`Frame user frame (not selected) 1`] = `
+<div
+ className="frame"
+ key="1"
+ onContextMenu={[Function]}
+ onKeyUp={[Function]}
+ onMouseDown={[Function]}
+ role="listitem"
+ tabIndex={0}
+>
+ <FrameIndent
+ indentLevel={1}
+ />
+ <FrameTitle
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "renderFoo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ options={
+ Object {
+ "shouldMapDisplayName": true,
+ }
+ }
+ />
+ <span
+ className="clipboard-only"
+ >
+
+ </span>
+ <FrameLocation
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "renderFoo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ />
+ <br
+ className="clipboard-only"
+ />
+</div>
+`;
+
+exports[`Frame user frame 1`] = `
+<div
+ className="frame selected"
+ key="1"
+ onContextMenu={[Function]}
+ onKeyUp={[Function]}
+ onMouseDown={[Function]}
+ role="listitem"
+ tabIndex={0}
+>
+ <FrameIndent
+ indentLevel={1}
+ />
+ <FrameTitle
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "renderFoo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ options={
+ Object {
+ "shouldMapDisplayName": true,
+ }
+ }
+ />
+ <span
+ className="clipboard-only"
+ >
+
+ </span>
+ <FrameLocation
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "renderFoo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 10,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "foo-view.js",
+ "group": "",
+ "path": "foo-view.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "foo-view.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ />
+ <br
+ className="clipboard-only"
+ />
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap
new file mode 100644
index 0000000000..d1068b1aa0
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap
@@ -0,0 +1,1001 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Frames Library Frames groups all the Webpack-related frames 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": "1-appFrame",
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="1-appFrame"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": "1-appFrame",
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Group
+ group={
+ Array [
+ Object {
+ "id": "2-webpackBootstrapFrame",
+ "location": Object {
+ "source": Object {
+ "url": "webpack:///webpack/bootstrap 01d88449ca6e9335a66f",
+ },
+ },
+ },
+ Object {
+ "id": "3-webpackBundleFrame",
+ "location": Object {
+ "source": Object {
+ "url": "https://foo.com/bundle.js",
+ },
+ },
+ },
+ Object {
+ "id": "4-webpackBootstrapFrame",
+ "location": Object {
+ "source": Object {
+ "url": "webpack:///webpack/bootstrap 01d88449ca6e9335a66f",
+ },
+ },
+ },
+ Object {
+ "id": "5-webpackBundleFrame",
+ "location": Object {
+ "source": Object {
+ "url": "https://foo.com/bundle.js",
+ },
+ },
+ },
+ ]
+ }
+ key="2-webpackBootstrapFrame"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": "1-appFrame",
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames selectable framework frames 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 2,
+ "library": "back",
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 3,
+ "library": "back",
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames selectable framework frames 2`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Group
+ group={
+ Array [
+ Object {
+ "id": 2,
+ "library": "back",
+ "location": Object {
+ "source": Object {},
+ },
+ },
+ Object {
+ "id": 3,
+ "library": "back",
+ "location": Object {
+ "source": Object {},
+ },
+ },
+ ]
+ }
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames toggling framework frames 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 2,
+ "library": "back",
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 3,
+ "library": "back",
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames toggling framework frames 2`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Group
+ group={
+ Array [
+ Object {
+ "id": 2,
+ "library": "back",
+ "location": Object {
+ "source": Object {},
+ },
+ },
+ Object {
+ "id": 3,
+ "library": "back",
+ "location": Object {
+ "source": Object {},
+ },
+ },
+ ]
+ }
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ "location": Object {
+ "source": Object {},
+ },
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Supports different number of frames disable frame truncation 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 2,
+ }
+ }
+ hideLocation={false}
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 3,
+ }
+ }
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 4,
+ }
+ }
+ hideLocation={false}
+ key="4"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 5,
+ }
+ }
+ hideLocation={false}
+ key="5"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 6,
+ }
+ }
+ hideLocation={false}
+ key="6"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 7,
+ }
+ }
+ hideLocation={false}
+ key="7"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ }
+ }
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 9,
+ }
+ }
+ hideLocation={false}
+ key="9"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 10,
+ }
+ }
+ hideLocation={false}
+ key="10"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 11,
+ }
+ }
+ hideLocation={false}
+ key="11"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 12,
+ }
+ }
+ hideLocation={false}
+ key="12"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 13,
+ }
+ }
+ hideLocation={false}
+ key="13"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 14,
+ }
+ }
+ hideLocation={false}
+ key="14"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 15,
+ }
+ }
+ hideLocation={false}
+ key="15"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 16,
+ }
+ }
+ hideLocation={false}
+ key="16"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 17,
+ }
+ }
+ hideLocation={false}
+ key="17"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 18,
+ }
+ }
+ hideLocation={false}
+ key="18"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 19,
+ }
+ }
+ hideLocation={false}
+ key="19"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 20,
+ }
+ }
+ hideLocation={false}
+ key="20"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Supports different number of frames empty frames 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ className="pane-info empty"
+ >
+ Not paused
+ </div>
+</div>
+`;
+
+exports[`Frames Supports different number of frames one frame 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Supports different number of frames passes the getFrameTitle prop to the Frame component 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "displayName": "renderFoo",
+ "id": 1,
+ "location": Object {
+ "line": 55,
+ "source": Object {
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ }
+ }
+ getFrameTitle={[Function]}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Supports different number of frames toggling the show more button 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 2,
+ }
+ }
+ hideLocation={false}
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 3,
+ }
+ }
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 4,
+ }
+ }
+ hideLocation={false}
+ key="4"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 5,
+ }
+ }
+ hideLocation={false}
+ key="5"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 6,
+ }
+ }
+ hideLocation={false}
+ key="6"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 7,
+ }
+ }
+ hideLocation={false}
+ key="7"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ }
+ }
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 9,
+ }
+ }
+ hideLocation={false}
+ key="9"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ <Frame
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 10,
+ }
+ }
+ hideLocation={false}
+ key="10"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ />
+ </div>
+ <div
+ className="show-more-container"
+ >
+ <button
+ className="show-more"
+ onClick={[Function]}
+ >
+ Collapse rows
+ </button>
+ </div>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap
new file mode 100644
index 0000000000..97c4c2ad1a
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap
@@ -0,0 +1,2286 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Group displays a group 1`] = `
+<div
+ className="frames-group"
+ onContextMenu={[Function]}
+>
+ <div
+ className="group"
+ key="frame"
+ onClick={[Function]}
+ role="listitem"
+ tabIndex={0}
+ title="Show Back frames"
+ >
+ <FrameIndent />
+ <FrameLocation
+ expanded={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "foo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "frame",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ />
+ <span
+ className="clipboard-only"
+ >
+
+ </span>
+ <Badge
+ badgeText={1}
+ />
+ <br
+ className="clipboard-only"
+ />
+ </div>
+</div>
+`;
+
+exports[`Group passes the getFrameTitle prop to the Frame components 1`] = `
+<div
+ className="frames-group expanded"
+ onContextMenu={[Function]}
+>
+ <div
+ className="group"
+ key="1"
+ onClick={[Function]}
+ role="listitem"
+ tabIndex={0}
+ title="Collapse Back frames"
+ >
+ <FrameIndent />
+ <FrameLocation
+ expanded={true}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "renderFoo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ />
+ <span
+ className="clipboard-only"
+ >
+
+ </span>
+ <Badge
+ badgeText={3}
+ />
+ <br
+ className="clipboard-only"
+ />
+ </div>
+ <div
+ className="frames-list"
+ >
+ <Frame
+ disableContextMenu={false}
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "renderFoo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ getFrameTitle={[Function]}
+ hideLocation={true}
+ isInGroup={true}
+ key="1"
+ panel="webconsole"
+ selectFrame={[MockFunction]}
+ selectLocation={[MockFunction]}
+ selectedFrame={
+ Object {
+ "asyncCause": null,
+ "displayName": "foo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "frame",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ shouldMapDisplayName={false}
+ />
+ <Frame
+ disableContextMenu={false}
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "a",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "2",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ getFrameTitle={[Function]}
+ hideLocation={true}
+ isInGroup={true}
+ key="2"
+ panel="webconsole"
+ selectFrame={[MockFunction]}
+ selectLocation={[MockFunction]}
+ selectedFrame={
+ Object {
+ "asyncCause": null,
+ "displayName": "foo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "frame",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ shouldMapDisplayName={false}
+ />
+ <Frame
+ disableContextMenu={false}
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "b",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "3",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ getFrameTitle={[Function]}
+ hideLocation={true}
+ isInGroup={true}
+ key="3"
+ panel="webconsole"
+ selectFrame={[MockFunction]}
+ selectLocation={[MockFunction]}
+ selectedFrame={
+ Object {
+ "asyncCause": null,
+ "displayName": "foo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "frame",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ shouldMapDisplayName={false}
+ />
+ </div>
+</div>
+`;
+
+exports[`Group renders group with anonymous functions 1`] = `
+<div
+ className="frames-group"
+ onContextMenu={[Function]}
+>
+ <div
+ className="group"
+ key="1"
+ onClick={[Function]}
+ role="listitem"
+ tabIndex={0}
+ title="Show Back frames"
+ >
+ <FrameIndent />
+ <FrameLocation
+ expanded={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-1",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ />
+ <span
+ className="clipboard-only"
+ >
+
+ </span>
+ <Badge
+ badgeText={3}
+ />
+ <br
+ className="clipboard-only"
+ />
+ </div>
+</div>
+`;
+
+exports[`Group renders group with anonymous functions 2`] = `
+<div
+ className="frames-group expanded"
+ onContextMenu={[Function]}
+>
+ <div
+ className="group"
+ key="1"
+ onClick={[Function]}
+ role="listitem"
+ tabIndex={0}
+ title="Collapse Back frames"
+ >
+ <FrameIndent />
+ <FrameLocation
+ expanded={true}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-1",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ />
+ <span
+ className="clipboard-only"
+ >
+
+ </span>
+ <Badge
+ badgeText={3}
+ />
+ <br
+ className="clipboard-only"
+ />
+ </div>
+ <div
+ className="frames-list"
+ >
+ <Frame
+ disableContextMenu={false}
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-1",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "1",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "mahscripts.js",
+ "group": "myfile.com",
+ "path": "/mahscripts.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ hideLocation={true}
+ isInGroup={true}
+ key="1"
+ panel="webconsole"
+ selectFrame={[MockFunction]}
+ selectLocation={[MockFunction]}
+ selectedFrame={
+ Object {
+ "asyncCause": null,
+ "displayName": "foo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "frame",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ shouldMapDisplayName={false}
+ />
+ <Frame
+ disableContextMenu={false}
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-2",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "2",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ hideLocation={true}
+ isInGroup={true}
+ key="2"
+ panel="webconsole"
+ selectFrame={[MockFunction]}
+ selectLocation={[MockFunction]}
+ selectedFrame={
+ Object {
+ "asyncCause": null,
+ "displayName": "foo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "frame",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ shouldMapDisplayName={false}
+ />
+ <Frame
+ disableContextMenu={false}
+ displayFullUrl={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-3",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "3",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 55,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "js",
+ "filename": "back.js",
+ "group": "myfile.com",
+ "path": "/back.js",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "http://myfile.com/back.js",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ hideLocation={true}
+ isInGroup={true}
+ key="3"
+ panel="webconsole"
+ selectFrame={[MockFunction]}
+ selectLocation={[MockFunction]}
+ selectedFrame={
+ Object {
+ "asyncCause": null,
+ "displayName": "foo",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "id": "frame",
+ "index": 0,
+ "library": "Back",
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "sourceActor": Object {
+ "actor": "source-actor",
+ "id": "source-actor",
+ "source": "source",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ },
+ "sourceActorId": "source-actor",
+ },
+ "scope": Object {
+ "actor": "scope-actor",
+ "bindings": Object {
+ "arguments": Array [],
+ "variables": Object {},
+ },
+ "function": null,
+ "object": null,
+ "parent": null,
+ "scopeKind": "",
+ "type": "block",
+ },
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ shouldMapDisplayName={false}
+ />
+ </div>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css
new file mode 100644
index 0000000000..07ebd44048
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css
@@ -0,0 +1,100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.secondary-panes .map-scopes-header {
+ padding-inline-end: 3px;
+}
+
+.secondary-panes .header-buttons .img.shortcuts {
+ width: 14px;
+ height: 14px;
+ /* Better vertical centering of the icon */
+ margin-top: -2px;
+}
+
+.scopes-content .node.object-node {
+ padding-inline-start: 7px;
+}
+
+.scopes-content .toggle-map-scopes a.mdn {
+ padding-inline-start: 3px;
+}
+
+.scopes-content .toggle-map-scopes .img.shortcuts {
+ background: var(--theme-comment);
+}
+
+.scopes-content .object-node.default-property {
+ opacity: 0.6;
+}
+
+.scopes-content .object-node {
+ padding-inline-start: 20px;
+}
+
+html[dir="rtl"] .scopes-content .object-node {
+ padding-right: 4px;
+}
+
+.scopes-content .object-label {
+ color: var(--theme-highlight-blue);
+}
+
+.objectBox-object,
+.objectBox-text,
+.objectBox-table,
+.objectLink-textNode,
+.objectLink-event,
+.objectLink-eventLog,
+.objectLink-regexp,
+.objectLink-object,
+.objectLink-Date,
+.theme-dark .objectBox-object,
+.theme-light .objectBox-object {
+ white-space: nowrap;
+}
+
+.scopes-pane ._content {
+ overflow: auto;
+}
+
+.scopes-list {
+ padding: 4px 0px;
+}
+
+.scopes-list .function-signature {
+ display: inline-block;
+}
+
+.scopes-list .scope-type-toggle {
+ text-align: center;
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+
+.scopes-list .scope-type-toggle button {
+ /* Override color so that the link doesn't turn purple */
+ color: var(--theme-body-color);
+ font-size: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.scopes-list .scope-type-toggle button:hover {
+ background: transparent;
+}
+
+.scopes-list .tree.object-inspector .node.object-node {
+ display: flex;
+ align-items: center;
+}
+
+.scopes-list .tree.object-inspector .tree-node button.arrow,
+.scopes-list button.invoke-getter {
+ margin-top: 2px;
+}
+
+.scopes-list .tree {
+ line-height: 15px;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js
new file mode 100644
index 0000000000..135decd254
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js
@@ -0,0 +1,413 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ button,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import AccessibleImage from "../shared/AccessibleImage";
+import { showMenu } from "../../context-menu/menu";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../actions/index";
+
+import {
+ getSelectedFrame,
+ getCurrentThread,
+ getSelectedSource,
+ getGeneratedFrameScope,
+ getOriginalFrameScope,
+ getPauseReason,
+ isMapScopesEnabled,
+ getLastExpandedScopes,
+ getIsCurrentThreadPaused,
+} from "../../selectors/index";
+import {
+ getScopesItemsForSelectedFrame,
+ getScopeItemPath,
+} from "../../utils/pause/scopes";
+import { clientCommands } from "../../client/firefox";
+
+import { objectInspector } from "devtools/client/shared/components/reps/index";
+const { ObjectInspector } = objectInspector;
+
+class Scopes extends PureComponent {
+ constructor(props) {
+ const { why, selectedFrame, originalFrameScopes, generatedFrameScopes } =
+ props;
+
+ super(props);
+
+ this.state = {
+ originalScopes: getScopesItemsForSelectedFrame(
+ why,
+ selectedFrame,
+ originalFrameScopes
+ ),
+ generatedScopes: getScopesItemsForSelectedFrame(
+ why,
+ selectedFrame,
+ generatedFrameScopes
+ ),
+ };
+ }
+
+ static get propTypes() {
+ return {
+ addWatchpoint: PropTypes.func.isRequired,
+ expandedScopes: PropTypes.array.isRequired,
+ generatedFrameScopes: PropTypes.object,
+ highlightDomElement: PropTypes.func.isRequired,
+ isLoading: PropTypes.bool.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ mapScopesEnabled: PropTypes.bool.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ openLink: PropTypes.func.isRequired,
+ originalFrameScopes: PropTypes.object,
+ removeWatchpoint: PropTypes.func.isRequired,
+ setExpandedScope: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ why: PropTypes.object.isRequired,
+ selectedFrame: PropTypes.object,
+ toggleMapScopes: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const {
+ selectedFrame,
+ originalFrameScopes,
+ generatedFrameScopes,
+ isPaused,
+ selectedSource,
+ } = this.props;
+ const isPausedChanged = isPaused !== nextProps.isPaused;
+ const selectedFrameChanged = selectedFrame !== nextProps.selectedFrame;
+ const originalFrameScopesChanged =
+ originalFrameScopes !== nextProps.originalFrameScopes;
+ const generatedFrameScopesChanged =
+ generatedFrameScopes !== nextProps.generatedFrameScopes;
+ const selectedSourceChanged = selectedSource !== nextProps.selectedSource;
+
+ if (
+ isPausedChanged ||
+ selectedFrameChanged ||
+ originalFrameScopesChanged ||
+ generatedFrameScopesChanged ||
+ selectedSourceChanged
+ ) {
+ this.setState({
+ originalScopes: getScopesItemsForSelectedFrame(
+ nextProps.why,
+ nextProps.selectedFrame,
+ nextProps.originalFrameScopes
+ ),
+ generatedScopes: getScopesItemsForSelectedFrame(
+ nextProps.why,
+ nextProps.selectedFrame,
+ nextProps.generatedFrameScopes
+ ),
+ });
+ }
+ }
+
+ onContextMenu = (event, item) => {
+ const { addWatchpoint, removeWatchpoint } = this.props;
+
+ if (!item.parent || !item.contents.configurable) {
+ return;
+ }
+
+ if (!item.contents || item.contents.watchpoint) {
+ const removeWatchpointLabel = L10N.getStr("watchpoints.removeWatchpoint");
+
+ const removeWatchpointItem = {
+ id: "node-menu-remove-watchpoint",
+ label: removeWatchpointLabel,
+ disabled: false,
+ click: () => removeWatchpoint(item),
+ };
+
+ const menuItems = [removeWatchpointItem];
+ showMenu(event, menuItems);
+ return;
+ }
+
+ const addSetWatchpointLabel = L10N.getStr("watchpoints.setWatchpoint");
+ const addGetWatchpointLabel = L10N.getStr("watchpoints.getWatchpoint");
+ const addGetOrSetWatchpointLabel = L10N.getStr(
+ "watchpoints.getOrSetWatchpoint"
+ );
+ const watchpointsSubmenuLabel = L10N.getStr("watchpoints.submenu");
+
+ const addSetWatchpointItem = {
+ id: "node-menu-add-set-watchpoint",
+ label: addSetWatchpointLabel,
+ disabled: false,
+ click: () => addWatchpoint(item, "set"),
+ };
+
+ const addGetWatchpointItem = {
+ id: "node-menu-add-get-watchpoint",
+ label: addGetWatchpointLabel,
+ disabled: false,
+ click: () => addWatchpoint(item, "get"),
+ };
+
+ const addGetOrSetWatchpointItem = {
+ id: "node-menu-add-get-watchpoint",
+ label: addGetOrSetWatchpointLabel,
+ disabled: false,
+ click: () => addWatchpoint(item, "getorset"),
+ };
+
+ const watchpointsSubmenuItem = {
+ id: "node-menu-watchpoints",
+ label: watchpointsSubmenuLabel,
+ disabled: false,
+ click: () => addWatchpoint(item, "set"),
+ submenu: [
+ addSetWatchpointItem,
+ addGetWatchpointItem,
+ addGetOrSetWatchpointItem,
+ ],
+ };
+
+ const menuItems = [watchpointsSubmenuItem];
+ showMenu(event, menuItems);
+ };
+
+ renderWatchpointButton = item => {
+ const { removeWatchpoint } = this.props;
+
+ if (
+ !item ||
+ !item.contents ||
+ !item.contents.watchpoint ||
+ typeof L10N === "undefined"
+ ) {
+ return null;
+ }
+
+ const { watchpoint } = item.contents;
+ return button({
+ className: `remove-watchpoint-${watchpoint}`,
+ title: L10N.getStr("watchpoints.removeWatchpointTooltip"),
+ onClick: e => {
+ e.stopPropagation();
+ removeWatchpoint(item);
+ },
+ });
+ };
+
+ renderScopesList() {
+ const {
+ isLoading,
+ openLink,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ mapScopesEnabled,
+ selectedFrame,
+ setExpandedScope,
+ expandedScopes,
+ selectedSource,
+ } = this.props;
+
+ if (!selectedSource) {
+ return div(
+ { className: "pane scopes-list" },
+ div({ className: "pane-info" }, L10N.getStr("scopes.notAvailable"))
+ );
+ }
+
+ const { originalScopes, generatedScopes } = this.state;
+ let scopes = null;
+
+ if (
+ selectedSource.isOriginal &&
+ !selectedSource.isPrettyPrinted &&
+ !selectedFrame.generatedLocation?.source.isWasm
+ ) {
+ if (!mapScopesEnabled) {
+ return div(
+ { className: "pane scopes-list" },
+ div(
+ {
+ className: "pane-info no-original-scopes-info",
+ "aria-role": "status",
+ },
+ span(
+ { className: "info icon" },
+ React.createElement(AccessibleImage, { className: "sourcemap" })
+ ),
+ L10N.getFormatStr(
+ "scopes.noOriginalScopes",
+ L10N.getStr("scopes.showOriginalScopes")
+ )
+ )
+ );
+ }
+ if (isLoading) {
+ return div(
+ {
+ className: "pane scopes-list",
+ },
+ div(
+ { className: "pane-info" },
+ span(
+ { className: "info icon" },
+ React.createElement(AccessibleImage, { className: "loader" })
+ ),
+ L10N.getStr("scopes.loadingOriginalScopes")
+ )
+ );
+ }
+ scopes = originalScopes;
+ } else {
+ if (isLoading) {
+ return div(
+ {
+ className: "pane scopes-list",
+ },
+ div(
+ { className: "pane-info" },
+ span(
+ { className: "info icon" },
+ React.createElement(AccessibleImage, { className: "loader" })
+ ),
+ L10N.getStr("loadingText")
+ )
+ );
+ }
+ scopes = generatedScopes;
+ }
+
+ function initiallyExpanded(item) {
+ return expandedScopes.some(path => path == getScopeItemPath(item));
+ }
+
+ if (scopes && !!scopes.length) {
+ return div(
+ {
+ className: "pane scopes-list",
+ },
+ React.createElement(ObjectInspector, {
+ roots: scopes,
+ autoExpandAll: false,
+ autoExpandDepth: 1,
+ client: clientCommands,
+ createElement: tagName => document.createElement(tagName),
+ disableWrap: true,
+ dimTopLevelWindow: true,
+ frame: selectedFrame,
+ mayUseCustomFormatter: true,
+ openLink: openLink,
+ onDOMNodeClick: grip => openElementInInspector(grip),
+ onInspectIconClick: grip => openElementInInspector(grip),
+ onDOMNodeMouseOver: grip => highlightDomElement(grip),
+ onDOMNodeMouseOut: grip => unHighlightDomElement(grip),
+ onContextMenu: this.onContextMenu,
+ setExpanded: (path, expand) =>
+ setExpandedScope(selectedFrame, path, expand),
+ initiallyExpanded: initiallyExpanded,
+ renderItemActions: this.renderWatchpointButton,
+ shouldRenderTooltip: true,
+ })
+ );
+ }
+
+ return div(
+ {
+ className: "pane scopes-list",
+ },
+ div(
+ {
+ className: "pane-info",
+ },
+ L10N.getStr("scopes.notAvailable")
+ )
+ );
+ }
+
+ render() {
+ return div(
+ {
+ className: "scopes-content",
+ },
+ this.renderScopesList()
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ // This component doesn't need any prop when we are not paused
+ const selectedFrame = getSelectedFrame(state, getCurrentThread(state));
+ if (!selectedFrame) {
+ return {};
+ }
+ const why = getPauseReason(state, selectedFrame.thread);
+ const expandedScopes = getLastExpandedScopes(state, selectedFrame.thread);
+ const isPaused = getIsCurrentThreadPaused(state);
+ const selectedSource = getSelectedSource(state);
+
+ let originalFrameScopes;
+ let generatedFrameScopes;
+ let isLoading;
+ let mapScopesEnabled;
+
+ if (
+ selectedSource?.isOriginal &&
+ !selectedSource?.isPrettyPrinted &&
+ !selectedFrame.generatedLocation?.source.isWasm
+ ) {
+ const { scope, pending: originalPending } = getOriginalFrameScope(
+ state,
+ selectedFrame
+ ) || {
+ scope: null,
+ pending: false,
+ };
+ originalFrameScopes = scope;
+ isLoading = originalPending;
+ mapScopesEnabled = isMapScopesEnabled(state);
+ } else {
+ const { scope, pending: generatedPending } = getGeneratedFrameScope(
+ state,
+ selectedFrame
+ ) || {
+ scope: null,
+ pending: false,
+ };
+ generatedFrameScopes = scope;
+ isLoading = generatedPending;
+ }
+
+ return {
+ originalFrameScopes,
+ generatedFrameScopes,
+ mapScopesEnabled,
+ selectedFrame,
+ isLoading,
+ why,
+ expandedScopes,
+ isPaused,
+ selectedSource,
+ };
+};
+
+export default connect(mapStateToProps, {
+ openLink: actions.openLink,
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+ setExpandedScope: actions.setExpandedScope,
+ addWatchpoint: actions.addWatchpoint,
+ removeWatchpoint: actions.removeWatchpoint,
+ toggleMapScopes: actions.toggleMapScopes,
+})(Scopes);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css b/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css
new file mode 100644
index 0000000000..6432e9fe75
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.secondary-panes {
+ overflow-x: hidden;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ white-space: nowrap;
+ background-color: var(--theme-sidebar-background);
+ --breakpoint-expression-right-clear-space: 36px;
+ --paused-background-color: hsl(54, 100%, 92%);
+ --paused-color: var(--theme-body-color);
+
+ .theme-dark & {
+ --paused-background-color: hsl(42, 37%, 19%);
+ --paused-color: hsl(43, 94%, 81%);
+ }
+}
+
+.secondary-panes .controlled > div {
+ max-width: 100%;
+}
+
+/*
+ We apply overflow to the container with the commandbar.
+ This allows the commandbar to remain fixed when scrolling
+ until the content completely ends. Not just the height of
+ the wrapper.
+ Ref: https://github.com/firefox-devtools/debugger/issues/3426
+*/
+
+.secondary-panes-wrapper {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.secondary-panes .accordion {
+ flex: 1 0 auto;
+ margin-bottom: 0;
+}
+
+.secondary-panes-wrapper .accordion li:last-child ._content {
+ border-bottom: 0;
+}
+
+.pane {
+ color: var(--theme-body-color);
+}
+
+.pane .pane-info {
+ text-align: start;
+ padding: 0.5em;
+ gap: 8px;
+ display: flex;
+ white-space: normal;
+}
+
+.pane .pane-info.no-original-scopes-info {
+ background-color: var(--theme-warning-background);
+ color: var(--theme-warning-color);
+}
+
+.secondary-panes .breakpoints-buttons {
+ display: flex;
+}
+
+.dropdown {
+ width: 20em;
+ overflow: auto;
+}
+
+.secondary-panes input[type="checkbox"] {
+ margin: 0;
+ margin-inline-end: 4px;
+ vertical-align: text-top;
+}
+
+.secondary-panes-wrapper .command-bar.bottom {
+ background-color: var(--theme-body-background);
+}
+
+/**
+ * Skip Pausing style
+ * Add a gray background and lower content opacity
+ */
+.skip-pausing .xhr-breakpoints-pane ._content,
+.skip-pausing .breakpoints-pane ._content,
+.skip-pausing .event-listeners-pane ._content,
+.skip-pausing .dom-mutations-pane ._content {
+ background-color: var(--skip-pausing-background-color);
+ opacity: var(--skip-pausing-opacity);
+ color: var(--skip-pausing-color);
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Thread.js b/devtools/client/debugger/src/components/SecondaryPanes/Thread.js
new file mode 100644
index 0000000000..bfa73f1346
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Thread.js
@@ -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/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div, span } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import actions from "../../actions/index";
+import { getCurrentThread, getIsPaused } from "../../selectors/index";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+export class Thread extends Component {
+ static get propTypes() {
+ return {
+ currentThread: PropTypes.string.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ selectThread: PropTypes.func.isRequired,
+ thread: PropTypes.object.isRequired,
+ };
+ }
+
+ onSelectThread = () => {
+ this.props.selectThread(this.props.thread.actor);
+ };
+
+ render() {
+ const { currentThread, isPaused, thread } = this.props;
+
+ const isWorker = thread.targetType.includes("worker");
+ let label = thread.name;
+ if (thread.serviceWorkerStatus) {
+ label += ` (${thread.serviceWorkerStatus})`;
+ }
+ return div(
+ {
+ className: classnames("thread", {
+ selected: thread.actor == currentThread,
+ paused: isPaused,
+ }),
+ key: thread.actor,
+ onClick: this.onSelectThread,
+ },
+ div(
+ {
+ className: "icon",
+ },
+ React.createElement(AccessibleImage, {
+ className: isWorker ? "worker" : "window",
+ })
+ ),
+ div(
+ {
+ className: "label",
+ },
+ label
+ ),
+ isPaused
+ ? span(
+ {
+ className: "pause-badge",
+ role: "status",
+ },
+ L10N.getStr("pausedThread")
+ )
+ : null
+ );
+ }
+}
+
+const mapStateToProps = (state, props) => ({
+ currentThread: getCurrentThread(state),
+ isPaused: getIsPaused(state, props.thread.actor),
+});
+
+export default connect(mapStateToProps, {
+ selectThread: actions.selectThread,
+})(Thread);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Threads.css b/devtools/client/debugger/src/components/SecondaryPanes/Threads.css
new file mode 100644
index 0000000000..603aa3ad8a
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Threads.css
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.threads-list {
+ padding: 4px 0;
+}
+
+.threads-list * {
+ user-select: none;
+}
+
+.threads-list > .thread {
+ font-size: inherit;
+ color: var(--theme-text-color-strong);
+ padding: 2px 6px;
+ padding-inline-start: 20px;
+ line-height: 16px;
+ position: relative;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ &:hover {
+ background-color: var(--search-overlays-semitransparent);
+ }
+
+ &.selected {
+ background: var(--theme-selection-background);
+ color: var(--theme-selection-color);
+
+ & .img {
+ background-color: currentColor;
+ }
+ }
+
+ &.paused:not(.selected) {
+ background-color: var(--paused-background-color);
+ color: var(--paused-color);
+ }
+}
+
+.threads-list .icon {
+ flex: none;
+}
+
+.threads-list .img {
+ display: block;
+}
+
+.threads-list .label {
+ display: inline-block;
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.threads-list .pause-badge {
+ flex: none;
+ font-weight: bold;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Threads.js b/devtools/client/debugger/src/components/SecondaryPanes/Threads.js
new file mode 100644
index 0000000000..2d21a1dcc5
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Threads.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import { getAllThreads } from "../../selectors/index";
+import Thread from "./Thread";
+
+export class Threads extends Component {
+ static get propTypes() {
+ return {
+ threads: PropTypes.array.isRequired,
+ };
+ }
+
+ render() {
+ const { threads } = this.props;
+ return div(
+ {
+ className: "pane threads-list",
+ },
+ threads.map(thread =>
+ React.createElement(Thread, {
+ thread: thread,
+ key: thread.actor,
+ })
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ threads: getAllThreads(state),
+});
+
+export default connect(mapStateToProps)(Threads);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css
new file mode 100644
index 0000000000..6fa5bc42d7
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.why-paused {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ border-bottom: 1px solid var(--theme-splitter-color);
+ background-color: var(--paused-background-color);
+ color: var(--paused-color);
+ font-size: 12px;
+ cursor: default;
+ min-height: 44px;
+ padding: 6px;
+ white-space: normal;
+ font-weight: bold;
+}
+
+.why-paused > div {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.why-paused .info.icon {
+ align-self: center;
+ padding-right: 4px;
+ margin-inline-start: 14px;
+ margin-inline-end: 3px;
+}
+
+.why-paused .pause.reason {
+ display: flex;
+ flex-direction: column;
+ padding-right: 4px;
+}
+
+.why-paused .message {
+ font-style: italic;
+ font-weight: 100;
+}
+
+.why-paused .mutationNode {
+ font-weight: normal;
+}
+
+.why-paused .message.warning {
+ color: var(--theme-graphs-full-red);
+ font-family: var(--monospace-font-family);
+ font-size: 10px;
+ font-style: normal;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js
new file mode 100644
index 0000000000..fabd75a7ce
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js
@@ -0,0 +1,218 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+const {
+ LocalizationProvider,
+ Localized,
+} = require("resource://devtools/client/shared/vendor/fluent-react.js");
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import { div, span } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import AccessibleImage from "../shared/AccessibleImage";
+import actions from "../../actions/index";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { Rep },
+ MODE,
+} = Reps;
+
+import { getPauseReason } from "../../utils/pause/index";
+import {
+ getCurrentThread,
+ getPaneCollapse,
+ getPauseReason as getWhy,
+} from "../../selectors/index";
+
+class WhyPaused extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { hideWhyPaused: "" };
+ }
+
+ static get propTypes() {
+ return {
+ delay: PropTypes.number.isRequired,
+ endPanelCollapsed: PropTypes.bool.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ why: PropTypes.object,
+ };
+ }
+
+ componentDidUpdate() {
+ const { delay } = this.props;
+
+ if (delay) {
+ setTimeout(() => {
+ this.setState({ hideWhyPaused: "" });
+ }, delay);
+ } else {
+ this.setState({ hideWhyPaused: "pane why-paused" });
+ }
+ }
+
+ renderExceptionSummary(exception) {
+ if (typeof exception === "string") {
+ return exception;
+ }
+
+ const { preview } = exception;
+ if (!preview || !preview.name || !preview.message) {
+ return null;
+ }
+
+ return `${preview.name}: ${preview.message}`;
+ }
+
+ renderMessage(why) {
+ const { type, exception, message } = why;
+
+ if (type == "exception" && exception) {
+ // Our types for 'Why' are too general because 'type' can be 'string'.
+ // $FlowFixMe - We should have a proper discriminating union of reasons.
+ const summary = this.renderExceptionSummary(exception);
+ return div(
+ {
+ className: "message warning",
+ },
+ summary
+ );
+ }
+
+ if (type === "mutationBreakpoint" && why.nodeGrip) {
+ const { nodeGrip, ancestorGrip, action } = why;
+ const {
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const targetRep = Rep({
+ object: nodeGrip,
+ mode: MODE.TINY,
+ onDOMNodeClick: () => openElementInInspector(nodeGrip),
+ onInspectIconClick: () => openElementInInspector(nodeGrip),
+ onDOMNodeMouseOver: () => highlightDomElement(nodeGrip),
+ onDOMNodeMouseOut: () => unHighlightDomElement(),
+ });
+
+ const ancestorRep = ancestorGrip
+ ? Rep({
+ object: ancestorGrip,
+ mode: MODE.TINY,
+ onDOMNodeClick: () => openElementInInspector(ancestorGrip),
+ onInspectIconClick: () => openElementInInspector(ancestorGrip),
+ onDOMNodeMouseOver: () => highlightDomElement(ancestorGrip),
+ onDOMNodeMouseOut: () => unHighlightDomElement(),
+ })
+ : null;
+ return div(
+ null,
+ div(
+ {
+ className: "message",
+ },
+ why.message
+ ),
+ div(
+ {
+ className: "mutationNode",
+ },
+ ancestorRep,
+ ancestorGrip
+ ? span(
+ {
+ className: "why-paused-ancestor",
+ },
+ React.createElement(Localized, {
+ id:
+ action === "remove"
+ ? "whypaused-mutation-breakpoint-removed"
+ : "whypaused-mutation-breakpoint-added",
+ }),
+ targetRep
+ )
+ : targetRep
+ )
+ );
+ }
+
+ if (typeof message == "string") {
+ return div(
+ {
+ className: "message",
+ },
+ message
+ );
+ }
+
+ return null;
+ }
+
+ render() {
+ const { endPanelCollapsed, why } = this.props;
+ const { fluentBundles } = this.context;
+ const reason = getPauseReason(why);
+
+ if (!why || !reason || endPanelCollapsed) {
+ return div({
+ className: this.state.hideWhyPaused,
+ });
+ }
+ return (
+ // We're rendering the LocalizationProvider component from here and not in an upper
+ // component because it does set a new context, overriding the context that we set
+ // in the first place in <App>, which breaks some components.
+ // This should be fixed in Bug 1743155.
+ React.createElement(
+ LocalizationProvider,
+ {
+ bundles: fluentBundles || [],
+ },
+ div(
+ {
+ className: "pane why-paused",
+ },
+ div(
+ null,
+ div(
+ {
+ className: "info icon",
+ },
+ React.createElement(AccessibleImage, {
+ className: "info",
+ })
+ ),
+ div(
+ {
+ className: "pause reason",
+ },
+ React.createElement(Localized, {
+ id: reason,
+ }),
+ this.renderMessage(why)
+ )
+ )
+ )
+ )
+ );
+ }
+}
+
+WhyPaused.contextTypes = { fluentBundles: PropTypes.array };
+
+const mapStateToProps = state => ({
+ endPanelCollapsed: getPaneCollapse(state, "end"),
+ why: getWhy(state, getCurrentThread(state)),
+});
+
+export default connect(mapStateToProps, {
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+})(WhyPaused);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css
new file mode 100644
index 0000000000..50b6240337
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css
@@ -0,0 +1,108 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.xhr-breakpoints-pane ._content {
+ overflow-x: auto;
+}
+
+.xhr-input-container {
+ display: flex;
+}
+
+.xhr-input-form {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ width: 100%;
+ /* helps to display a nice outline on focused elements */
+ padding-block: 2px;
+ padding-inline-start: 20px;
+ padding-inline-end: 12px;
+ /* Stop select height from increasing as input height increases */
+ align-items: center;
+}
+
+.xhr-checkbox {
+ margin-inline-start: 0;
+ margin-inline-end: 4px;
+}
+
+.xhr-input-url {
+ border: 1px;
+ flex: 1 1 100px;
+ min-width: min(100%, 100px);
+ height: 24px;
+ background-color: var(--theme-sidebar-background);
+ font-size: inherit;
+ color: var(--theme-body-color);
+}
+
+.xhr-input-url::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
+
+.expressions-list .xhr-input-url {
+ /* Prevent vertical bounce when editing an existing XHR Breakpoint */
+ height: 100%;
+}
+
+.xhr-input-method {
+ flex: 0 1 100px;
+ min-width: min(100%, 100px);
+}
+
+.xhr-container {
+ border-left: 4px solid transparent;
+ width: 100%;
+ color: var(--theme-body-color);
+ padding-inline-start: 16px;
+ padding-inline-end: 6px;
+ display: flex;
+ align-items: center;
+ position: relative;
+ height: var(--expression-item-height);
+}
+
+:root.theme-light .xhr-container:hover {
+ background-color: var(--search-overlays-semitransparent);
+}
+
+:root.theme-dark .xhr-container:hover {
+ background-color: var(--search-overlays-semitransparent);
+}
+
+.xhr-label-method {
+ line-height: 14px;
+ display: inline-block;
+ margin-inline-end: 2px;
+}
+
+.xhr-input-container:not(.focused) .xhr-input-method {
+ display: none;
+}
+
+.xhr-label-url {
+ max-width: calc(100% - var(--breakpoint-expression-right-clear-space));
+ color: var(--theme-comment);
+ display: inline-block;
+ cursor: text;
+ flex-grow: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding: 0px 2px 0px 2px;
+ line-height: 14px;
+}
+
+.xhr-container label {
+ flex-grow: 1;
+ display: flex;
+ align-items: center;
+ max-width: 100%;
+}
+
+.xhr-container__close-btn {
+ display: flex;
+ padding: 2px;
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js
new file mode 100644
index 0000000000..9774255dcd
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js
@@ -0,0 +1,383 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ form,
+ input,
+ li,
+ label,
+ ul,
+ option,
+ select,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import actions from "../../actions/index";
+
+import { CloseButton } from "../shared/Button/index";
+
+import { getXHRBreakpoints, shouldPauseOnAnyXHR } from "../../selectors/index";
+import ExceptionOption from "./Breakpoints/ExceptionOption";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+// At present, the "Pause on any URL" checkbox creates an xhrBreakpoint
+// of "ANY" with no path, so we can remove that before creating the list
+function getExplicitXHRBreakpoints(xhrBreakpoints) {
+ return xhrBreakpoints.filter(bp => bp.path !== "");
+}
+
+const xhrMethods = [
+ "ANY",
+ "GET",
+ "POST",
+ "PUT",
+ "HEAD",
+ "DELETE",
+ "PATCH",
+ "OPTIONS",
+];
+
+class XHRBreakpoints extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ editing: false,
+ inputValue: "",
+ inputMethod: "ANY",
+ focused: false,
+ editIndex: -1,
+ clickedOnFormElement: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ disableXHRBreakpoint: PropTypes.func.isRequired,
+ enableXHRBreakpoint: PropTypes.func.isRequired,
+ onXHRAdded: PropTypes.func.isRequired,
+ removeXHRBreakpoint: PropTypes.func.isRequired,
+ setXHRBreakpoint: PropTypes.func.isRequired,
+ shouldPauseOnAny: PropTypes.bool.isRequired,
+ showInput: PropTypes.bool.isRequired,
+ togglePauseOnAny: PropTypes.func.isRequired,
+ updateXHRBreakpoint: PropTypes.func.isRequired,
+ xhrBreakpoints: PropTypes.array.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ const { showInput } = this.props;
+
+ // Ensures that the input is focused when the "+"
+ // is clicked while the panel is collapsed
+ if (this._input && showInput) {
+ this._input.focus();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const _input = this._input;
+
+ if (!_input) {
+ return;
+ }
+
+ if (!prevState.editing && this.state.editing) {
+ _input.setSelectionRange(0, _input.value.length);
+ _input.focus();
+ } else if (this.props.showInput && !this.state.focused) {
+ _input.focus();
+ }
+ }
+
+ handleNewSubmit = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const setXHRBreakpoint = function () {
+ this.props.setXHRBreakpoint(
+ this.state.inputValue,
+ this.state.inputMethod
+ );
+ this.hideInput();
+ };
+
+ // force update inputMethod in state for mochitest purposes
+ // before setting XHR breakpoint
+ this.setState(
+ { inputMethod: e.target.children[1].value },
+ setXHRBreakpoint
+ );
+ };
+
+ handleExistingSubmit = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const { editIndex, inputValue, inputMethod } = this.state;
+ const { xhrBreakpoints } = this.props;
+ const { path, method } = xhrBreakpoints[editIndex];
+
+ if (path !== inputValue || method != inputMethod) {
+ this.props.updateXHRBreakpoint(editIndex, inputValue, inputMethod);
+ }
+
+ this.hideInput();
+ };
+
+ handleChange = e => {
+ this.setState({ inputValue: e.target.value });
+ };
+
+ handleMethodChange = e => {
+ this.setState({
+ focused: true,
+ editing: true,
+ inputMethod: e.target.value,
+ });
+ };
+
+ hideInput = () => {
+ if (this.state.clickedOnFormElement) {
+ this.setState({
+ focused: true,
+ clickedOnFormElement: false,
+ });
+ } else {
+ this.setState({
+ focused: false,
+ editing: false,
+ editIndex: -1,
+ inputValue: "",
+ inputMethod: "ANY",
+ });
+ this.props.onXHRAdded();
+ }
+ };
+
+ onFocus = () => {
+ this.setState({ focused: true, editing: true });
+ };
+
+ onMouseDown = e => {
+ this.setState({ editing: false, clickedOnFormElement: true });
+ };
+
+ handleTab = e => {
+ if (e.key !== "Tab") {
+ return;
+ }
+
+ if (e.currentTarget.nodeName === "INPUT") {
+ this.setState({
+ clickedOnFormElement: true,
+ editing: false,
+ });
+ } else if (e.currentTarget.nodeName === "SELECT" && !e.shiftKey) {
+ // The user has tabbed off the select and we should
+ // cancel the edit
+ this.hideInput();
+ }
+ };
+
+ editExpression = index => {
+ const { xhrBreakpoints } = this.props;
+ const { path, method } = xhrBreakpoints[index];
+ this.setState({
+ inputValue: path,
+ inputMethod: method,
+ editing: true,
+ editIndex: index,
+ });
+ };
+
+ renderXHRInput(onSubmit) {
+ const { focused, inputValue } = this.state;
+ const placeholder = L10N.getStr("xhrBreakpoints.placeholder");
+ return form(
+ {
+ key: "xhr-input-container",
+ className: classnames("xhr-input-container xhr-input-form", {
+ focused,
+ }),
+ onSubmit: onSubmit,
+ },
+ input({
+ className: "xhr-input-url",
+ type: "text",
+ placeholder: placeholder,
+ onChange: this.handleChange,
+ onBlur: this.hideInput,
+ onFocus: this.onFocus,
+ value: inputValue,
+ onKeyDown: this.handleTab,
+ ref: c => (this._input = c),
+ }),
+ this.renderMethodSelectElement(),
+ input({
+ type: "submit",
+ style: {
+ display: "none",
+ },
+ })
+ );
+ }
+
+ handleCheckbox = index => {
+ const { xhrBreakpoints, enableXHRBreakpoint, disableXHRBreakpoint } =
+ this.props;
+ const breakpoint = xhrBreakpoints[index];
+ if (breakpoint.disabled) {
+ enableXHRBreakpoint(index);
+ } else {
+ disableXHRBreakpoint(index);
+ }
+ };
+
+ renderBreakpoint = breakpoint => {
+ const { path, disabled, method } = breakpoint;
+ const { editIndex } = this.state;
+ const { removeXHRBreakpoint, xhrBreakpoints } = this.props;
+
+ // The "pause on any" checkbox
+ if (!path) {
+ return null;
+ }
+
+ // Finds the xhrbreakpoint so as to not make assumptions about position
+ const index = xhrBreakpoints.findIndex(
+ bp => bp.path === path && bp.method === method
+ );
+
+ if (index === editIndex) {
+ return this.renderXHRInput(this.handleExistingSubmit);
+ }
+ return li(
+ {
+ className: "xhr-container",
+ key: `${path}-${method}`,
+ title: path,
+ onDoubleClick: (items, options) => this.editExpression(index),
+ },
+ label(
+ null,
+ React.createElement("input", {
+ type: "checkbox",
+ className: "xhr-checkbox",
+ checked: !disabled,
+ onChange: () => this.handleCheckbox(index),
+ onClick: ev => ev.stopPropagation(),
+ }),
+ div(
+ {
+ className: "xhr-label-method",
+ },
+ method
+ ),
+ div(
+ {
+ className: "xhr-label-url",
+ },
+ path
+ ),
+ div(
+ {
+ className: "xhr-container__close-btn",
+ },
+ React.createElement(CloseButton, {
+ handleClick: e => removeXHRBreakpoint(index),
+ })
+ )
+ )
+ );
+ };
+
+ renderBreakpoints = explicitXhrBreakpoints => {
+ const { showInput } = this.props;
+ return React.createElement(
+ React.Fragment,
+ null,
+ ul(
+ {
+ className: "pane expressions-list",
+ },
+ explicitXhrBreakpoints.map(this.renderBreakpoint)
+ ),
+ showInput && this.renderXHRInput(this.handleNewSubmit)
+ );
+ };
+
+ renderCheckbox = explicitXhrBreakpoints => {
+ const { shouldPauseOnAny, togglePauseOnAny } = this.props;
+ return div(
+ {
+ className: classnames("breakpoints-options", {
+ empty: explicitXhrBreakpoints.length === 0,
+ }),
+ },
+ React.createElement(ExceptionOption, {
+ className: "breakpoints-exceptions",
+ label: L10N.getStr("pauseOnAnyXHR"),
+ isChecked: shouldPauseOnAny,
+ onChange: () => togglePauseOnAny(),
+ })
+ );
+ };
+ renderMethodOption = method => {
+ return option(
+ {
+ key: method,
+ value: method,
+ // e.stopPropagation() required here since otherwise Firefox triggers 2x
+ // onMouseDown events on <select> upon clicking on an <option>
+ onMouseDown: e => e.stopPropagation(),
+ },
+ method
+ );
+ };
+
+ renderMethodSelectElement = () => {
+ return select(
+ {
+ value: this.state.inputMethod,
+ className: "xhr-input-method",
+ onChange: this.handleMethodChange,
+ onMouseDown: this.onMouseDown,
+ onKeyDown: this.handleTab,
+ },
+ xhrMethods.map(this.renderMethodOption)
+ );
+ };
+
+ render() {
+ const { xhrBreakpoints } = this.props;
+ const explicitXhrBreakpoints = getExplicitXHRBreakpoints(xhrBreakpoints);
+ return React.createElement(
+ React.Fragment,
+ null,
+ this.renderCheckbox(explicitXhrBreakpoints),
+ explicitXhrBreakpoints.length === 0
+ ? this.renderXHRInput(this.handleNewSubmit)
+ : this.renderBreakpoints(explicitXhrBreakpoints)
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ xhrBreakpoints: getXHRBreakpoints(state),
+ shouldPauseOnAny: shouldPauseOnAnyXHR(state),
+});
+
+export default connect(mapStateToProps, {
+ setXHRBreakpoint: actions.setXHRBreakpoint,
+ removeXHRBreakpoint: actions.removeXHRBreakpoint,
+ enableXHRBreakpoint: actions.enableXHRBreakpoint,
+ disableXHRBreakpoint: actions.disableXHRBreakpoint,
+ updateXHRBreakpoint: actions.updateXHRBreakpoint,
+ togglePauseOnAny: actions.togglePauseOnAny,
+})(XHRBreakpoints);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/index.js b/devtools/client/debugger/src/components/SecondaryPanes/index.js
new file mode 100644
index 0000000000..20830afc12
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/index.js
@@ -0,0 +1,548 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+const SplitBox = require("resource://devtools/client/shared/components/splitter/SplitBox.js");
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ input,
+ label,
+ button,
+ a,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import actions from "../../actions/index";
+import {
+ getTopFrame,
+ getExpressions,
+ getPauseCommand,
+ isMapScopesEnabled,
+ getSelectedFrame,
+ getSelectedSource,
+ getThreads,
+ getCurrentThread,
+ getPauseReason,
+ getShouldBreakpointsPaneOpenOnPause,
+ getSkipPausing,
+ shouldLogEventBreakpoints,
+} from "../../selectors/index";
+
+import AccessibleImage from "../shared/AccessibleImage";
+import { prefs } from "../../utils/prefs";
+
+import Breakpoints from "./Breakpoints/index";
+import Expressions from "./Expressions";
+import Frames from "./Frames/index";
+import Threads from "./Threads";
+import Accordion from "../shared/Accordion";
+import CommandBar from "./CommandBar";
+import XHRBreakpoints from "./XHRBreakpoints";
+import EventListeners from "./EventListeners";
+import DOMMutationBreakpoints from "./DOMMutationBreakpoints";
+import WhyPaused from "./WhyPaused";
+
+import Scopes from "./Scopes";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+function debugBtn(onClick, type, className, tooltip) {
+ return button(
+ {
+ onClick: onClick,
+ className: `${type} ${className}`,
+ key: type,
+ title: tooltip,
+ },
+ React.createElement(AccessibleImage, {
+ className: type,
+ title: tooltip,
+ "aria-label": tooltip,
+ })
+ );
+}
+
+const mdnLink =
+ "https://firefox-source-docs.mozilla.org/devtools-user/debugger/using_the_debugger_map_scopes_feature/";
+
+class SecondaryPanes extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ showExpressionsInput: false,
+ showXHRInput: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ evaluateExpressionsForCurrentContext: PropTypes.func.isRequired,
+ expressions: PropTypes.array.isRequired,
+ hasFrames: PropTypes.bool.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ logEventBreakpoints: PropTypes.bool.isRequired,
+ mapScopesEnabled: PropTypes.bool.isRequired,
+ pauseReason: PropTypes.string.isRequired,
+ shouldBreakpointsPaneOpenOnPause: PropTypes.bool.isRequired,
+ thread: PropTypes.string.isRequired,
+ renderWhyPauseDelay: PropTypes.number.isRequired,
+ selectedFrame: PropTypes.object,
+ skipPausing: PropTypes.bool.isRequired,
+ source: PropTypes.object,
+ toggleEventLogging: PropTypes.func.isRequired,
+ resetBreakpointsPaneState: PropTypes.func.isRequired,
+ toggleMapScopes: PropTypes.func.isRequired,
+ threads: PropTypes.array.isRequired,
+ removeAllBreakpoints: PropTypes.func.isRequired,
+ removeAllXHRBreakpoints: PropTypes.func.isRequired,
+ };
+ }
+
+ onExpressionAdded = () => {
+ this.setState({ showExpressionsInput: false });
+ };
+
+ onXHRAdded = () => {
+ this.setState({ showXHRInput: false });
+ };
+
+ watchExpressionHeaderButtons() {
+ const { expressions } = this.props;
+ const buttons = [];
+
+ if (expressions.length) {
+ buttons.push(
+ debugBtn(
+ () => {
+ this.props.evaluateExpressionsForCurrentContext();
+ },
+ "refresh",
+ "active",
+ L10N.getStr("watchExpressions.refreshButton")
+ )
+ );
+ }
+ buttons.push(
+ debugBtn(
+ () => {
+ if (!prefs.expressionsVisible) {
+ this.onWatchExpressionPaneToggle(true);
+ }
+ this.setState({ showExpressionsInput: true });
+ },
+ "plus",
+ "active",
+ L10N.getStr("expressions.placeholder")
+ )
+ );
+ return buttons;
+ }
+
+ xhrBreakpointsHeaderButtons() {
+ return [
+ debugBtn(
+ () => {
+ if (!prefs.xhrBreakpointsVisible) {
+ this.onXHRPaneToggle(true);
+ }
+ this.setState({ showXHRInput: true });
+ },
+ "plus",
+ "active",
+ L10N.getStr("xhrBreakpoints.label")
+ ),
+
+ debugBtn(
+ () => {
+ this.props.removeAllXHRBreakpoints();
+ },
+ "removeAll",
+ "active",
+ L10N.getStr("xhrBreakpoints.removeAll.tooltip")
+ ),
+ ];
+ }
+
+ breakpointsHeaderButtons() {
+ return [
+ debugBtn(
+ () => {
+ this.props.removeAllBreakpoints();
+ },
+ "removeAll",
+ "active",
+ L10N.getStr("breakpointMenuItem.deleteAll")
+ ),
+ ];
+ }
+
+ getScopeItem() {
+ return {
+ header: L10N.getStr("scopes.header"),
+ className: "scopes-pane",
+ component: React.createElement(Scopes, null),
+ opened: prefs.scopesVisible,
+ buttons: this.getScopesButtons(),
+ onToggle: opened => {
+ prefs.scopesVisible = opened;
+ },
+ };
+ }
+
+ getScopesButtons() {
+ const { selectedFrame, mapScopesEnabled, source } = this.props;
+
+ if (!selectedFrame || !source?.isOriginal || source?.isPrettyPrinted) {
+ return null;
+ }
+
+ return [
+ div(
+ {
+ key: "scopes-buttons",
+ },
+ label(
+ {
+ className: "map-scopes-header",
+ title: L10N.getStr("scopes.showOriginalScopesTooltip"),
+ onClick: e => e.stopPropagation(),
+ },
+ input({
+ type: "checkbox",
+ checked: mapScopesEnabled ? "checked" : "",
+ onChange: e => this.props.toggleMapScopes(),
+ }),
+ L10N.getStr("scopes.showOriginalScopes")
+ ),
+ a(
+ {
+ className: "mdn",
+ target: "_blank",
+ href: mdnLink,
+ onClick: e => e.stopPropagation(),
+ title: L10N.getStr("scopes.showOriginalScopesHelpTooltip"),
+ },
+ React.createElement(AccessibleImage, {
+ className: "shortcuts",
+ })
+ )
+ ),
+ ];
+ }
+
+ getEventButtons() {
+ const { logEventBreakpoints } = this.props;
+ return [
+ div(
+ {
+ key: "events-buttons",
+ },
+ label(
+ {
+ className: "events-header",
+ title: L10N.getStr("eventlisteners.log.label"),
+ },
+ input({
+ type: "checkbox",
+ checked: logEventBreakpoints ? "checked" : "",
+ onChange: e => this.props.toggleEventLogging(),
+ }),
+ L10N.getStr("eventlisteners.log")
+ )
+ ),
+ ];
+ }
+
+ onWatchExpressionPaneToggle(opened) {
+ prefs.expressionsVisible = opened;
+ }
+
+ getWatchItem() {
+ return {
+ header: L10N.getStr("watchExpressions.header"),
+ id: "watch-expressions-pane",
+ className: "watch-expressions-pane",
+ buttons: this.watchExpressionHeaderButtons(),
+ component: React.createElement(Expressions, {
+ showInput: this.state.showExpressionsInput,
+ onExpressionAdded: this.onExpressionAdded,
+ }),
+ opened: prefs.expressionsVisible,
+ onToggle: this.onWatchExpressionPaneToggle,
+ };
+ }
+
+ onXHRPaneToggle(opened) {
+ prefs.xhrBreakpointsVisible = opened;
+ }
+
+ getXHRItem() {
+ const { pauseReason } = this.props;
+
+ return {
+ header: L10N.getStr("xhrBreakpoints.header"),
+ id: "xhr-breakpoints-pane",
+ className: "xhr-breakpoints-pane",
+ buttons: this.xhrBreakpointsHeaderButtons(),
+ component: React.createElement(XHRBreakpoints, {
+ showInput: this.state.showXHRInput,
+ onXHRAdded: this.onXHRAdded,
+ }),
+ opened: prefs.xhrBreakpointsVisible || pauseReason === "XHR",
+ onToggle: this.onXHRPaneToggle,
+ };
+ }
+
+ getCallStackItem() {
+ return {
+ header: L10N.getStr("callStack.header"),
+ id: "call-stack-pane",
+ className: "call-stack-pane",
+ component: React.createElement(Frames, {
+ panel: "debugger",
+ }),
+ opened: prefs.callStackVisible,
+ onToggle: opened => {
+ prefs.callStackVisible = opened;
+ },
+ };
+ }
+
+ getThreadsItem() {
+ return {
+ header: L10N.getStr("threadsHeader"),
+ id: "threads-pane",
+ className: "threads-pane",
+ component: React.createElement(Threads, null),
+ opened: prefs.threadsVisible,
+ onToggle: opened => {
+ prefs.threadsVisible = opened;
+ },
+ };
+ }
+
+ getBreakpointsItem() {
+ const { pauseReason, shouldBreakpointsPaneOpenOnPause, thread } =
+ this.props;
+
+ return {
+ header: L10N.getStr("breakpoints.header"),
+ id: "breakpoints-pane",
+ className: "breakpoints-pane",
+ buttons: this.breakpointsHeaderButtons(),
+ component: React.createElement(Breakpoints),
+ opened:
+ prefs.breakpointsVisible ||
+ (pauseReason === "breakpoint" && shouldBreakpointsPaneOpenOnPause),
+ onToggle: opened => {
+ prefs.breakpointsVisible = opened;
+ // one-shot flag used to force open the Breakpoints Pane only
+ // when hitting a breakpoint, but not when selecting frames etc...
+ if (shouldBreakpointsPaneOpenOnPause) {
+ this.props.resetBreakpointsPaneState(thread);
+ }
+ },
+ };
+ }
+
+ getEventListenersItem() {
+ const { pauseReason } = this.props;
+
+ return {
+ header: L10N.getStr("eventListenersHeader1"),
+ id: "event-listeners-pane",
+ className: "event-listeners-pane",
+ buttons: this.getEventButtons(),
+ component: React.createElement(EventListeners, null),
+ opened: prefs.eventListenersVisible || pauseReason === "eventBreakpoint",
+ onToggle: opened => {
+ prefs.eventListenersVisible = opened;
+ },
+ };
+ }
+
+ getDOMMutationsItem() {
+ const { pauseReason } = this.props;
+
+ return {
+ header: L10N.getStr("domMutationHeader"),
+ id: "dom-mutations-pane",
+ className: "dom-mutations-pane",
+ buttons: [],
+ component: React.createElement(DOMMutationBreakpoints, null),
+ opened:
+ prefs.domMutationBreakpointsVisible ||
+ pauseReason === "mutationBreakpoint",
+ onToggle: opened => {
+ prefs.domMutationBreakpointsVisible = opened;
+ },
+ };
+ }
+
+ getStartItems() {
+ const items = [];
+ const { horizontal, hasFrames } = this.props;
+
+ if (horizontal) {
+ if (this.props.threads.length) {
+ items.push(this.getThreadsItem());
+ }
+
+ items.push(this.getWatchItem());
+ }
+
+ items.push(this.getBreakpointsItem());
+
+ if (hasFrames) {
+ items.push(this.getCallStackItem());
+ if (horizontal) {
+ items.push(this.getScopeItem());
+ }
+ }
+
+ items.push(this.getXHRItem());
+
+ items.push(this.getEventListenersItem());
+
+ items.push(this.getDOMMutationsItem());
+
+ return items;
+ }
+
+ getEndItems() {
+ if (this.props.horizontal) {
+ return [];
+ }
+
+ const items = [];
+ if (this.props.threads.length) {
+ items.push(this.getThreadsItem());
+ }
+
+ items.push(this.getWatchItem());
+
+ if (this.props.hasFrames) {
+ items.push(this.getScopeItem());
+ }
+
+ return items;
+ }
+
+ getItems() {
+ return [...this.getStartItems(), ...this.getEndItems()];
+ }
+
+ renderHorizontalLayout() {
+ const { renderWhyPauseDelay } = this.props;
+ return div(
+ null,
+ React.createElement(WhyPaused, {
+ delay: renderWhyPauseDelay,
+ }),
+ React.createElement(Accordion, {
+ items: this.getItems(),
+ })
+ );
+ }
+
+ renderVerticalLayout() {
+ return React.createElement(SplitBox, {
+ initialSize: "300px",
+ minSize: 10,
+ maxSize: "50%",
+ splitterSize: 1,
+ startPanel: div(
+ {
+ style: {
+ width: "inherit",
+ },
+ },
+ React.createElement(WhyPaused, {
+ delay: this.props.renderWhyPauseDelay,
+ }),
+ React.createElement(Accordion, {
+ items: this.getStartItems(),
+ })
+ ),
+ endPanel: React.createElement(Accordion, {
+ items: this.getEndItems(),
+ }),
+ });
+ }
+
+ render() {
+ const { skipPausing } = this.props;
+ return div(
+ {
+ className: "secondary-panes-wrapper",
+ },
+ React.createElement(CommandBar, {
+ horizontal: this.props.horizontal,
+ }),
+ React.createElement(
+ "div",
+ {
+ className: classnames(
+ "secondary-panes",
+ skipPausing && "skip-pausing"
+ ),
+ },
+ this.props.horizontal
+ ? this.renderHorizontalLayout()
+ : this.renderVerticalLayout()
+ )
+ );
+ }
+}
+
+// Checks if user is in debugging mode and adds a delay preventing
+// excessive vertical 'jumpiness'
+function getRenderWhyPauseDelay(state, thread) {
+ const inPauseCommand = !!getPauseCommand(state, thread);
+
+ if (!inPauseCommand) {
+ return 100;
+ }
+
+ return 0;
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ const selectedFrame = getSelectedFrame(state, thread);
+ const pauseReason = getPauseReason(state, thread);
+ const shouldBreakpointsPaneOpenOnPause = getShouldBreakpointsPaneOpenOnPause(
+ state,
+ thread
+ );
+
+ return {
+ expressions: getExpressions(state),
+ hasFrames: !!getTopFrame(state, thread),
+ renderWhyPauseDelay: getRenderWhyPauseDelay(state, thread),
+ selectedFrame,
+ mapScopesEnabled: isMapScopesEnabled(state),
+ threads: getThreads(state),
+ skipPausing: getSkipPausing(state),
+ logEventBreakpoints: shouldLogEventBreakpoints(state),
+ source: getSelectedSource(state),
+ pauseReason: pauseReason?.type ?? "",
+ shouldBreakpointsPaneOpenOnPause,
+ thread,
+ };
+};
+
+export default connect(mapStateToProps, {
+ evaluateExpressionsForCurrentContext:
+ actions.evaluateExpressionsForCurrentContext,
+ toggleMapScopes: actions.toggleMapScopes,
+ breakOnNext: actions.breakOnNext,
+ toggleEventLogging: actions.toggleEventLogging,
+ removeAllBreakpoints: actions.removeAllBreakpoints,
+ removeAllXHRBreakpoints: actions.removeAllXHRBreakpoints,
+ resetBreakpointsPaneState: actions.resetBreakpointsPaneState,
+})(SecondaryPanes);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/moz.build b/devtools/client/debugger/src/components/SecondaryPanes/moz.build
new file mode 100644
index 0000000000..33cfa2e316
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/moz.build
@@ -0,0 +1,22 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "Breakpoints",
+ "Frames",
+]
+
+CompiledModules(
+ "CommandBar.js",
+ "DOMMutationBreakpoints.js",
+ "EventListeners.js",
+ "Expressions.js",
+ "index.js",
+ "Scopes.js",
+ "Thread.js",
+ "Threads.js",
+ "WhyPaused.js",
+ "XHRBreakpoints.js",
+)
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js
new file mode 100644
index 0000000000..b82997eb9a
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import CommandBar from "../CommandBar";
+
+describe("CommandBar", () => {
+ it("f8 key command calls props.breakOnNext when not in paused state", () => {
+ const props = {
+ breakOnNext: jest.fn(),
+ resume: jest.fn(),
+ isPaused: false,
+ };
+ const mockEvent = {
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ };
+
+ // The "on" spy will see all the keyboard listeners being registered by
+ // the shortcuts.on function
+ const context = { shortcuts: { on: jest.fn() } };
+
+ shallow(React.createElement(CommandBar.WrappedComponent, props), {
+ context,
+ });
+
+ // get the keyboard event listeners recorded from the "on" spy.
+ // this will be an array where each item is itself a two item array
+ // containing the key code and the corresponding handler for that key code
+ const keyEventHandlers = context.shortcuts.on.mock.calls;
+
+ // simulate pressing the F8 key by calling the F8 handlers
+ keyEventHandlers
+ .filter(i => i[0] === "F8")
+ .forEach(([_, handler]) => {
+ handler(mockEvent);
+ });
+
+ expect(props.breakOnNext).toHaveBeenCalled();
+ expect(props.resume).not.toHaveBeenCalled();
+ });
+
+ it("f8 key command calls props.resume when in paused state", () => {
+ const props = {
+ breakOnNext: jest.fn(),
+ resume: jest.fn(),
+ isPaused: true,
+ };
+ const mockEvent = {
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ };
+
+ // The "on" spy will see all the keyboard listeners being registered by
+ // the shortcuts.on function
+ const context = { shortcuts: { on: jest.fn() } };
+
+ shallow(React.createElement(CommandBar.WrappedComponent, props), {
+ context,
+ });
+
+ // get the keyboard event listeners recorded from the "on" spy.
+ // this will be an array where each item is itself a two item array
+ // containing the key code and the corresponding handler for that key code
+ const keyEventHandlers = context.shortcuts.on.mock.calls;
+
+ // simulate pressing the F8 key by calling the F8 handlers
+ keyEventHandlers
+ .filter(i => i[0] === "F8")
+ .forEach(([_, handler]) => {
+ handler(mockEvent);
+ });
+ expect(props.resume).toHaveBeenCalled();
+ expect(props.breakOnNext).not.toHaveBeenCalled();
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js
new file mode 100644
index 0000000000..58d7776e5a
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js
@@ -0,0 +1,136 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import EventListeners from "../EventListeners";
+
+function getCategories() {
+ return [
+ {
+ name: "Category 1",
+ events: [
+ { name: "Subcategory 1", id: "category1.subcategory1" },
+ { name: "Subcategory 2", id: "category1.subcategory2" },
+ ],
+ },
+ {
+ name: "Category 2",
+ events: [
+ { name: "Subcategory 3", id: "category2.subcategory1" },
+ { name: "Subcategory 4", id: "category2.subcategory2" },
+ ],
+ },
+ ];
+}
+
+function generateDefaults(overrides = {}) {
+ const defaults = {
+ activeEventListeners: [],
+ expandedCategories: [],
+ categories: [],
+ };
+
+ return { ...defaults, ...overrides };
+}
+
+function render(overrides = {}) {
+ const props = generateDefaults(overrides);
+ const component = shallow(
+ React.createElement(EventListeners.WrappedComponent, props)
+ );
+ return { component, props };
+}
+
+describe("EventListeners", () => {
+ it("should render", async () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should render categories appropriately", async () => {
+ const props = {
+ ...generateDefaults(),
+ categories: getCategories(),
+ };
+ const { component } = render(props);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should render expanded categories appropriately", async () => {
+ const props = {
+ ...generateDefaults(),
+ categories: getCategories(),
+ expandedCategories: ["Category 2"],
+ };
+ const { component } = render(props);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should render checked subcategories appropriately", async () => {
+ const props = {
+ ...generateDefaults(),
+ categories: getCategories(),
+ activeEventListeners: ["category1.subcategory2"],
+ expandedCategories: ["Category 1"],
+ };
+ const { component } = render(props);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should filter the event listeners based on the event name", async () => {
+ const props = {
+ ...generateDefaults(),
+ categories: getCategories(),
+ };
+ const { component } = render(props);
+ component.find(".event-search-input").simulate("focus");
+
+ const searchInput = component.find(".event-search-input");
+ // Simulate a search query of "Subcategory 3" to display just one event which
+ // will be the Subcategory 3 event
+ searchInput.simulate("change", {
+ currentTarget: { value: "Subcategory 3" },
+ });
+
+ const displayedEvents = component.find(".event-listener-event");
+ expect(displayedEvents).toHaveLength(1);
+ });
+
+ it("should filter the event listeners based on the category name", async () => {
+ const props = {
+ ...generateDefaults(),
+ categories: getCategories(),
+ };
+ const { component } = render(props);
+ component.find(".event-search-input").simulate("focus");
+
+ const searchInput = component.find(".event-search-input");
+ // Simulate a search query of "Category 1" to display two events which will be
+ // the Subcategory 1 event and the Subcategory 2 event
+ searchInput.simulate("change", { currentTarget: { value: "Category 1" } });
+
+ const displayedEvents = component.find(".event-listener-event");
+ expect(displayedEvents).toHaveLength(2);
+ });
+
+ it("should be case insensitive when filtering events and categories", async () => {
+ const props = {
+ ...generateDefaults(),
+ categories: getCategories(),
+ };
+ const { component } = render(props);
+ component.find(".event-search-input").simulate("focus");
+
+ const searchInput = component.find(".event-search-input");
+ // Simulate a search query of "Subcategory 3" to display just one event which
+ // will be the Subcategory 3 event
+ searchInput.simulate("change", {
+ currentTarget: { value: "sUbCaTeGoRy 3" },
+ });
+
+ const displayedEvents = component.find(".event-listener-event");
+ expect(displayedEvents).toHaveLength(1);
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js
new file mode 100644
index 0000000000..685c636f05
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import Expressions from "../Expressions";
+
+function generateDefaults(overrides) {
+ return {
+ evaluateExpressions: async () => {},
+ expressions: [
+ {
+ input: "expression1",
+ value: {
+ result: {
+ value: "foo",
+ class: "",
+ },
+ },
+ },
+ {
+ input: "expression2",
+ value: {
+ result: {
+ value: "bar",
+ class: "",
+ },
+ },
+ },
+ ],
+ ...overrides,
+ };
+}
+
+function render(overrides = {}) {
+ const props = generateDefaults(overrides);
+ const component = shallow(
+ React.createElement(Expressions.WrappedComponent, props)
+ );
+ return { component, props };
+}
+
+describe("Expressions", () => {
+ it("should render", async () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should always have unique keys", async () => {
+ const overrides = {
+ expressions: [
+ {
+ input: "expression1",
+ value: {
+ result: {
+ value: undefined,
+ class: "",
+ },
+ },
+ },
+ {
+ input: "expression2",
+ value: {
+ result: {
+ value: undefined,
+ class: "",
+ },
+ },
+ },
+ ],
+ };
+
+ const { component } = render(overrides);
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js
new file mode 100644
index 0000000000..532c95e4ad
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js
@@ -0,0 +1,341 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { mount } from "enzyme";
+import XHRBreakpoints from "../XHRBreakpoints";
+
+const xhrMethods = [
+ "ANY",
+ "GET",
+ "POST",
+ "PUT",
+ "HEAD",
+ "DELETE",
+ "PATCH",
+ "OPTIONS",
+];
+
+// default state includes xhrBreakpoints[0] which is the checkbox that
+// enables breaking on any url during an XMLHTTPRequest
+function generateDefaultState(propsOverride) {
+ return {
+ xhrBreakpoints: [
+ {
+ path: "",
+ method: "ANY",
+ disabled: false,
+ loading: false,
+ text: 'URL contains ""',
+ },
+ ],
+ enableXHRBreakpoint: () => {},
+ disableXHRBreakpoint: () => {},
+ updateXHRBreakpoint: () => {},
+ removeXHRBreakpoint: () => {},
+ setXHRBreakpoint: () => {},
+ togglePauseOnAny: () => {},
+ showInput: false,
+ shouldPauseOnAny: false,
+ onXHRAdded: () => {},
+ ...propsOverride,
+ };
+}
+
+function renderXHRBreakpointsComponent(propsOverride) {
+ const props = generateDefaultState(propsOverride);
+ const xhrBreakpointsComponent = mount(
+ React.createElement(XHRBreakpoints.WrappedComponent, props)
+ );
+ return xhrBreakpointsComponent;
+}
+
+describe("XHR Breakpoints", function () {
+ it("should render with 0 expressions passed from props", function () {
+ const xhrBreakpointsComponent = renderXHRBreakpointsComponent();
+ expect(xhrBreakpointsComponent).toMatchSnapshot();
+ });
+
+ it("should render with 8 expressions passed from props", function () {
+ const allXHRBreakpointMethods = {
+ xhrBreakpoints: [
+ {
+ path: "",
+ method: "ANY",
+ disabled: false,
+ loading: false,
+ text: 'URL contains ""',
+ },
+ {
+ path: "this is any",
+ method: "ANY",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is any"',
+ },
+ {
+ path: "this is get",
+ method: "GET",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is get"',
+ },
+ {
+ path: "this is post",
+ method: "POST",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is post"',
+ },
+ {
+ path: "this is put",
+ method: "PUT",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is put"',
+ },
+ {
+ path: "this is head",
+ method: "HEAD",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is head"',
+ },
+ {
+ path: "this is delete",
+ method: "DELETE",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is delete"',
+ },
+ {
+ path: "this is patch",
+ method: "PATCH",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is patch"',
+ },
+ {
+ path: "this is options",
+ method: "OPTIONS",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is options"',
+ },
+ ],
+ };
+
+ const xhrBreakpointsComponent = renderXHRBreakpointsComponent(
+ allXHRBreakpointMethods
+ );
+ expect(xhrBreakpointsComponent).toMatchSnapshot();
+ });
+
+ it("should display xhr-input-method on click", function () {
+ const xhrBreakpointsComponent = renderXHRBreakpointsComponent();
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus");
+
+ const xhrInputContainer = xhrBreakpointsComponent.find(
+ ".xhr-input-container"
+ );
+ expect(xhrInputContainer.hasClass("focused")).toBeTruthy();
+ });
+
+ it("should have focused and editing default to false", function () {
+ const xhrBreakpointsComponent = renderXHRBreakpointsComponent();
+ expect(xhrBreakpointsComponent.state("focused")).toBe(false);
+ expect(xhrBreakpointsComponent.state("editing")).toBe(false);
+ });
+
+ it("should have state {..focused: true, editing: true} on focus", function () {
+ const xhrBreakpointsComponent = renderXHRBreakpointsComponent();
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus");
+ expect(xhrBreakpointsComponent.state("focused")).toBe(true);
+ expect(xhrBreakpointsComponent.state("editing")).toBe(true);
+ });
+
+ // shifting focus from .xhr-input to any other element apart from
+ // .xhr-input-method should unrender .xhr-input-method
+ it("shifting focus should unrender XHR methods", function () {
+ const propsOverride = {
+ onXHRAdded: jest.fn,
+ togglePauseOnAny: jest.fn,
+ };
+ const xhrBreakpointsComponent =
+ renderXHRBreakpointsComponent(propsOverride);
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus");
+ let xhrInputContainer = xhrBreakpointsComponent.find(
+ ".xhr-input-container"
+ );
+ expect(xhrInputContainer.hasClass("focused")).toBeTruthy();
+
+ xhrBreakpointsComponent.find(".breakpoints-options").simulate("mousedown");
+ expect(xhrBreakpointsComponent.state("focused")).toBe(true);
+ expect(xhrBreakpointsComponent.state("editing")).toBe(true);
+ expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false);
+
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("blur");
+ expect(xhrBreakpointsComponent.state("focused")).toBe(false);
+ expect(xhrBreakpointsComponent.state("editing")).toBe(false);
+ expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false);
+
+ xhrBreakpointsComponent.find(".breakpoints-options").simulate("click");
+
+ xhrInputContainer = xhrBreakpointsComponent.find(".xhr-input-container");
+ expect(xhrInputContainer.hasClass("focused")).not.toBeTruthy();
+ });
+
+ // shifting focus from .xhr-input to .xhr-input-method
+ // should not unrender .xhr-input-method
+ it("shifting focus to XHR methods should not unrender", function () {
+ const xhrBreakpointsComponent = renderXHRBreakpointsComponent();
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus");
+
+ xhrBreakpointsComponent.find(".xhr-input-method").simulate("mousedown");
+ expect(xhrBreakpointsComponent.state("focused")).toBe(true);
+ expect(xhrBreakpointsComponent.state("editing")).toBe(false);
+ expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(true);
+
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("blur");
+ expect(xhrBreakpointsComponent.state("focused")).toBe(true);
+ expect(xhrBreakpointsComponent.state("editing")).toBe(false);
+ expect(xhrBreakpointsComponent.state("clickedOnFormElement")).toBe(false);
+
+ xhrBreakpointsComponent.find(".xhr-input-method").simulate("click");
+ const xhrInputContainer = xhrBreakpointsComponent.find(
+ ".xhr-input-container"
+ );
+ expect(xhrInputContainer.hasClass("focused")).toBeTruthy();
+ });
+
+ it("should have all 8 methods available as options", function () {
+ const xhrBreakpointsComponent = renderXHRBreakpointsComponent();
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus");
+
+ const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method");
+ expect(xhrInputMethod.children()).toHaveLength(8);
+
+ const actualXHRMethods = [];
+ const expectedXHRMethods = xhrMethods;
+
+ // fill the actualXHRMethods array with actual methods displayed in DOM
+ for (let i = 0; i < xhrInputMethod.children().length; i++) {
+ actualXHRMethods.push(xhrInputMethod.childAt(i).key());
+ }
+
+ // check each expected XHR Method to see if they match the actual methods
+ expectedXHRMethods.forEach((expectedMethod, i) => {
+ function compareMethods(actualMethod) {
+ return expectedMethod === actualMethod;
+ }
+ expect(actualXHRMethods.find(compareMethods)).toBeTruthy();
+ });
+ });
+
+ it("should return focus to input box after selecting a method", function () {
+ const xhrBreakpointsComponent = renderXHRBreakpointsComponent();
+
+ // focus starts off at .xhr-input
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus");
+
+ // click on method options and select GET
+ const methodEvent = { target: { value: "GET" } };
+ xhrBreakpointsComponent.find(".xhr-input-method").simulate("mousedown");
+ expect(xhrBreakpointsComponent.state("inputMethod")).toBe("ANY");
+ expect(xhrBreakpointsComponent.state("editing")).toBe(false);
+ xhrBreakpointsComponent
+ .find(".xhr-input-method")
+ .simulate("change", methodEvent);
+
+ // if state.editing changes from false to true, infer that
+ // this._input.focus() is called, which shifts focus back to input box
+ expect(xhrBreakpointsComponent.state("inputMethod")).toBe("GET");
+ expect(xhrBreakpointsComponent.state("editing")).toBe(true);
+ });
+
+ it("should submit the URL and method when adding a breakpoint", function () {
+ const setXHRBreakpointCallback = jest.fn();
+ const propsOverride = {
+ setXHRBreakpoint: setXHRBreakpointCallback,
+ onXHRAdded: jest.fn(),
+ };
+ const mockEvent = {
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ };
+ const availableXHRMethods = xhrMethods;
+ expect(!!availableXHRMethods.length).toBeTruthy();
+
+ // check each of the available methods to see whether
+ // adding them as a method to a new breakpoint works as expected
+ availableXHRMethods.forEach(function (method) {
+ const xhrBreakpointsComponent =
+ renderXHRBreakpointsComponent(propsOverride);
+ xhrBreakpointsComponent.find(".xhr-input-url").simulate("focus");
+ const urlValue = `${method.toLowerCase()}URLValue`;
+
+ // simulate DOM event adding urlValue to .xhr-input
+ const xhrInput = xhrBreakpointsComponent.find(".xhr-input-url");
+ xhrInput.simulate("change", { target: { value: urlValue } });
+
+ // simulate DOM event adding the input method to .xhr-input-method
+ const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method");
+ xhrInputMethod.simulate("change", { target: { value: method } });
+
+ xhrBreakpointsComponent.find("form").simulate("submit", mockEvent);
+ expect(setXHRBreakpointCallback).toHaveBeenCalledWith(urlValue, method);
+ });
+ });
+
+ it("should submit the URL and method when editing a breakpoint", function () {
+ const setXHRBreakpointCallback = jest.fn();
+ const mockEvent = {
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ };
+ const propsOverride = {
+ updateXHRBreakpoint: setXHRBreakpointCallback,
+ onXHRAdded: jest.fn(),
+ xhrBreakpoints: [
+ {
+ path: "",
+ method: "ANY",
+ disabled: false,
+ loading: false,
+ text: 'URL contains ""',
+ },
+ {
+ path: "this is GET",
+ method: "GET",
+ disabled: false,
+ loading: false,
+ text: 'URL contains "this is get"',
+ },
+ ],
+ };
+ const xhrBreakpointsComponent =
+ renderXHRBreakpointsComponent(propsOverride);
+
+ // load xhrBreakpoints pane with one existing xhrBreakpoint
+ const existingXHRbreakpoint =
+ xhrBreakpointsComponent.find(".xhr-container");
+ expect(existingXHRbreakpoint).toHaveLength(1);
+
+ // double click on existing breakpoint
+ existingXHRbreakpoint.simulate("doubleclick");
+ const xhrInput = xhrBreakpointsComponent.find(".xhr-input-url");
+ xhrInput.simulate("focus");
+
+ // change inputs and submit form
+ const xhrInputMethod = xhrBreakpointsComponent.find(".xhr-input-method");
+ xhrInput.simulate("change", { target: { value: "POSTURLValue" } });
+ xhrInputMethod.simulate("change", { target: { value: "POST" } });
+ xhrBreakpointsComponent.find("form").simulate("submit", mockEvent);
+ expect(setXHRBreakpointCallback).toHaveBeenCalledWith(
+ 1,
+ "POSTURLValue",
+ "POST"
+ );
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap
new file mode 100644
index 0000000000..cc2ddf09f6
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/EventListeners.spec.js.snap
@@ -0,0 +1,408 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EventListeners should render 1`] = `
+<div
+ className="event-listeners"
+>
+ <div
+ className="event-search-container"
+ >
+ <form
+ className="event-search-form"
+ onSubmit={[Function]}
+ >
+ <input
+ className="event-search-input"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Filter by event type"
+ value=""
+ />
+ </form>
+ </div>
+ <div
+ className="event-listeners-content"
+ >
+ <ul
+ className="event-listeners-list"
+ />
+ </div>
+</div>
+`;
+
+exports[`EventListeners should render categories appropriately 1`] = `
+<div
+ className="event-listeners"
+>
+ <div
+ className="event-search-container"
+ >
+ <form
+ className="event-search-form"
+ onSubmit={[Function]}
+ >
+ <input
+ className="event-search-input"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Filter by event type"
+ value=""
+ />
+ </form>
+ </div>
+ <div
+ className="event-listeners-content"
+ >
+ <ul
+ className="event-listeners-list"
+ >
+ <li
+ className="event-listener-group"
+ key="0"
+ >
+ <div
+ className="event-listener-header"
+ >
+ <button
+ className="event-listener-expand"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="arrow"
+ />
+ </button>
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="Category 1"
+ />
+ <span
+ className="event-listener-category"
+ >
+ Category 1
+ </span>
+ </label>
+ </div>
+ </li>
+ <li
+ className="event-listener-group"
+ key="1"
+ >
+ <div
+ className="event-listener-header"
+ >
+ <button
+ className="event-listener-expand"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="arrow"
+ />
+ </button>
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="Category 2"
+ />
+ <span
+ className="event-listener-category"
+ >
+ Category 2
+ </span>
+ </label>
+ </div>
+ </li>
+ </ul>
+ </div>
+</div>
+`;
+
+exports[`EventListeners should render checked subcategories appropriately 1`] = `
+<div
+ className="event-listeners"
+>
+ <div
+ className="event-search-container"
+ >
+ <form
+ className="event-search-form"
+ onSubmit={[Function]}
+ >
+ <input
+ className="event-search-input"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Filter by event type"
+ value=""
+ />
+ </form>
+ </div>
+ <div
+ className="event-listeners-content"
+ >
+ <ul
+ className="event-listeners-list"
+ >
+ <li
+ className="event-listener-group"
+ key="0"
+ >
+ <div
+ className="event-listener-header"
+ >
+ <button
+ className="event-listener-expand"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="arrow expanded"
+ />
+ </button>
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="Category 1"
+ />
+ <span
+ className="event-listener-category"
+ >
+ Category 1
+ </span>
+ </label>
+ </div>
+ <ul>
+ <li
+ className="event-listener-event"
+ key="category1.subcategory1"
+ >
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="category1.subcategory1"
+ />
+ <span
+ className="event-listener-name"
+ >
+ Subcategory 1
+ </span>
+ </label>
+ </li>
+ <li
+ className="event-listener-event"
+ key="category1.subcategory2"
+ >
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={true}
+ onChange={[Function]}
+ type="checkbox"
+ value="category1.subcategory2"
+ />
+ <span
+ className="event-listener-name"
+ >
+ Subcategory 2
+ </span>
+ </label>
+ </li>
+ </ul>
+ </li>
+ <li
+ className="event-listener-group"
+ key="1"
+ >
+ <div
+ className="event-listener-header"
+ >
+ <button
+ className="event-listener-expand"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="arrow"
+ />
+ </button>
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="Category 2"
+ />
+ <span
+ className="event-listener-category"
+ >
+ Category 2
+ </span>
+ </label>
+ </div>
+ </li>
+ </ul>
+ </div>
+</div>
+`;
+
+exports[`EventListeners should render expanded categories appropriately 1`] = `
+<div
+ className="event-listeners"
+>
+ <div
+ className="event-search-container"
+ >
+ <form
+ className="event-search-form"
+ onSubmit={[Function]}
+ >
+ <input
+ className="event-search-input"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Filter by event type"
+ value=""
+ />
+ </form>
+ </div>
+ <div
+ className="event-listeners-content"
+ >
+ <ul
+ className="event-listeners-list"
+ >
+ <li
+ className="event-listener-group"
+ key="0"
+ >
+ <div
+ className="event-listener-header"
+ >
+ <button
+ className="event-listener-expand"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="arrow"
+ />
+ </button>
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="Category 1"
+ />
+ <span
+ className="event-listener-category"
+ >
+ Category 1
+ </span>
+ </label>
+ </div>
+ </li>
+ <li
+ className="event-listener-group"
+ key="1"
+ >
+ <div
+ className="event-listener-header"
+ >
+ <button
+ className="event-listener-expand"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="arrow expanded"
+ />
+ </button>
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="Category 2"
+ />
+ <span
+ className="event-listener-category"
+ >
+ Category 2
+ </span>
+ </label>
+ </div>
+ <ul>
+ <li
+ className="event-listener-event"
+ key="category2.subcategory1"
+ >
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="category2.subcategory1"
+ />
+ <span
+ className="event-listener-name"
+ >
+ Subcategory 3
+ </span>
+ </label>
+ </li>
+ <li
+ className="event-listener-event"
+ key="category2.subcategory2"
+ >
+ <label
+ className="event-listener-label"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ value="category2.subcategory2"
+ />
+ <span
+ className="event-listener-name"
+ >
+ Subcategory 4
+ </span>
+ </label>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </div>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap
new file mode 100644
index 0000000000..6ecf6c2c2e
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap
@@ -0,0 +1,203 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Expressions should always have unique keys 1`] = `
+<div
+ className="pane"
+>
+ <ul
+ className="pane expressions-list"
+ >
+ <li
+ className="expression-container"
+ key="expression1"
+ title="expression1"
+ >
+ <div
+ className="expression-content"
+ >
+ <Component
+ autoExpandDepth={0}
+ createElement={[Function]}
+ disableWrap={true}
+ mayUseCustomFormatter={true}
+ onDOMNodeClick={[Function]}
+ onDOMNodeMouseOut={[Function]}
+ onDOMNodeMouseOver={[Function]}
+ onDoubleClick={[Function]}
+ onInspectIconClick={[Function]}
+ roots={
+ Array [
+ Object {
+ "contents": Object {
+ "front": null,
+ "value": Object {
+ "class": "",
+ "value": undefined,
+ },
+ },
+ "name": "expression1",
+ "path": "expression1",
+ },
+ ]
+ }
+ shouldRenderTooltip={true}
+ />
+ <div
+ className="expression-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove watch expression"
+ />
+ </div>
+ </div>
+ </li>
+ <li
+ className="expression-container"
+ key="expression2"
+ title="expression2"
+ >
+ <div
+ className="expression-content"
+ >
+ <Component
+ autoExpandDepth={0}
+ createElement={[Function]}
+ disableWrap={true}
+ mayUseCustomFormatter={true}
+ onDOMNodeClick={[Function]}
+ onDOMNodeMouseOut={[Function]}
+ onDOMNodeMouseOver={[Function]}
+ onDoubleClick={[Function]}
+ onInspectIconClick={[Function]}
+ roots={
+ Array [
+ Object {
+ "contents": Object {
+ "front": null,
+ "value": Object {
+ "class": "",
+ "value": undefined,
+ },
+ },
+ "name": "expression2",
+ "path": "expression2",
+ },
+ ]
+ }
+ shouldRenderTooltip={true}
+ />
+ <div
+ className="expression-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove watch expression"
+ />
+ </div>
+ </div>
+ </li>
+ </ul>
+</div>
+`;
+
+exports[`Expressions should render 1`] = `
+<div
+ className="pane"
+>
+ <ul
+ className="pane expressions-list"
+ >
+ <li
+ className="expression-container"
+ key="expression1"
+ title="expression1"
+ >
+ <div
+ className="expression-content"
+ >
+ <Component
+ autoExpandDepth={0}
+ createElement={[Function]}
+ disableWrap={true}
+ mayUseCustomFormatter={true}
+ onDOMNodeClick={[Function]}
+ onDOMNodeMouseOut={[Function]}
+ onDOMNodeMouseOver={[Function]}
+ onDoubleClick={[Function]}
+ onInspectIconClick={[Function]}
+ roots={
+ Array [
+ Object {
+ "contents": Object {
+ "front": null,
+ "value": Object {
+ "class": "",
+ "value": "foo",
+ },
+ },
+ "name": "expression1",
+ "path": "expression1",
+ },
+ ]
+ }
+ shouldRenderTooltip={true}
+ />
+ <div
+ className="expression-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove watch expression"
+ />
+ </div>
+ </div>
+ </li>
+ <li
+ className="expression-container"
+ key="expression2"
+ title="expression2"
+ >
+ <div
+ className="expression-content"
+ >
+ <Component
+ autoExpandDepth={0}
+ createElement={[Function]}
+ disableWrap={true}
+ mayUseCustomFormatter={true}
+ onDOMNodeClick={[Function]}
+ onDOMNodeMouseOut={[Function]}
+ onDOMNodeMouseOver={[Function]}
+ onDoubleClick={[Function]}
+ onInspectIconClick={[Function]}
+ roots={
+ Array [
+ Object {
+ "contents": Object {
+ "front": null,
+ "value": Object {
+ "class": "",
+ "value": "bar",
+ },
+ },
+ "name": "expression2",
+ "path": "expression2",
+ },
+ ]
+ }
+ shouldRenderTooltip={true}
+ />
+ <div
+ className="expression-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove watch expression"
+ />
+ </div>
+ </div>
+ </li>
+ </ul>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap
new file mode 100644
index 0000000000..e7c4527621
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap
@@ -0,0 +1,619 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`XHR Breakpoints should render with 0 expressions passed from props 1`] = `
+<XHRBreakpoints
+ disableXHRBreakpoint={[Function]}
+ enableXHRBreakpoint={[Function]}
+ onXHRAdded={[Function]}
+ removeXHRBreakpoint={[Function]}
+ setXHRBreakpoint={[Function]}
+ shouldPauseOnAny={false}
+ showInput={false}
+ togglePauseOnAny={[Function]}
+ updateXHRBreakpoint={[Function]}
+ xhrBreakpoints={
+ Array [
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "ANY",
+ "path": "",
+ "text": "URL contains \\"\\"",
+ },
+ ]
+ }
+>
+ <div
+ className="breakpoints-options empty"
+ >
+ <ExceptionOption
+ className="breakpoints-exceptions"
+ isChecked={false}
+ label="Pause on any URL"
+ onChange={[Function]}
+ >
+ <label
+ className="breakpoints-exceptions"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="breakpoint-exceptions-label"
+ >
+ Pause on any URL
+ </div>
+ </label>
+ </ExceptionOption>
+ </div>
+ <form
+ className="xhr-input-container xhr-input-form"
+ key="xhr-input-container"
+ onSubmit={[Function]}
+ >
+ <input
+ className="xhr-input-url"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Break when URL contains"
+ type="text"
+ value=""
+ />
+ <select
+ className="xhr-input-method"
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ onMouseDown={[Function]}
+ value="ANY"
+ >
+ <option
+ key="ANY"
+ onMouseDown={[Function]}
+ value="ANY"
+ >
+ ANY
+ </option>
+ <option
+ key="GET"
+ onMouseDown={[Function]}
+ value="GET"
+ >
+ GET
+ </option>
+ <option
+ key="POST"
+ onMouseDown={[Function]}
+ value="POST"
+ >
+ POST
+ </option>
+ <option
+ key="PUT"
+ onMouseDown={[Function]}
+ value="PUT"
+ >
+ PUT
+ </option>
+ <option
+ key="HEAD"
+ onMouseDown={[Function]}
+ value="HEAD"
+ >
+ HEAD
+ </option>
+ <option
+ key="DELETE"
+ onMouseDown={[Function]}
+ value="DELETE"
+ >
+ DELETE
+ </option>
+ <option
+ key="PATCH"
+ onMouseDown={[Function]}
+ value="PATCH"
+ >
+ PATCH
+ </option>
+ <option
+ key="OPTIONS"
+ onMouseDown={[Function]}
+ value="OPTIONS"
+ >
+ OPTIONS
+ </option>
+ </select>
+ <input
+ style={
+ Object {
+ "display": "none",
+ }
+ }
+ type="submit"
+ />
+ </form>
+</XHRBreakpoints>
+`;
+
+exports[`XHR Breakpoints should render with 8 expressions passed from props 1`] = `
+<XHRBreakpoints
+ disableXHRBreakpoint={[Function]}
+ enableXHRBreakpoint={[Function]}
+ onXHRAdded={[Function]}
+ removeXHRBreakpoint={[Function]}
+ setXHRBreakpoint={[Function]}
+ shouldPauseOnAny={false}
+ showInput={false}
+ togglePauseOnAny={[Function]}
+ updateXHRBreakpoint={[Function]}
+ xhrBreakpoints={
+ Array [
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "ANY",
+ "path": "",
+ "text": "URL contains \\"\\"",
+ },
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "ANY",
+ "path": "this is any",
+ "text": "URL contains \\"this is any\\"",
+ },
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "GET",
+ "path": "this is get",
+ "text": "URL contains \\"this is get\\"",
+ },
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "POST",
+ "path": "this is post",
+ "text": "URL contains \\"this is post\\"",
+ },
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "PUT",
+ "path": "this is put",
+ "text": "URL contains \\"this is put\\"",
+ },
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "HEAD",
+ "path": "this is head",
+ "text": "URL contains \\"this is head\\"",
+ },
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "DELETE",
+ "path": "this is delete",
+ "text": "URL contains \\"this is delete\\"",
+ },
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "PATCH",
+ "path": "this is patch",
+ "text": "URL contains \\"this is patch\\"",
+ },
+ Object {
+ "disabled": false,
+ "loading": false,
+ "method": "OPTIONS",
+ "path": "this is options",
+ "text": "URL contains \\"this is options\\"",
+ },
+ ]
+ }
+>
+ <div
+ className="breakpoints-options"
+ >
+ <ExceptionOption
+ className="breakpoints-exceptions"
+ isChecked={false}
+ label="Pause on any URL"
+ onChange={[Function]}
+ >
+ <label
+ className="breakpoints-exceptions"
+ >
+ <input
+ checked={false}
+ onChange={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="breakpoint-exceptions-label"
+ >
+ Pause on any URL
+ </div>
+ </label>
+ </ExceptionOption>
+ </div>
+ <ul
+ className="pane expressions-list"
+ >
+ <li
+ className="xhr-container"
+ key="this is any-ANY"
+ onDoubleClick={[Function]}
+ title="this is any"
+ >
+ <label>
+ <input
+ checked={true}
+ className="xhr-checkbox"
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="xhr-label-method"
+ >
+ ANY
+ </div>
+ <div
+ className="xhr-label-url"
+ >
+ this is any
+ </div>
+ <div
+ className="xhr-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ >
+ <button
+ className="close-btn"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="close"
+ >
+ <span
+ className="img close"
+ />
+ </AccessibleImage>
+ </button>
+ </CloseButton>
+ </div>
+ </label>
+ </li>
+ <li
+ className="xhr-container"
+ key="this is get-GET"
+ onDoubleClick={[Function]}
+ title="this is get"
+ >
+ <label>
+ <input
+ checked={true}
+ className="xhr-checkbox"
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="xhr-label-method"
+ >
+ GET
+ </div>
+ <div
+ className="xhr-label-url"
+ >
+ this is get
+ </div>
+ <div
+ className="xhr-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ >
+ <button
+ className="close-btn"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="close"
+ >
+ <span
+ className="img close"
+ />
+ </AccessibleImage>
+ </button>
+ </CloseButton>
+ </div>
+ </label>
+ </li>
+ <li
+ className="xhr-container"
+ key="this is post-POST"
+ onDoubleClick={[Function]}
+ title="this is post"
+ >
+ <label>
+ <input
+ checked={true}
+ className="xhr-checkbox"
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="xhr-label-method"
+ >
+ POST
+ </div>
+ <div
+ className="xhr-label-url"
+ >
+ this is post
+ </div>
+ <div
+ className="xhr-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ >
+ <button
+ className="close-btn"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="close"
+ >
+ <span
+ className="img close"
+ />
+ </AccessibleImage>
+ </button>
+ </CloseButton>
+ </div>
+ </label>
+ </li>
+ <li
+ className="xhr-container"
+ key="this is put-PUT"
+ onDoubleClick={[Function]}
+ title="this is put"
+ >
+ <label>
+ <input
+ checked={true}
+ className="xhr-checkbox"
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="xhr-label-method"
+ >
+ PUT
+ </div>
+ <div
+ className="xhr-label-url"
+ >
+ this is put
+ </div>
+ <div
+ className="xhr-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ >
+ <button
+ className="close-btn"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="close"
+ >
+ <span
+ className="img close"
+ />
+ </AccessibleImage>
+ </button>
+ </CloseButton>
+ </div>
+ </label>
+ </li>
+ <li
+ className="xhr-container"
+ key="this is head-HEAD"
+ onDoubleClick={[Function]}
+ title="this is head"
+ >
+ <label>
+ <input
+ checked={true}
+ className="xhr-checkbox"
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="xhr-label-method"
+ >
+ HEAD
+ </div>
+ <div
+ className="xhr-label-url"
+ >
+ this is head
+ </div>
+ <div
+ className="xhr-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ >
+ <button
+ className="close-btn"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="close"
+ >
+ <span
+ className="img close"
+ />
+ </AccessibleImage>
+ </button>
+ </CloseButton>
+ </div>
+ </label>
+ </li>
+ <li
+ className="xhr-container"
+ key="this is delete-DELETE"
+ onDoubleClick={[Function]}
+ title="this is delete"
+ >
+ <label>
+ <input
+ checked={true}
+ className="xhr-checkbox"
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="xhr-label-method"
+ >
+ DELETE
+ </div>
+ <div
+ className="xhr-label-url"
+ >
+ this is delete
+ </div>
+ <div
+ className="xhr-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ >
+ <button
+ className="close-btn"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="close"
+ >
+ <span
+ className="img close"
+ />
+ </AccessibleImage>
+ </button>
+ </CloseButton>
+ </div>
+ </label>
+ </li>
+ <li
+ className="xhr-container"
+ key="this is patch-PATCH"
+ onDoubleClick={[Function]}
+ title="this is patch"
+ >
+ <label>
+ <input
+ checked={true}
+ className="xhr-checkbox"
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="xhr-label-method"
+ >
+ PATCH
+ </div>
+ <div
+ className="xhr-label-url"
+ >
+ this is patch
+ </div>
+ <div
+ className="xhr-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ >
+ <button
+ className="close-btn"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="close"
+ >
+ <span
+ className="img close"
+ />
+ </AccessibleImage>
+ </button>
+ </CloseButton>
+ </div>
+ </label>
+ </li>
+ <li
+ className="xhr-container"
+ key="this is options-OPTIONS"
+ onDoubleClick={[Function]}
+ title="this is options"
+ >
+ <label>
+ <input
+ checked={true}
+ className="xhr-checkbox"
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="xhr-label-method"
+ >
+ OPTIONS
+ </div>
+ <div
+ className="xhr-label-url"
+ >
+ this is options
+ </div>
+ <div
+ className="xhr-container__close-btn"
+ >
+ <CloseButton
+ handleClick={[Function]}
+ >
+ <button
+ className="close-btn"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="close"
+ >
+ <span
+ className="img close"
+ />
+ </AccessibleImage>
+ </button>
+ </CloseButton>
+ </div>
+ </label>
+ </li>
+ </ul>
+</XHRBreakpoints>
+`;
diff --git a/devtools/client/debugger/src/components/ShortcutsModal.css b/devtools/client/debugger/src/components/ShortcutsModal.css
new file mode 100644
index 0000000000..84024f9677
--- /dev/null
+++ b/devtools/client/debugger/src/components/ShortcutsModal.css
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.shortcuts-content {
+ padding: 15px;
+ column-width: 250px;
+ cursor: default;
+ user-select: none;
+}
+
+.shortcuts-content h2 {
+ margin-top: 2px;
+ margin-bottom: 2px;
+ color: var(--theme-text-color-strong);
+}
+
+.shortcuts-section {
+ display: inline-block;
+ margin: 5px;
+ margin-bottom: 15px;
+ width: 250px;
+}
+
+.shortcuts-list {
+ list-style: none;
+ margin: 0px;
+ padding: 0px;
+ overflow: auto;
+ width: calc(100% - 1px); /* 1px fixes the hidden right border */
+}
+
+.shortcuts-list li {
+ font-size: 12px;
+ color: var(--theme-body-color);
+ padding-top: 5px;
+ display: flex;
+ justify-content: space-between;
+ border: 1px solid transparent;
+ white-space: pre;
+}
+
+@media (max-width: 640px) {
+ .shortcuts-section {
+ width: 100%;
+ }
+}
diff --git a/devtools/client/debugger/src/components/ShortcutsModal.js b/devtools/client/debugger/src/components/ShortcutsModal.js
new file mode 100644
index 0000000000..2e72c1b50f
--- /dev/null
+++ b/devtools/client/debugger/src/components/ShortcutsModal.js
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ ul,
+ h2,
+ span,
+ li,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import Modal from "./shared/Modal";
+import { formatKeyShortcut } from "../utils/text";
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+export class ShortcutsModal extends Component {
+ static get propTypes() {
+ return {
+ enabled: PropTypes.bool.isRequired,
+ handleClose: PropTypes.func.isRequired,
+ };
+ }
+
+ renderPrettyCombos(combo) {
+ return combo
+ .split(" ")
+ .map(c =>
+ span(
+ {
+ key: c,
+ className: "keystroke",
+ },
+ c
+ )
+ )
+ .reduce((prev, curr) => [prev, " + ", curr]);
+ }
+
+ renderShorcutItem(title, combo) {
+ return li(
+ null,
+ span(null, title),
+ span(null, this.renderPrettyCombos(combo))
+ );
+ }
+
+ renderEditorShortcuts() {
+ return ul(
+ {
+ className: "shortcuts-list",
+ },
+ this.renderShorcutItem(
+ L10N.getStr("shortcuts.toggleBreakpoint"),
+ formatKeyShortcut(L10N.getStr("toggleBreakpoint.key"))
+ ),
+ this.renderShorcutItem(
+ L10N.getStr("shortcuts.toggleCondPanel.breakpoint"),
+ formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key"))
+ ),
+ this.renderShorcutItem(
+ L10N.getStr("shortcuts.toggleCondPanel.logPoint"),
+ formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key"))
+ )
+ );
+ }
+
+ renderSteppingShortcuts() {
+ return ul(
+ {
+ className: "shortcuts-list",
+ },
+ this.renderShorcutItem(L10N.getStr("shortcuts.pauseOrResume"), "F8"),
+ this.renderShorcutItem(L10N.getStr("shortcuts.stepOver"), "F10"),
+ this.renderShorcutItem(L10N.getStr("shortcuts.stepIn"), "F11"),
+ this.renderShorcutItem(
+ L10N.getStr("shortcuts.stepOut"),
+ formatKeyShortcut(L10N.getStr("stepOut.key"))
+ )
+ );
+ }
+
+ renderSearchShortcuts() {
+ return ul(
+ {
+ className: "shortcuts-list",
+ },
+ this.renderShorcutItem(
+ L10N.getStr("shortcuts.fileSearch2"),
+ formatKeyShortcut(L10N.getStr("sources.search.key2"))
+ ),
+ this.renderShorcutItem(
+ L10N.getStr("shortcuts.projectSearch2"),
+ formatKeyShortcut(L10N.getStr("projectTextSearch.key"))
+ ),
+ this.renderShorcutItem(
+ L10N.getStr("shortcuts.functionSearch2"),
+ formatKeyShortcut(L10N.getStr("functionSearch.key"))
+ ),
+ this.renderShorcutItem(
+ L10N.getStr("shortcuts.gotoLine"),
+ formatKeyShortcut(L10N.getStr("gotoLineModal.key3"))
+ )
+ );
+ }
+
+ renderShortcutsContent() {
+ return div(
+ {
+ className: classnames("shortcuts-content", isMacOS ? "mac" : ""),
+ },
+ div(
+ {
+ className: "shortcuts-section",
+ },
+ h2(null, L10N.getStr("shortcuts.header.editor")),
+ this.renderEditorShortcuts()
+ ),
+ div(
+ {
+ className: "shortcuts-section",
+ },
+ h2(null, L10N.getStr("shortcuts.header.stepping")),
+ this.renderSteppingShortcuts()
+ ),
+ div(
+ {
+ className: "shortcuts-section",
+ },
+ h2(null, L10N.getStr("shortcuts.header.search")),
+ this.renderSearchShortcuts()
+ )
+ );
+ }
+
+ render() {
+ const { enabled } = this.props;
+
+ if (!enabled) {
+ return null;
+ }
+
+ return React.createElement(
+ Modal,
+ {
+ additionalClass: "shortcuts-modal",
+ handleClose: this.props.handleClose,
+ },
+ this.renderShortcutsContent()
+ );
+ }
+}
diff --git a/devtools/client/debugger/src/components/WelcomeBox.css b/devtools/client/debugger/src/components/WelcomeBox.css
new file mode 100644
index 0000000000..a0932625ae
--- /dev/null
+++ b/devtools/client/debugger/src/components/WelcomeBox.css
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.welcomebox {
+ position: absolute;
+ top: var(--editor-header-height);
+ left: 0;
+ bottom: var(--editor-footer-height);
+ width: calc(100% - 1px);
+ padding: 10vh 0;
+ background-color: var(--theme-toolbar-background);
+ overflow: hidden;
+ font-weight: 300;
+ z-index: 10;
+ user-select: none;
+}
+
+.theme-dark .welcomebox {
+ background-color: var(--theme-body-background);
+}
+
+.alignlabel {
+ display: flex;
+ white-space: nowrap;
+ font-size: 1.25em;
+}
+
+.shortcutKey,
+.shortcutLabel {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+}
+
+.welcomebox__searchSources:hover,
+.welcomebox__searchProject:hover,
+.welcomebox__allShortcuts:hover {
+ color: var(--theme-body-color);
+}
+
+.shortcutKey {
+ direction: ltr;
+ text-align: right;
+ padding-right: 10px;
+ font-family: var(--monospace-font-family);
+ font-size: 14px;
+ line-height: 18px;
+ color: var(--theme-body-color);
+}
+
+.shortcutKey:dir(rtl) {
+ text-align: left;
+}
+
+:root[platform="mac"] .welcomebox .shortcutKey {
+ font-family: system-ui, -apple-system, sans-serif;
+ font-weight: 500;
+}
+
+.shortcutLabel {
+ text-align: start;
+ padding-left: 10px;
+ font-size: 14px;
+ line-height: 18px;
+}
+
+.shortcutFunction {
+ margin: 0 auto;
+ color: var(--theme-comment);
+ display: table;
+}
+
+.shortcutFunction p {
+ display: table-row;
+}
+
+.shortcutFunction .shortcutKey,
+.shortcutFunction .shortcutLabel {
+ padding: 10px 5px;
+ display: table-cell;
+}
diff --git a/devtools/client/debugger/src/components/WelcomeBox.js b/devtools/client/debugger/src/components/WelcomeBox.js
new file mode 100644
index 0000000000..cdb1d6c23e
--- /dev/null
+++ b/devtools/client/debugger/src/components/WelcomeBox.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "devtools/client/shared/vendor/react";
+import {
+ div,
+ p,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { primaryPaneTabs } from "../constants";
+
+import actions from "../actions/index";
+import { getPaneCollapse } from "../selectors/index";
+import { formatKeyShortcut } from "../utils/text";
+
+export class WelcomeBox extends Component {
+ static get propTypes() {
+ return {
+ openQuickOpen: PropTypes.func.isRequired,
+ setActiveSearch: PropTypes.func.isRequired,
+ toggleShortcutsModal: PropTypes.func.isRequired,
+ setPrimaryPaneTab: PropTypes.func.isRequired,
+ };
+ }
+
+ render() {
+ const searchSourcesShortcut = formatKeyShortcut(
+ L10N.getStr("sources.search.key2")
+ );
+
+ const searchProjectShortcut = formatKeyShortcut(
+ L10N.getStr("projectTextSearch.key")
+ );
+
+ const allShortcutsShortcut = formatKeyShortcut(
+ L10N.getStr("allShortcut.key")
+ );
+
+ const allShortcutsLabel = L10N.getStr("welcome.allShortcuts");
+ const searchSourcesLabel = L10N.getStr("welcome.search2").substring(2);
+ const searchProjectLabel = L10N.getStr("welcome.findInFiles2").substring(2);
+
+ return div(
+ {
+ className: "welcomebox",
+ },
+ div(
+ {
+ className: "alignlabel",
+ },
+ div(
+ {
+ className: "shortcutFunction",
+ },
+ p(
+ {
+ className: "welcomebox__searchSources",
+ role: "button",
+ tabIndex: "0",
+ onClick: () => this.props.openQuickOpen(),
+ },
+ span(
+ {
+ className: "shortcutKey",
+ },
+ searchSourcesShortcut
+ ),
+ span(
+ {
+ className: "shortcutLabel",
+ },
+ searchSourcesLabel
+ )
+ ),
+ p(
+ {
+ className: "welcomebox__searchProject",
+ role: "button",
+ tabIndex: "0",
+ onClick: () => {
+ this.props.setActiveSearch(primaryPaneTabs.PROJECT_SEARCH);
+ this.props.setPrimaryPaneTab(primaryPaneTabs.PROJECT_SEARCH);
+ },
+ },
+ span(
+ {
+ className: "shortcutKey",
+ },
+ searchProjectShortcut
+ ),
+ span(
+ {
+ className: "shortcutLabel",
+ },
+ searchProjectLabel
+ )
+ ),
+ p(
+ {
+ className: "welcomebox__allShortcuts",
+ role: "button",
+ tabIndex: "0",
+ onClick: () => this.props.toggleShortcutsModal(),
+ },
+ span(
+ {
+ className: "shortcutKey",
+ },
+ allShortcutsShortcut
+ ),
+ span(
+ {
+ className: "shortcutLabel",
+ },
+ allShortcutsLabel
+ )
+ )
+ )
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ endPanelCollapsed: getPaneCollapse(state, "end"),
+});
+
+export default connect(mapStateToProps, {
+ togglePaneCollapse: actions.togglePaneCollapse,
+ setActiveSearch: actions.setActiveSearch,
+ openQuickOpen: actions.openQuickOpen,
+ setPrimaryPaneTab: actions.setPrimaryPaneTab,
+})(WelcomeBox);
diff --git a/devtools/client/debugger/src/components/moz.build b/devtools/client/debugger/src/components/moz.build
new file mode 100644
index 0000000000..6cd81e653b
--- /dev/null
+++ b/devtools/client/debugger/src/components/moz.build
@@ -0,0 +1,18 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "Editor",
+ "PrimaryPanes",
+ "SecondaryPanes",
+ "shared",
+]
+
+CompiledModules(
+ "App.js",
+ "QuickOpenModal.js",
+ "ShortcutsModal.js",
+ "WelcomeBox.js",
+)
diff --git a/devtools/client/debugger/src/components/shared/AccessibleImage.css b/devtools/client/debugger/src/components/shared/AccessibleImage.css
new file mode 100644
index 0000000000..4ba5f1326a
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/AccessibleImage.css
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.img {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ vertical-align: middle;
+ /* use background-color for the icon color, and mask-image for its shape */
+ background-color: var(--theme-icon-color);
+ mask-size: contain;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ /* multicolor icons use background-image */
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: contain;
+ /* do not let images shrink when used as flex children */
+ flex-shrink: 0;
+}
+
+/* Expand arrow icon */
+.img.arrow {
+ width: 10px;
+ height: 10px;
+ mask-image: url(chrome://devtools/content/debugger/images/arrow.svg);
+ /* we may override the width/height in specific contexts to make the
+ clickable area bigger, but we should always keep the mask size 10x10 */
+ mask-size: 10px 10px;
+ background-color: var(--theme-icon-dimmed-color);
+ transform: rotate(-90deg);
+ transition: transform 180ms var(--animation-curve);
+}
+
+.img.arrow:dir(rtl) {
+ transform: rotate(90deg);
+}
+
+.img.arrow.expanded {
+ /* icon should always point to the bottom (default) when expanded,
+ regardless of the text direction */
+ transform: none !important;
+}
+
+.img.arrow-down {
+ mask-image: url(chrome://devtools/content/debugger/images/arrow-down.svg);
+}
+
+.img.arrow-up {
+ mask-image: url(chrome://devtools/content/debugger/images/arrow-up.svg);
+}
+
+.img.blackBox {
+ mask-image: url(chrome://devtools/content/debugger/images/blackBox.svg);
+}
+
+.img.breadcrumb {
+ mask-image: url(chrome://devtools/content/debugger/images/breadcrumbs-divider.svg);
+}
+
+.img.close {
+ mask-image: url(chrome://devtools/skin/images/close.svg);
+}
+
+.img.disable-pausing {
+ mask-image: url(chrome://devtools/content/debugger/images/disable-pausing.svg);
+}
+
+.img.enable-pausing {
+ mask-image: url(chrome://devtools/content/debugger/images/enable-pausing.svg);
+ background-color: var(--theme-icon-checked-color);
+}
+
+.img.globe {
+ mask-image: url(chrome://devtools/content/debugger/images/globe.svg);
+}
+
+.img.globe-small {
+ mask-image: url(chrome://devtools/content/debugger/images/globe-small.svg);
+ mask-size: 12px 12px;
+}
+
+.img.window {
+ mask-image: url(chrome://devtools/content/debugger/images/window.svg);
+}
+
+.img.file {
+ mask-image: url(chrome://devtools/content/debugger/images/file-small.svg);
+ mask-size: 12px 12px;
+}
+
+.img.folder {
+ mask-image: url(chrome://devtools/content/debugger/images/folder.svg);
+}
+
+.img.home {
+ mask-image: url(chrome://devtools/content/debugger/images/home.svg);
+}
+
+.img.info {
+ mask-image: url(chrome://devtools/skin/images/info.svg);
+}
+
+.img.loader {
+ background-image: url(chrome://devtools/content/debugger/images/loader.svg);
+ -moz-context-properties: fill;
+ fill: var(--theme-icon-color);
+ background-color: unset;
+}
+
+.img.more-tabs {
+ mask-image: url(chrome://devtools/content/debugger/images/command-chevron.svg);
+}
+
+html[dir="rtl"] .img.more-tabs {
+ transform: scaleX(-1);
+}
+
+.img.sourcemap {
+ background-image: url(chrome://devtools/content/debugger/images/sourcemap.svg);
+ -moz-context-properties: fill;
+ fill: var(--theme-icon-warning-color);
+ background-color: unset;
+}
+
+.img.next {
+ mask-image: url(chrome://devtools/content/debugger/images/next.svg);
+}
+
+.img.next-circle {
+ mask-image: url(chrome://devtools/content/debugger/images/next-circle.svg);
+}
+
+.img.pane-collapse {
+ mask-image: url(chrome://devtools/content/debugger/images/pane-collapse.svg);
+}
+
+.img.pane-expand {
+ mask-image: url(chrome://devtools/content/debugger/images/pane-expand.svg);
+}
+
+.img.pause {
+ mask-image: url(chrome://devtools/content/debugger/images/pause.svg);
+}
+
+.img.plus {
+ mask-image: url(chrome://devtools/skin/images/add.svg);
+}
+
+.img.prettyPrint {
+ background-image: url(chrome://devtools/content/debugger/images/prettyPrint.svg);
+ background-size: 14px 14px;
+ background-color: transparent !important;
+ fill: var(--theme-icon-color);
+ -moz-context-properties: fill;
+}
+
+.img.removeAll {
+ mask-image: url(chrome://devtools/skin/images/clear.svg)
+}
+
+.img.refresh {
+ mask-image: url(chrome://devtools/skin/images/reload.svg);
+}
+
+.img.resume {
+ mask-image: url(resource://devtools-shared-images/resume.svg);
+}
+
+.img.search {
+ mask-image: url(chrome://devtools/content/debugger/images/search.svg);
+}
+
+.img.shortcuts {
+ mask-image: url(chrome://devtools/content/debugger/images/help.svg);
+}
+
+.img.spin {
+ animation: spin 0.5s linear infinite;
+}
+
+.img.stepIn {
+ mask-image: url(chrome://devtools/content/debugger/images/stepIn.svg);
+}
+
+.img.stepOut {
+ mask-image: url(chrome://devtools/content/debugger/images/stepOut.svg);
+}
+
+.img.stepOver {
+ mask-image: url(resource://devtools-shared-images/stepOver.svg);
+}
+
+.img.tab {
+ mask-image: url(chrome://devtools/content/debugger/images/tab.svg);
+}
+
+.img.worker {
+ mask-image: url(chrome://devtools/content/debugger/images/worker.svg);
+}
diff --git a/devtools/client/debugger/src/components/shared/AccessibleImage.js b/devtools/client/debugger/src/components/shared/AccessibleImage.js
new file mode 100644
index 0000000000..e3a59573ea
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/AccessibleImage.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+const AccessibleImage = props => {
+ return React.createElement("span", {
+ ...props,
+ className: classnames("img", props.className),
+ });
+};
+
+AccessibleImage.propTypes = {
+ className: PropTypes.string.isRequired,
+};
+
+export default AccessibleImage;
diff --git a/devtools/client/debugger/src/components/shared/Accordion.css b/devtools/client/debugger/src/components/shared/Accordion.css
new file mode 100644
index 0000000000..d970527014
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Accordion.css
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.accordion {
+ background-color: var(--theme-sidebar-background);
+ width: 100%;
+ list-style-type: none;
+ padding: 0px;
+ margin-top: 0px;
+}
+
+.accordion ._header {
+ background-color: var(--theme-accordion-header-background);
+ border-bottom: 1px solid var(--theme-splitter-color);
+ display: flex;
+ column-gap: 8px;
+ font-size: 12px;
+ line-height: calc(16 / 12);
+ padding: 4px 6px;
+ width: 100%;
+ align-items: center;
+ margin: 0px;
+ font-weight: normal;
+ cursor: default;
+ user-select: none;
+}
+
+.accordion ._header:hover {
+ background-color: var(--theme-accordion-header-hover);
+}
+
+
+.accordion ._header .header-label {
+ flex-grow: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--theme-toolbar-color);
+ background: transparent;
+ padding: 0;
+
+ /* align expand arrow and button text */
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ &:hover {
+ background: transparent;
+ }
+
+ /* The expand arrow needs to be displayed inside the button to be accessible */
+ &::before {
+ content: "";
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ background-image: url(chrome://devtools/content/debugger/images/arrow.svg);
+ background-size: contain;
+ -moz-context-properties: fill;
+ fill: var(--theme-icon-dimmed-color);
+ rotate: -90deg;
+ transition: rotate 180ms var(--animation-curve);
+
+ &:dir(rtl) {
+ rotate: 90deg;
+ }
+ }
+
+ &[aria-expanded="true"]::before {
+ /* icon should always point to the bottom (default) when expanded,
+ regardless of the text direction */
+ rotate: 0deg !important;
+ }
+}
+
+.accordion ._header .header-buttons {
+ display: flex;
+ margin-inline-start: auto;
+}
+
+.accordion ._header .header-buttons button {
+ color: var(--theme-body-color);
+ border: none;
+ background: none;
+ padding: 0;
+ margin: 0 2px;
+ width: 16px;
+ height: 16px;
+}
+
+.accordion ._header .header-buttons button::-moz-focus-inner {
+ border: none;
+}
+
+.accordion ._header .header-buttons button .img {
+ display: block;
+}
+
+.accordion ._content {
+ border-bottom: 1px solid var(--theme-splitter-color);
+ font-size: var(--theme-body-font-size);
+}
+
+.accordion div:last-child ._content {
+ border-bottom: none;
+}
diff --git a/devtools/client/debugger/src/components/shared/Accordion.js b/devtools/client/debugger/src/components/shared/Accordion.js
new file mode 100644
index 0000000000..3b5d5ae516
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Accordion.js
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { cloneElement, Component } from "devtools/client/shared/vendor/react";
+import {
+ aside,
+ button,
+ div,
+ h2,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+class Accordion extends Component {
+ static get propTypes() {
+ return {
+ items: PropTypes.array.isRequired,
+ };
+ }
+
+ handleHeaderClick(i) {
+ const item = this.props.items[i];
+ const opened = !item.opened;
+ item.opened = opened;
+
+ if (item.onToggle) {
+ item.onToggle(opened);
+ }
+
+ // We force an update because otherwise the accordion
+ // would not re-render
+ this.forceUpdate();
+ }
+
+ renderContainer = (item, i) => {
+ const { opened } = item;
+ const contentElementId = `${item.id}-content`;
+
+ return aside(
+ {
+ className: item.className,
+ key: item.id,
+ "aria-labelledby": item.id,
+ role: item.role,
+ },
+ h2(
+ {
+ className: "_header",
+ },
+ button(
+ {
+ id: item.id,
+ className: "header-label",
+ "aria-expanded": `${opened ? "true" : "false"}`,
+ "aria-controls": opened ? contentElementId : undefined,
+ onClick: () => this.handleHeaderClick(i),
+ },
+ item.header
+ ),
+ item.buttons
+ ? div(
+ {
+ className: "header-buttons",
+ },
+ item.buttons
+ )
+ : null
+ ),
+ opened &&
+ div(
+ {
+ className: "_content",
+ id: contentElementId,
+ },
+ cloneElement(item.component, item.componentProps || {})
+ )
+ );
+ };
+ render() {
+ return div(
+ {
+ className: "accordion",
+ },
+ this.props.items.map(this.renderContainer)
+ );
+ }
+}
+
+export default Accordion;
diff --git a/devtools/client/debugger/src/components/shared/Badge.css b/devtools/client/debugger/src/components/shared/Badge.css
new file mode 100644
index 0000000000..f52d32edf4
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Badge.css
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.badge {
+ --size: 17px;
+ --radius: calc(var(--size) / 2);
+ height: var(--size);
+ min-width: var(--size);
+ line-height: var(--size);
+ background: var(--theme-toolbar-background-hover);
+ color: var(--theme-body-color);
+ border-radius: var(--radius);
+ padding: 0 4px;
+ font-size: 0.9em;
+}
diff --git a/devtools/client/debugger/src/components/shared/Badge.js b/devtools/client/debugger/src/components/shared/Badge.js
new file mode 100644
index 0000000000..72571c0f58
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Badge.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+class Badge extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ static get propTypes() {
+ return {
+ badgeText: PropTypes.node.isRequired,
+ };
+ }
+
+ render() {
+ return React.createElement(
+ "span",
+ {
+ className: "badge text-white text-center",
+ },
+ this.props.badgeText
+ );
+ }
+}
+
+export default Badge;
diff --git a/devtools/client/debugger/src/components/shared/BracketArrow.css b/devtools/client/debugger/src/components/shared/BracketArrow.css
new file mode 100644
index 0000000000..afca888371
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/BracketArrow.css
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.bracket-arrow {
+ position: absolute;
+ pointer-events: none;
+}
+
+.bracket-arrow::before,
+.bracket-arrow::after {
+ content: "";
+ height: 0;
+ width: 0;
+ position: absolute;
+ border: 7px solid transparent;
+}
+
+.bracket-arrow.up::before {
+ border-bottom-color: var(--theme-splitter-color);
+ top: -1px;
+}
+
+.theme-dark .bracket-arrow.up::before {
+ border-bottom-color: var(--theme-body-color);
+}
+
+.bracket-arrow.up::after {
+ border-bottom-color: var(--theme-body-background);
+ top: 0px;
+}
+
+.bracket-arrow.down::before {
+ border-bottom-color: transparent;
+ border-top-color: var(--theme-splitter-color);
+ top: 0px;
+}
+
+.theme-dark .bracket-arrow.down::before {
+ border-top-color: var(--theme-body-color);
+}
+
+.bracket-arrow.down::after {
+ border-bottom-color: transparent;
+ border-top-color: var(--theme-body-background);
+ top: -1px;
+}
+
+.bracket-arrow.left::before {
+ border-left-color: transparent;
+ border-right-color: var(--theme-splitter-color);
+ top: 0px;
+}
+
+.theme-dark .bracket-arrow.left::before {
+ border-right-color: var(--theme-body-color);
+}
+
+.bracket-arrow.left::after {
+ border-left-color: transparent;
+ border-right-color: var(--theme-body-background);
+ top: 0px;
+ left: 1px;
+}
diff --git a/devtools/client/debugger/src/components/shared/BracketArrow.js b/devtools/client/debugger/src/components/shared/BracketArrow.js
new file mode 100644
index 0000000000..40e2cda6c4
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/BracketArrow.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+const BracketArrow = ({ orientation, left, top, bottom }) => {
+ return div({
+ className: classnames("bracket-arrow", orientation || "up"),
+ style: {
+ left,
+ top,
+ bottom,
+ },
+ });
+};
+
+BracketArrow.propTypes = {
+ bottom: PropTypes.number,
+ left: PropTypes.number,
+ orientation: PropTypes.string.isRequired,
+ top: PropTypes.number,
+};
+
+export default BracketArrow;
diff --git a/devtools/client/debugger/src/components/shared/Button/CloseButton.js b/devtools/client/debugger/src/components/shared/Button/CloseButton.js
new file mode 100644
index 0000000000..a8f66de60d
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/CloseButton.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { button } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import AccessibleImage from "../AccessibleImage";
+
+function CloseButton({ handleClick, buttonClass, tooltip }) {
+ return button(
+ {
+ className: buttonClass ? `close-btn ${buttonClass}` : "close-btn",
+ onClick: handleClick,
+ title: tooltip,
+ },
+ React.createElement(AccessibleImage, {
+ className: "close",
+ })
+ );
+}
+
+CloseButton.propTypes = {
+ buttonClass: PropTypes.string,
+ handleClick: PropTypes.func.isRequired,
+ tooltip: PropTypes.string,
+};
+
+export default CloseButton;
diff --git a/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js b/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js
new file mode 100644
index 0000000000..4b0b52e186
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/CommandBarButton.js
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { button } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import AccessibleImage from "../AccessibleImage";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+export function debugBtn(
+ onClick,
+ type,
+ className,
+ tooltip,
+ disabled = false,
+ ariaPressed = false
+) {
+ return React.createElement(
+ CommandBarButton,
+ {
+ className: classnames(type, className),
+ disabled: disabled,
+ key: type,
+ onClick: onClick,
+ pressed: ariaPressed,
+ title: tooltip,
+ },
+ React.createElement(AccessibleImage, {
+ className: type,
+ })
+ );
+}
+const CommandBarButton = props => {
+ const { children, className, pressed = false, ...rest } = props;
+
+ return button(
+ {
+ "aria-pressed": pressed,
+ className: classnames("command-bar-button", className),
+ ...rest,
+ },
+ children
+ );
+};
+
+CommandBarButton.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string.isRequired,
+ pressed: PropTypes.bool,
+};
+
+export default CommandBarButton;
diff --git a/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js b/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js
new file mode 100644
index 0000000000..ad003552ad
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import AccessibleImage from "../AccessibleImage";
+import { CommandBarButton } from "./index";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class PaneToggleButton extends PureComponent {
+ static defaultProps = {
+ horizontal: false,
+ position: "start",
+ };
+
+ static get propTypes() {
+ return {
+ collapsed: PropTypes.bool.isRequired,
+ handleClick: PropTypes.func.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ position: PropTypes.oneOf(["start", "end"]).isRequired,
+ };
+ }
+
+ label(position, collapsed) {
+ switch (position) {
+ case "start":
+ return L10N.getStr(collapsed ? "expandSources" : "collapseSources");
+ case "end":
+ return L10N.getStr(
+ collapsed ? "expandBreakpoints" : "collapseBreakpoints"
+ );
+ }
+ return null;
+ }
+
+ render() {
+ const { position, collapsed, horizontal, handleClick } = this.props;
+ return React.createElement(
+ CommandBarButton,
+ {
+ className: classnames("toggle-button", position, {
+ collapsed,
+ vertical: !horizontal,
+ }),
+ onClick: () => handleClick(position, !collapsed),
+ title: this.label(position, collapsed),
+ },
+ React.createElement(AccessibleImage, {
+ className: collapsed ? "pane-expand" : "pane-collapse",
+ })
+ );
+ }
+}
+
+export default PaneToggleButton;
diff --git a/devtools/client/debugger/src/components/shared/Button/index.js b/devtools/client/debugger/src/components/shared/Button/index.js
new file mode 100644
index 0000000000..df7976ba90
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/index.js
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import CloseButton from "./CloseButton";
+import CommandBarButton, { debugBtn } from "./CommandBarButton";
+import PaneToggleButton from "./PaneToggleButton";
+
+export { CloseButton, CommandBarButton, debugBtn, PaneToggleButton };
diff --git a/devtools/client/debugger/src/components/shared/Button/moz.build b/devtools/client/debugger/src/components/shared/Button/moz.build
new file mode 100644
index 0000000000..c6e652d5dc
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/moz.build
@@ -0,0 +1,15 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "styles",
+]
+
+CompiledModules(
+ "CloseButton.js",
+ "CommandBarButton.js",
+ "index.js",
+ "PaneToggleButton.js",
+)
diff --git a/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css b/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css
new file mode 100644
index 0000000000..c2d8df6d38
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.close-btn {
+ width: 16px;
+ height: 16px;
+ border: 1px solid transparent;
+ border-radius: 2px;
+ padding: 1px;
+ color: var(--theme-icon-color);
+}
+
+.close-btn:hover {
+ color: var(--theme-selection-color);
+ background-color: var(--theme-selection-background);
+}
+
+.close-btn .img {
+ display: block;
+ width: 12px;
+ height: 12px;
+ /* inherit the button's text color for the icon's color */
+ background-color: currentColor;
+}
+
+.close-btn.big {
+ width: 20px;
+ height: 20px;
+}
+
+.close-btn.big .img {
+ width: 16px;
+ height: 16px;
+}
diff --git a/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css b/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css
new file mode 100644
index 0000000000..12e53e6fc5
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.command-bar-button {
+ appearance: none;
+ background: transparent;
+ border: none;
+ display: inline-block;
+ text-align: center;
+ position: relative;
+ padding: 0px 5px;
+ fill: currentColor;
+ min-width: 30px;
+ /* Adjust outline so it's not clipped */
+ outline-offset: -3px;
+}
+
+.command-bar-button:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.command-bar-button:not(.disabled):hover,
+.devtools-button.debugger-settings-menu-button:empty:enabled:not([aria-expanded="true"]):hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+.theme-dark .command-bar-button:not(.disabled):hover,
+.devtools-button.debugger-settings-menu-button:empty:enabled:not([aria-expanded="true"]):hover {
+ background: var(--theme-toolbar-hover);
+}
+
+:root.theme-dark .command-bar-button {
+ color: var(--theme-body-color);
+}
+
+.command-bar-button > * {
+ width: 16px;
+ height: 16px;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+/**
+ * Settings icon and menu
+ */
+.devtools-button.debugger-settings-menu-button {
+ border-radius: 0;
+ margin: 0;
+ padding: 0;
+}
+
+.devtools-button.debugger-settings-menu-button::before {
+ background-image: url("chrome://devtools/skin/images/settings.svg");
+}
+
+.devtools-button.debugger-trace-menu-button::before {
+ background-image: url(chrome://devtools/content/debugger/images/trace.svg);
+}
+.devtools-button.debugger-trace-menu-button:is(.active, .pending)::before {
+ fill: var(--theme-icon-checked-color);
+}
+.devtools-button.debugger-trace-menu-button.pending::after
+{
+ content: url("chrome://global/skin/icons/badge-blue.svg");
+ width: 14px;
+ height: 14px;
+ display: block;
+ position: absolute;
+ bottom: -2px;
+ right: 0;
+}
diff --git a/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css b/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css
new file mode 100644
index 0000000000..d8a2495408
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/styles/PaneToggleButton.css
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.toggle-button {
+ padding: 4px 6px;
+}
+
+.toggle-button .img {
+ vertical-align: middle;
+}
+
+.toggle-button.end {
+ margin-inline-end: 0px;
+ margin-inline-start: auto;
+}
+
+.toggle-button.start {
+ margin-inline-start: 0px;
+}
+
+html[dir="rtl"] .toggle-button.start .img,
+html[dir="ltr"] .toggle-button.end:not(.vertical) .img {
+ transform: scaleX(-1);
+}
+
+.toggle-button.end.vertical .img {
+ transform: rotate(-90deg);
+}
diff --git a/devtools/client/debugger/src/components/shared/Button/styles/moz.build b/devtools/client/debugger/src/components/shared/Button/styles/moz.build
new file mode 100644
index 0000000000..7d80140dbe
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/styles/moz.build
@@ -0,0 +1,8 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules()
diff --git a/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js
new file mode 100644
index 0000000000..5e448881d9
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.spec.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import { CloseButton } from "../";
+
+describe("CloseButton", () => {
+ it("renders with tooltip", () => {
+ const tooltip = "testTooltip";
+ const wrapper = shallow(
+ React.createElement(CloseButton, {
+ tooltip: tooltip,
+ handleClick: () => {},
+ })
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("handles click event", () => {
+ const handleClickSpy = jest.fn();
+ const wrapper = shallow(
+ React.createElement(CloseButton, {
+ handleClick: handleClickSpy,
+ })
+ );
+ wrapper.simulate("click");
+ expect(handleClickSpy).toHaveBeenCalled();
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js
new file mode 100644
index 0000000000..41537cf8e4
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import { CommandBarButton, debugBtn } from "../";
+
+describe("CommandBarButton", () => {
+ it("renders", () => {
+ const wrapper = shallow(
+ React.createElement(CommandBarButton, {
+ children: [],
+ className: "",
+ })
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("renders children", () => {
+ const children = [1, 2, 3, 4];
+ const wrapper = shallow(
+ React.createElement(CommandBarButton, {
+ children: children,
+ className: "",
+ })
+ );
+ expect(wrapper.find("button").children()).toHaveLength(4);
+ });
+});
+
+describe("debugBtn", () => {
+ it("renders", () => {
+ const wrapper = shallow(debugBtn());
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("handles onClick", () => {
+ const onClickSpy = jest.fn();
+ const wrapper = shallow(debugBtn(onClickSpy));
+ wrapper.simulate("click");
+ expect(onClickSpy).toHaveBeenCalled();
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js b/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js
new file mode 100644
index 0000000000..89b548379d
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/tests/PaneToggleButton.spec.js
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import { PaneToggleButton } from "../";
+
+describe("PaneToggleButton", () => {
+ const handleClickSpy = jest.fn();
+ const wrapper = shallow(
+ React.createElement(PaneToggleButton, {
+ handleClick: handleClickSpy,
+ collapsed: false,
+ position: "start",
+ })
+ );
+
+ it("renders default", () => {
+ expect(wrapper.hasClass("vertical")).toBe(true);
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("toggles horizontal class", () => {
+ wrapper.setProps({ horizontal: true });
+ expect(wrapper.hasClass("vertical")).toBe(false);
+ });
+
+ it("toggles collapsed class", () => {
+ wrapper.setProps({ collapsed: true });
+ expect(wrapper.hasClass("collapsed")).toBe(true);
+ });
+
+ it("toggles start position", () => {
+ wrapper.setProps({ position: "start" });
+ expect(wrapper.hasClass("start")).toBe(true);
+ });
+
+ it("toggles end position", () => {
+ wrapper.setProps({ position: "end" });
+ expect(wrapper.hasClass("end")).toBe(true);
+ });
+
+ it("handleClick is called", () => {
+ const position = "end";
+ const collapsed = false;
+ wrapper.setProps({ position, collapsed });
+ wrapper.simulate("click");
+ expect(handleClickSpy).toHaveBeenCalledWith(position, true);
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap
new file mode 100644
index 0000000000..d0a0cb9967
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CloseButton.spec.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CloseButton renders with tooltip 1`] = `
+<button
+ className="close-btn"
+ onClick={[Function]}
+ title="testTooltip"
+>
+ <AccessibleImage
+ className="close"
+ />
+</button>
+`;
diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap
new file mode 100644
index 0000000000..cebcb5892c
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/CommandBarButton.spec.js.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CommandBarButton renders 1`] = `
+<button
+ aria-pressed={false}
+ className="command-bar-button"
+/>
+`;
+
+exports[`debugBtn renders 1`] = `
+<button
+ aria-pressed={false}
+ className="command-bar-button"
+ disabled={false}
+>
+ <AccessibleImage />
+</button>
+`;
diff --git a/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap
new file mode 100644
index 0000000000..86067066a6
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/tests/__snapshots__/PaneToggleButton.spec.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PaneToggleButton renders default 1`] = `
+<CommandBarButton
+ className="toggle-button start vertical"
+ onClick={[Function]}
+ title="Collapse Sources and Outline panes"
+>
+ <AccessibleImage
+ className="pane-collapse"
+ />
+</CommandBarButton>
+`;
diff --git a/devtools/client/debugger/src/components/shared/Dropdown.css b/devtools/client/debugger/src/components/shared/Dropdown.css
new file mode 100644
index 0000000000..bb9295b296
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Dropdown.css
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.dropdown {
+ background: var(--theme-body-background);
+ border: 1px solid var(--theme-splitter-color);
+ border-radius: 4px;
+ box-shadow: 0 4px 4px 0 var(--search-overlays-semitransparent);
+ max-height: 300px;
+ position: absolute;
+ top: 24px;
+ width: 150px;
+ z-index: 1000;
+ overflow: auto;
+}
+
+[dir="ltr"] .dropdown {
+ right: 2px;
+}
+
+[dir="rtl"] .dropdown {
+ left: 2px;
+}
+
+.dropdown-block {
+ position: relative;
+ align-self: center;
+ height: 100%;
+}
+
+/* cover the reserved space at the end of .source-tabs */
+.source-tabs + .dropdown-block {
+ margin-inline-start: -28px;
+}
+
+.dropdown-button {
+ color: var(--theme-comment);
+ background: none;
+ border: none;
+ padding: 4px 6px;
+ font-weight: 100;
+ font-size: 14px;
+ height: 100%;
+ width: 28px;
+ outline-offset: -2px;
+}
+
+.dropdown-button .img {
+ display: block;
+}
+
+.dropdown ul {
+ margin: 0;
+ padding: 4px 0;
+ list-style: none;
+}
+
+.dropdown li {
+ display: flex;
+ align-items: center;
+ padding: 6px 8px;
+ font-size: 12px;
+ line-height: calc(16 / 12);
+ transition: all 0.25s ease;
+}
+
+.dropdown li:hover {
+ background-color: var(--search-overlays-semitransparent);
+}
+
+.dropdown-icon {
+ margin-inline-end: 4px;
+ mask-size: 13px 13px;
+}
+
+.dropdown-label {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dropdown-icon.prettyPrint,
+.dropdown-icon.blackBox {
+ background-color: var(--theme-highlight-blue);
+}
+
+.dropdown-mask {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ background: transparent;
+ z-index: 999;
+ left: 0;
+ top: 0;
+}
diff --git a/devtools/client/debugger/src/components/shared/Dropdown.js b/devtools/client/debugger/src/components/shared/Dropdown.js
new file mode 100644
index 0000000000..a47eef9534
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Dropdown.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "devtools/client/shared/vendor/react";
+import { button, div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+export class Dropdown extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ dropdownShown: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ icon: PropTypes.node.isRequired,
+ panel: PropTypes.node.isRequired,
+ };
+ }
+
+ toggleDropdown = e => {
+ this.setState(prevState => ({
+ dropdownShown: !prevState.dropdownShown,
+ }));
+ };
+
+ renderPanel() {
+ return div(
+ {
+ className: "dropdown",
+ onClick: this.toggleDropdown,
+ style: {
+ display: this.state.dropdownShown ? "block" : "none",
+ },
+ },
+ this.props.panel
+ );
+ }
+
+ renderButton() {
+ return button(
+ {
+ className: "dropdown-button",
+ onClick: this.toggleDropdown,
+ },
+ this.props.icon
+ );
+ }
+
+ renderMask() {
+ return div({
+ className: "dropdown-mask",
+ onClick: this.toggleDropdown,
+ style: {
+ display: this.state.dropdownShown ? "block" : "none",
+ },
+ });
+ }
+ render() {
+ return div(
+ {
+ className: "dropdown-block",
+ },
+ this.renderPanel(),
+ this.renderButton(),
+ this.renderMask()
+ );
+ }
+}
+
+export default Dropdown;
diff --git a/devtools/client/debugger/src/components/shared/Modal.css b/devtools/client/debugger/src/components/shared/Modal.css
new file mode 100644
index 0000000000..2c8f429285
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Modal.css
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.modal-wrapper {
+ position: fixed;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ z-index: 100;
+}
+
+.modal {
+ display: flex;
+ flex-direction: column;
+ /* Place the modal below the sources tab strip */
+ margin-block-start: var(--editor-header-height);
+ width: 80%;
+ max-height: 80vh;
+ overflow-y: auto;
+ background-color: var(--theme-toolbar-background);
+ box-shadow: 1px 1px 6px 1px var(--popup-shadow-color);
+
+ @media not (prefers-reduced-motion) {
+ animation: 150ms cubic-bezier(0.07, 0.95, 0, 1) slidein forwards;
+ }
+}
+
+@keyframes slidein {
+ from {
+ transform: translateY(-101%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+/* This rule is active when the screen is not narrow */
+@media (min-width: 580px) {
+ .modal {
+ width: 50%;
+ }
+}
diff --git a/devtools/client/debugger/src/components/shared/Modal.js b/devtools/client/debugger/src/components/shared/Modal.js
new file mode 100644
index 0000000000..c14732f302
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Modal.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import React from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class Modal extends React.Component {
+ static get propTypes() {
+ return {
+ additionalClass: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ handleClose: PropTypes.func.isRequired,
+ };
+ }
+
+ onClick = e => {
+ e.stopPropagation();
+ };
+
+ render() {
+ const { additionalClass, children, handleClose } = this.props;
+ return div(
+ {
+ className: "modal-wrapper",
+ onClick: handleClose,
+ },
+ div(
+ {
+ className: classnames("modal", additionalClass),
+ onClick: this.onClick,
+ },
+ children
+ )
+ );
+ }
+}
+
+Modal.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+export default Modal;
diff --git a/devtools/client/debugger/src/components/shared/Popover.css b/devtools/client/debugger/src/components/shared/Popover.css
new file mode 100644
index 0000000000..5da8ea4b63
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Popover.css
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.popover {
+ position: fixed;
+ z-index: 100;
+ --gap-size: 10px;
+ --left-offset: -55px;
+}
+
+.popover.orientation-right {
+ display: flex;
+ flex-direction: row;
+}
+
+.popover.orientation-right .gap {
+ width: var(--gap-size);
+}
+
+.popover:not(.orientation-right) .gap {
+ height: var(--gap-size);
+ margin-left: var(--left-offset);
+}
+
+.popover:not(.orientation-right) .preview-popup {
+ margin-left: var(--left-offset);
+}
+
+.popover .add-to-expression-bar {
+ margin-left: var(--left-offset);
+}
diff --git a/devtools/client/debugger/src/components/shared/Popover.js b/devtools/client/debugger/src/components/shared/Popover.js
new file mode 100644
index 0000000000..8748e36418
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Popover.js
@@ -0,0 +1,324 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { div } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import BracketArrow from "./BracketArrow";
+import SmartGap from "./SmartGap";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+class Popover extends Component {
+ state = {
+ coords: {
+ left: 0,
+ top: 0,
+ orientation: "down",
+ targetMid: { x: 0, y: 0 },
+ },
+ };
+ firstRender = true;
+
+ static defaultProps = {
+ type: "popover",
+ };
+
+ static get propTypes() {
+ return {
+ children: PropTypes.node.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ mouseout: PropTypes.func.isRequired,
+ target: PropTypes.object.isRequired,
+ targetPosition: PropTypes.object.isRequired,
+ type: PropTypes.string.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ const { type } = this.props;
+ this.gapHeight = this.$gap.getBoundingClientRect().height;
+ const coords =
+ type == "popover" ? this.getPopoverCoords() : this.getTooltipCoords();
+
+ if (coords) {
+ this.setState({ coords });
+ }
+
+ this.firstRender = false;
+ this.startTimer();
+ }
+
+ componentDidUpdate(prevProps) {
+ // We have to update `coords` when the Popover type changes
+ if (
+ prevProps.type != this.props.type ||
+ prevProps.target !== this.props.target
+ ) {
+ const coords =
+ this.props.type == "popover"
+ ? this.getPopoverCoords()
+ : this.getTooltipCoords();
+
+ if (coords) {
+ this.setState({ coords });
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.timerId) {
+ clearTimeout(this.timerId);
+ }
+ }
+
+ startTimer() {
+ this.timerId = setTimeout(this.onTimeout, 0);
+ }
+
+ onTimeout = () => {
+ const isHoveredOnGap = this.$gap && this.$gap.matches(":hover");
+ const isHoveredOnPopover = this.$popover && this.$popover.matches(":hover");
+ const isHoveredOnTooltip = this.$tooltip && this.$tooltip.matches(":hover");
+ const isHoveredOnTarget = this.props.target.matches(":hover");
+
+ if (isHoveredOnGap) {
+ if (!this.wasOnGap) {
+ this.wasOnGap = true;
+ this.timerId = setTimeout(this.onTimeout, 200);
+ return;
+ }
+ this.props.mouseout();
+ return;
+ }
+
+ // Don't clear the current preview if mouse is hovered on
+ // the current preview's token (target) or the popup element
+ if (isHoveredOnPopover || isHoveredOnTooltip || isHoveredOnTarget) {
+ this.wasOnGap = false;
+ this.timerId = setTimeout(this.onTimeout, 0);
+ return;
+ }
+
+ this.props.mouseout();
+ };
+
+ calculateLeft(target, editor, popover, orientation) {
+ const estimatedLeft = target.left;
+ const estimatedRight = estimatedLeft + popover.width;
+ const isOverflowingRight = estimatedRight > editor.right;
+ if (orientation === "right") {
+ return target.left + target.width;
+ }
+ if (isOverflowingRight) {
+ const adjustedLeft = editor.right - popover.width - 8;
+ return adjustedLeft;
+ }
+ return estimatedLeft;
+ }
+
+ calculateTopForRightOrientation = (target, editor, popover) => {
+ if (popover.height <= editor.height) {
+ const rightOrientationTop = target.top - popover.height / 2;
+ if (rightOrientationTop < editor.top) {
+ return editor.top - target.height;
+ }
+ const rightOrientationBottom = rightOrientationTop + popover.height;
+ if (rightOrientationBottom > editor.bottom) {
+ return editor.bottom + target.height - popover.height + this.gapHeight;
+ }
+ return rightOrientationTop;
+ }
+ return editor.top - target.height;
+ };
+
+ calculateOrientation(target, editor, popover) {
+ const estimatedBottom = target.bottom + popover.height;
+ if (editor.bottom > estimatedBottom) {
+ return "down";
+ }
+ const upOrientationTop = target.top - popover.height;
+ if (upOrientationTop > editor.top) {
+ return "up";
+ }
+
+ return "right";
+ }
+
+ calculateTop = (target, editor, popover, orientation) => {
+ if (orientation === "down") {
+ return target.bottom;
+ }
+ if (orientation === "up") {
+ return target.top - popover.height;
+ }
+
+ return this.calculateTopForRightOrientation(target, editor, popover);
+ };
+
+ getPopoverCoords() {
+ if (!this.$popover || !this.props.editorRef) {
+ return null;
+ }
+
+ const popover = this.$popover;
+ const editor = this.props.editorRef;
+ const popoverRect = popover.getBoundingClientRect();
+ const editorRect = editor.getBoundingClientRect();
+ const targetRect = this.props.targetPosition;
+ const orientation = this.calculateOrientation(
+ targetRect,
+ editorRect,
+ popoverRect
+ );
+ const top = this.calculateTop(
+ targetRect,
+ editorRect,
+ popoverRect,
+ orientation
+ );
+ const popoverLeft = this.calculateLeft(
+ targetRect,
+ editorRect,
+ popoverRect,
+ orientation
+ );
+ let targetMid;
+ if (orientation === "right") {
+ targetMid = {
+ x: -14,
+ y: targetRect.top - top - 2,
+ };
+ } else {
+ targetMid = {
+ x: targetRect.left - popoverLeft + targetRect.width / 2 - 8,
+ y: 0,
+ };
+ }
+
+ return {
+ left: popoverLeft,
+ top,
+ orientation,
+ targetMid,
+ };
+ }
+
+ getTooltipCoords() {
+ if (!this.$tooltip || !this.props.editorRef) {
+ return null;
+ }
+ const tooltip = this.$tooltip;
+ const editor = this.props.editorRef;
+ const tooltipRect = tooltip.getBoundingClientRect();
+ const editorRect = editor.getBoundingClientRect();
+ const targetRect = this.props.targetPosition;
+ const left = this.calculateLeft(targetRect, editorRect, tooltipRect);
+ const enoughRoomForTooltipAbove =
+ targetRect.top - editorRect.top > tooltipRect.height;
+ const top = enoughRoomForTooltipAbove
+ ? targetRect.top - tooltipRect.height
+ : targetRect.bottom;
+
+ return {
+ left,
+ top,
+ orientation: enoughRoomForTooltipAbove ? "up" : "down",
+ targetMid: { x: 0, y: 0 },
+ };
+ }
+
+ getChildren() {
+ const { children } = this.props;
+ const { coords } = this.state;
+ const gap = this.getGap();
+
+ return coords.orientation === "up" ? [children, gap] : [gap, children];
+ }
+
+ getGap() {
+ if (this.firstRender) {
+ return div({
+ className: "gap",
+ key: "gap",
+ ref: a => (this.$gap = a),
+ });
+ }
+
+ return div(
+ {
+ className: "gap",
+ key: "gap",
+ ref: a => (this.$gap = a),
+ },
+ React.createElement(SmartGap, {
+ token: this.props.target,
+ preview: this.$tooltip || this.$popover,
+ type: this.props.type,
+ gapHeight: this.gapHeight,
+ coords: this.state.coords,
+ offset: this.$gap.getBoundingClientRect().left,
+ })
+ );
+ }
+
+ getPopoverArrow(orientation, left, top) {
+ let arrowProps = {};
+
+ if (orientation === "up") {
+ arrowProps = { orientation: "down", bottom: 10, left };
+ } else if (orientation === "down") {
+ arrowProps = { orientation: "up", top: -2, left };
+ } else {
+ arrowProps = { orientation: "left", top, left: -4 };
+ }
+ return React.createElement(BracketArrow, arrowProps);
+ }
+
+ renderPopover() {
+ const { top, left, orientation, targetMid } = this.state.coords;
+ const arrow = this.getPopoverArrow(orientation, targetMid.x, targetMid.y);
+ return div(
+ {
+ className: classnames("popover", `orientation-${orientation}`, {
+ up: orientation === "up",
+ }),
+ style: {
+ top,
+ left,
+ },
+ ref: c => (this.$popover = c),
+ },
+ arrow,
+ this.getChildren()
+ );
+ }
+
+ renderTooltip() {
+ const { top, left, orientation } = this.state.coords;
+ return div(
+ {
+ className: `tooltip orientation-${orientation}`,
+ style: {
+ top,
+ left,
+ },
+ ref: c => (this.$tooltip = c),
+ },
+ this.getChildren()
+ );
+ }
+
+ render() {
+ const { type } = this.props;
+
+ if (type === "tooltip") {
+ return this.renderTooltip();
+ }
+
+ return this.renderPopover();
+ }
+}
+
+export default Popover;
diff --git a/devtools/client/debugger/src/components/shared/PreviewFunction.css b/devtools/client/debugger/src/components/shared/PreviewFunction.css
new file mode 100644
index 0000000000..bff9ce25a2
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/PreviewFunction.css
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.function-signature {
+ align-self: center;
+}
+
+.function-signature .function-name {
+ color: var(--theme-highlight-blue);
+}
+
+.function-signature .param {
+ color: var(--theme-highlight-red);
+}
+
+.function-signature .paren {
+ color: var(--object-color);
+}
+
+.function-signature .comma {
+ color: var(--object-color);
+}
diff --git a/devtools/client/debugger/src/components/shared/PreviewFunction.js b/devtools/client/debugger/src/components/shared/PreviewFunction.js
new file mode 100644
index 0000000000..1a6d164cdf
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/PreviewFunction.js
@@ -0,0 +1,108 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "devtools/client/shared/vendor/react";
+import {
+ span,
+ button,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { formatDisplayName } from "../../utils/pause/frames/index";
+
+const IGNORED_SOURCE_URLS = ["debugger eval code"];
+
+export default class PreviewFunction extends Component {
+ static get propTypes() {
+ return {
+ func: PropTypes.object.isRequired,
+ };
+ }
+
+ renderFunctionName(func) {
+ const { l10n } = this.context;
+ const name = formatDisplayName(func, undefined, l10n);
+ return span(
+ {
+ className: "function-name",
+ },
+ name
+ );
+ }
+
+ renderParams(func) {
+ const { parameterNames = [] } = func;
+
+ return parameterNames
+ .filter(Boolean)
+ .map((param, i, arr) => {
+ const elements = [
+ span(
+ {
+ className: "param",
+ key: param,
+ },
+ param
+ ),
+ ];
+ // if this isn't the last param, add a comma
+ if (i !== arr.length - 1) {
+ elements.push(
+ span(
+ {
+ className: "delimiter",
+ key: i,
+ },
+ ", "
+ )
+ );
+ }
+ return elements;
+ })
+ .flat();
+ }
+
+ jumpToDefinitionButton(func) {
+ const { location } = func;
+
+ if (!location?.url || IGNORED_SOURCE_URLS.includes(location.url)) {
+ return null;
+ }
+
+ const lastIndex = location.url.lastIndexOf("/");
+ return button({
+ className: "jump-definition",
+ draggable: "false",
+ title: `${location.url.slice(lastIndex + 1)}:${location.line}`,
+ });
+ }
+
+ render() {
+ const { func } = this.props;
+ return span(
+ {
+ className: "function-signature",
+ },
+ this.renderFunctionName(func),
+ span(
+ {
+ className: "paren",
+ },
+ "("
+ ),
+ this.renderParams(func),
+ span(
+ {
+ className: "paren",
+ },
+ ")"
+ ),
+ this.jumpToDefinitionButton(func)
+ );
+ }
+}
+
+PreviewFunction.contextTypes = {
+ l10n: PropTypes.object,
+};
diff --git a/devtools/client/debugger/src/components/shared/ResultList.css b/devtools/client/debugger/src/components/shared/ResultList.css
new file mode 100644
index 0000000000..037c3497d3
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/ResultList.css
@@ -0,0 +1,131 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.result-list {
+ list-style: none;
+ margin: 0px;
+ padding: 0px;
+ overflow: auto;
+ width: 100%;
+ background: var(--theme-body-background);
+}
+
+.result-list * {
+ user-select: none;
+}
+
+.result-list li {
+ color: var(--theme-body-color);
+ padding: 4px 8px;
+ display: flex;
+}
+
+.result-list.big li {
+ flex-direction: row;
+ align-items: center;
+ padding: 6px 8px;
+ font-size: 12px;
+ line-height: 16px;
+}
+
+.result-list.small li {
+ justify-content: space-between;
+}
+
+.result-list li:hover {
+ background: var(--theme-tab-toolbar-background);
+}
+
+.theme-dark .result-list li:hover {
+ background: var(--grey-70);
+}
+
+.result-list li.selected {
+ background: var(--theme-accordion-header-background);
+}
+
+.result-list.small li.selected {
+ background-color: var(--theme-selection-background);
+ color: white;
+}
+
+.result-list li .result-item-icon {
+ background-color: var(--theme-icon-dimmed-color);
+}
+
+.result-list li .icon {
+ align-self: center;
+ margin-inline-end: 14px;
+ margin-inline-start: 4px;
+}
+
+.result-list .result-item-icon {
+ display: block;
+}
+
+.result-list .selected .result-item-icon {
+ background-color: var(--theme-selection-color);
+}
+
+.result-list li .title {
+ word-break: break-all;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/
+ color: var(--grey-90);
+}
+
+.theme-dark .result-list li .title {
+ /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/
+ color: var(--grey-30);
+}
+
+.result-list li.selected .title {
+ color: white;
+}
+
+.result-list.big li.selected {
+ background-color: var(--theme-selection-background);
+ color: white;
+}
+
+.result-list.big li.selected .subtitle {
+ color: white;
+}
+
+.result-list.big li.selected .subtitle .highlight {
+ color: white;
+ font-weight: bold;
+}
+
+.result-list.big li .subtitle {
+ word-break: break-all;
+ /** https://searchfox.org/mozilla-central/source/devtools/client/themes/variables.css **/
+ color: var(--grey-40);
+ margin-left: 15px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.theme-dark .result-list.big li.selected .subtitle {
+ color: white;
+}
+
+.theme-dark .result-list.big li .subtitle {
+ color: var(--theme-text-color-inactive);
+}
+
+.search-bar .result-list li.selected .subtitle {
+ color: white;
+}
+
+.search-bar .result-list {
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.theme-dark .result-list {
+ background-color: var(--theme-body-background);
+}
diff --git a/devtools/client/debugger/src/components/shared/ResultList.js b/devtools/client/debugger/src/components/shared/ResultList.js
new file mode 100644
index 0000000000..6b29de51f4
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/ResultList.js
@@ -0,0 +1,102 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import { li, div, ul } from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import AccessibleImage from "./AccessibleImage";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+
+import { scrollList } from "../../utils/result-list";
+
+export default class ResultList extends Component {
+ static defaultProps = {
+ size: "small",
+ role: "listbox",
+ };
+
+ static get propTypes() {
+ return {
+ items: PropTypes.array.isRequired,
+ role: PropTypes.oneOf(["listbox"]),
+ selectItem: PropTypes.func.isRequired,
+ selected: PropTypes.number.isRequired,
+ size: PropTypes.oneOf(["big", "small"]),
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.ref = React.createRef();
+ }
+
+ componentDidUpdate() {
+ if (this.ref.current.childNodes) {
+ scrollList(this.ref.current.childNodes, this.props.selected);
+ }
+ }
+
+ renderListItem = (item, index) => {
+ if (item.value === "/" && item.title === "") {
+ item.title = "(index)";
+ }
+
+ const { selectItem, selected } = this.props;
+ const props = {
+ onClick: event => selectItem(event, item, index),
+ key: `${item.id}${item.value}${index}`,
+ title: item.value,
+ "aria-labelledby": `${item.id}-title`,
+ "aria-describedby": `${item.id}-subtitle`,
+ role: "option",
+ className: classnames("result-item", {
+ selected: index === selected,
+ }),
+ };
+
+ return li(
+ props,
+ item.icon &&
+ div(
+ {
+ className: "icon",
+ },
+ React.createElement(AccessibleImage, {
+ className: item.icon,
+ })
+ ),
+ div(
+ {
+ id: `${item.id}-title`,
+ className: "title",
+ },
+ item.title
+ ),
+ item.subtitle != item.title
+ ? div(
+ {
+ id: `${item.id}-subtitle`,
+ className: "subtitle",
+ },
+ item.subtitle
+ )
+ : null
+ );
+ };
+ render() {
+ const { size, items, role } = this.props;
+ return ul(
+ {
+ ref: this.ref,
+ className: classnames("result-list", size),
+ id: "result-list",
+ role: role,
+ "aria-live": "polite",
+ },
+ items.map(this.renderListItem)
+ );
+ }
+}
diff --git a/devtools/client/debugger/src/components/shared/SearchInput.css b/devtools/client/debugger/src/components/shared/SearchInput.css
new file mode 100644
index 0000000000..4a5ee85ed3
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SearchInput.css
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.search-outline {
+ border: 1px solid var(--theme-toolbar-background);
+ border-bottom: 1px solid var(--theme-splitter-color);
+ transition: border-color 200ms ease-in-out;
+ display: flex;
+ flex-direction: column;
+}
+
+.search-field {
+ position: relative;
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ min-height: 24px;
+ width: 100%;
+}
+
+.search-field .img.search {
+ --icon-mask-size: 12px;
+ --icon-inset-inline-start: 6px;
+ position: absolute;
+ z-index: 1;
+ top: calc(50% - 8px);
+ mask-size: var(--icon-mask-size);
+ background-color: var(--theme-icon-dimmed-color);
+ pointer-events: none;
+}
+
+.search-field.big .img.search {
+ --icon-mask-size: 16px;
+ --icon-inset-inline-start: 12px;
+}
+
+[dir="ltr"] .search-field .img.search {
+ left: var(--icon-inset-inline-start);
+}
+
+[dir="rtl"] .search-field .img.search {
+ right: var(--icon-inset-inline-start);
+}
+
+.search-field .img.loader {
+ width: 24px;
+ height: 24px;
+ margin-inline-end: 4px;
+}
+
+.search-field input {
+ align-self: stretch;
+ flex-grow: 1;
+ height: 24px;
+ width: 40px;
+ border: none;
+ padding: 4px;
+ padding-inline-start: 28px;
+ line-height: 16px;
+ font-family: inherit;
+ font-size: inherit;
+ color: var(--theme-body-color);
+ background-color: transparent;
+ outline-offset: -1px;
+
+ &:focus-visible {
+ /* Don't show the box-shadow focus indicator, only keep the outline, otherwise the
+ shadow overlap the first item in the result list */
+ box-shadow: none;
+ }
+}
+
+.exclude-patterns-field {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ flex-shrink: 0;
+ min-height: 24px;
+ width: 100%;
+ border-top: 1px solid var(--theme-splitter-color);
+ margin-top: 1px;
+ outline-offset: -1px;
+}
+
+.exclude-patterns-field label {
+ padding-inline-start: 8px;
+ padding-top: 5px;
+ padding-bottom: 3px;
+ align-self: stretch;
+ background-color: var(--theme-accordion-header-background);
+ font-size: 12px;
+}
+
+.exclude-patterns-field input {
+ align-self: stretch;
+ height: 24px;
+ border: none;
+ padding-top: 14px;
+ padding-bottom: 14px;
+ padding-inline-start: 10px;
+ line-height: 16px;
+ font-family: inherit;
+ font-size: inherit;
+ color: var(--theme-body-color);
+ background-color: transparent;
+ border-top: 1px solid var(--theme-splitter-color);
+ min-height: 24px;
+ outline-offset: -1px;
+}
+
+.exclude-patterns-field input::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
+
+.search-field.big input {
+ height: 40px;
+ padding-top: 10px;
+ padding-bottom: 10px;
+ padding-inline-start: 40px;
+ font-size: 14px;
+ line-height: 20px;
+}
+
+.search-field input::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
+
+.search-field-summary {
+ align-self: center;
+ padding: 2px 4px;
+ white-space: nowrap;
+ text-align: center;
+ user-select: none;
+ color: var(--theme-text-color-alt);
+ /* Avoid layout jumps when we increment the result count quickly. With tabular
+ numbers, layout will only jump between 9 and 10, 99 and 100, etc. */
+ font-variant-numeric: tabular-nums;
+}
+
+.search-field.big .search-field-summary {
+ margin-inline-end: 4px;
+}
+
+.search-field .search-nav-buttons {
+ display: flex;
+ user-select: none;
+}
+
+.search-field .search-nav-buttons .nav-btn {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ padding: 4px;
+ background: transparent;
+ outline-offset: -2px;
+}
+
+.search-field .search-nav-buttons .nav-btn:hover {
+ background-color: var(--theme-toolbar-background-hover);
+}
+
+.search-field .close-btn {
+ margin-inline-end: 4px;
+}
+
+.search-field.big .close-btn {
+ margin-inline-end: 8px;
+}
+
+.search-field .close-btn::-moz-focus-inner {
+ border: none;
+}
+
+.search-buttons-bar .pipe-divider {
+ flex: none;
+ align-self: stretch;
+ width: 1px;
+ vertical-align: middle;
+ margin: 4px;
+ background-color: var(--theme-splitter-color);
+}
+
+.search-buttons-bar * {
+ user-select: none;
+}
+
+.search-buttons-bar {
+ display: flex;
+ flex-shrink: 0;
+ justify-content: flex-end;
+ align-items: center;
+ padding: 0;
+}
+
+.search-buttons-bar .search-type-toggles {
+ display: flex;
+ align-items: center;
+ max-width: 68%;
+}
+
+.search-buttons-bar .search-type-name {
+ margin: 0 4px;
+ border: none;
+ background: transparent;
+ color: var(--theme-comment);
+}
+
+.search-buttons-bar .search-type-toggles .search-type-btn.active {
+ color: var(--theme-selection-background);
+}
+
+.theme-dark .search-buttons-bar .search-type-toggles .search-type-btn.active {
+ color: white;
+}
+
+.search-buttons-bar .close-btn {
+ margin-inline-end: 3px;
+}
diff --git a/devtools/client/debugger/src/components/shared/SearchInput.js b/devtools/client/debugger/src/components/shared/SearchInput.js
new file mode 100644
index 0000000000..18f6ffbebb
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SearchInput.js
@@ -0,0 +1,362 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "devtools/client/shared/vendor/react";
+import {
+ button,
+ div,
+ label,
+ input,
+ span,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+import { connect } from "devtools/client/shared/vendor/react-redux";
+import { CloseButton } from "./Button/index";
+
+import AccessibleImage from "./AccessibleImage";
+import actions from "../../actions/index";
+import { getSearchOptions } from "../../selectors/index";
+
+const classnames = require("resource://devtools/client/shared/classnames.js");
+const SearchModifiers = require("resource://devtools/client/shared/components/SearchModifiers.js");
+
+const arrowBtn = (onClick, type, className, tooltip) => {
+ const props = {
+ className,
+ key: type,
+ onClick,
+ title: tooltip,
+ type,
+ };
+ return button(
+ props,
+ React.createElement(AccessibleImage, {
+ className: type,
+ })
+ );
+};
+
+export class SearchInput extends Component {
+ static defaultProps = {
+ expanded: false,
+ hasPrefix: false,
+ selectedItemId: "",
+ size: "",
+ showClose: true,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ history: [],
+ excludePatterns: props.searchOptions.excludePatterns,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ count: PropTypes.number.isRequired,
+ expanded: PropTypes.bool.isRequired,
+ handleClose: PropTypes.func,
+ handleNext: PropTypes.func,
+ handlePrev: PropTypes.func,
+ hasPrefix: PropTypes.bool.isRequired,
+ isLoading: PropTypes.bool.isRequired,
+ onBlur: PropTypes.func,
+ onChange: PropTypes.func,
+ onFocus: PropTypes.func,
+ onHistoryScroll: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onKeyUp: PropTypes.func,
+ placeholder: PropTypes.string,
+ query: PropTypes.string,
+ selectedItemId: PropTypes.string,
+ shouldFocus: PropTypes.bool,
+ showClose: PropTypes.bool.isRequired,
+ showExcludePatterns: PropTypes.bool.isRequired,
+ excludePatternsLabel: PropTypes.string,
+ excludePatternsPlaceholder: PropTypes.string,
+ showErrorEmoji: PropTypes.bool.isRequired,
+ size: PropTypes.string,
+ summaryMsg: PropTypes.string,
+ searchKey: PropTypes.string.isRequired,
+ searchOptions: PropTypes.object,
+ setSearchOptions: PropTypes.func,
+ showSearchModifiers: PropTypes.bool.isRequired,
+ onToggleSearchModifier: PropTypes.func,
+ };
+ }
+
+ componentDidMount() {
+ this.setFocus();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.shouldFocus && !prevProps.shouldFocus) {
+ this.setFocus();
+ }
+ }
+
+ setFocus() {
+ if (this.$input) {
+ const _input = this.$input;
+ _input.focus();
+
+ if (!_input.value) {
+ return;
+ }
+
+ // omit prefix @:# from being selected
+ const selectStartPos = this.props.hasPrefix ? 1 : 0;
+ _input.setSelectionRange(selectStartPos, _input.value.length + 1);
+ }
+ }
+
+ renderArrowButtons() {
+ const { handleNext, handlePrev } = this.props;
+
+ return [
+ arrowBtn(
+ handlePrev,
+ "arrow-up",
+ classnames("nav-btn", "prev"),
+ L10N.getFormatStr("editor.searchResults.prevResult")
+ ),
+ arrowBtn(
+ handleNext,
+ "arrow-down",
+ classnames("nav-btn", "next"),
+ L10N.getFormatStr("editor.searchResults.nextResult")
+ ),
+ ];
+ }
+
+ onFocus = e => {
+ const { onFocus } = this.props;
+
+ if (onFocus) {
+ onFocus(e);
+ }
+ };
+
+ onBlur = e => {
+ const { onBlur } = this.props;
+
+ if (onBlur) {
+ onBlur(e);
+ }
+ };
+
+ onKeyDown = e => {
+ const { onHistoryScroll, onKeyDown } = this.props;
+ if (!onHistoryScroll) {
+ onKeyDown(e);
+ return;
+ }
+
+ const inputValue = e.target.value;
+ const { history } = this.state;
+ const currentHistoryIndex = history.indexOf(inputValue);
+
+ if (e.key === "Enter") {
+ this.saveEnteredTerm(inputValue);
+ onKeyDown(e);
+ return;
+ }
+
+ if (e.key === "ArrowUp") {
+ const previous =
+ currentHistoryIndex > -1 ? currentHistoryIndex - 1 : history.length - 1;
+ const previousInHistory = history[previous];
+ if (previousInHistory) {
+ e.preventDefault();
+ onHistoryScroll(previousInHistory);
+ }
+ return;
+ }
+
+ if (e.key === "ArrowDown") {
+ const next = currentHistoryIndex + 1;
+ const nextInHistory = history[next];
+ if (nextInHistory) {
+ onHistoryScroll(nextInHistory);
+ }
+ }
+ };
+
+ onExcludeKeyDown = e => {
+ if (e.key === "Enter") {
+ this.props.setSearchOptions(this.props.searchKey, {
+ excludePatterns: this.state.excludePatterns,
+ });
+ this.props.onKeyDown(e);
+ }
+ };
+
+ saveEnteredTerm(query) {
+ const { history } = this.state;
+ const previousIndex = history.indexOf(query);
+ if (previousIndex !== -1) {
+ history.splice(previousIndex, 1);
+ }
+ history.push(query);
+ this.setState({ history });
+ }
+
+ renderSummaryMsg() {
+ const { summaryMsg } = this.props;
+
+ if (!summaryMsg) {
+ return null;
+ }
+ return div(
+ {
+ className: "search-field-summary",
+ },
+ summaryMsg
+ );
+ }
+
+ renderSpinner() {
+ const { isLoading } = this.props;
+ if (!isLoading) {
+ return null;
+ }
+ return React.createElement(AccessibleImage, {
+ className: "loader spin",
+ });
+ }
+
+ renderNav() {
+ const { count, handleNext, handlePrev } = this.props;
+ if ((!handleNext && !handlePrev) || !count || count == 1) {
+ return null;
+ }
+ return div(
+ {
+ className: "search-nav-buttons",
+ },
+ this.renderArrowButtons()
+ );
+ }
+
+ renderSearchModifiers() {
+ if (!this.props.showSearchModifiers) {
+ return null;
+ }
+ return React.createElement(SearchModifiers, {
+ modifiers: this.props.searchOptions,
+ onToggleSearchModifier: updatedOptions => {
+ this.props.setSearchOptions(this.props.searchKey, updatedOptions);
+ this.props.onToggleSearchModifier();
+ },
+ });
+ }
+
+ renderExcludePatterns() {
+ if (!this.props.showExcludePatterns) {
+ return null;
+ }
+ return div(
+ {
+ className: classnames("exclude-patterns-field", this.props.size),
+ },
+ label(null, this.props.excludePatternsLabel),
+ input({
+ placeholder: this.props.excludePatternsPlaceholder,
+ value: this.state.excludePatterns,
+ onKeyDown: this.onExcludeKeyDown,
+ onChange: e =>
+ this.setState({
+ excludePatterns: e.target.value,
+ }),
+ })
+ );
+ }
+
+ renderClose() {
+ if (!this.props.showClose) {
+ return null;
+ }
+ return React.createElement(
+ React.Fragment,
+ null,
+ span({
+ className: "pipe-divider",
+ }),
+ React.createElement(CloseButton, {
+ handleClick: this.props.handleClose,
+ buttonClass: this.props.size,
+ })
+ );
+ }
+
+ render() {
+ const {
+ expanded,
+ onChange,
+ onKeyUp,
+ placeholder,
+ query,
+ selectedItemId,
+ showErrorEmoji,
+ size,
+ } = this.props;
+
+ const inputProps = {
+ className: classnames({
+ empty: showErrorEmoji,
+ }),
+ onChange,
+ onKeyDown: e => this.onKeyDown(e),
+ onKeyUp,
+ onFocus: e => this.onFocus(e),
+ onBlur: e => this.onBlur(e),
+ "aria-autocomplete": "list",
+ "aria-controls": "result-list",
+ "aria-activedescendant":
+ expanded && selectedItemId ? `${selectedItemId}-title` : "",
+ placeholder,
+ value: query,
+ spellCheck: false,
+ ref: c => (this.$input = c),
+ };
+ return div(
+ {
+ className: "search-outline",
+ },
+ div(
+ {
+ className: classnames("search-field", size),
+ role: "combobox",
+ "aria-haspopup": "listbox",
+ "aria-owns": "result-list",
+ "aria-expanded": expanded,
+ },
+ React.createElement(AccessibleImage, {
+ className: "search",
+ }),
+ input(inputProps),
+ this.renderSpinner(),
+ this.renderSummaryMsg(),
+ this.renderNav(),
+ div(
+ {
+ className: "search-buttons-bar",
+ },
+ this.renderSearchModifiers(),
+ this.renderClose()
+ )
+ ),
+ this.renderExcludePatterns()
+ );
+ }
+}
+const mapStateToProps = (state, props) => ({
+ searchOptions: getSearchOptions(state, props.searchKey),
+});
+
+export default connect(mapStateToProps, {
+ setSearchOptions: actions.setSearchOptions,
+})(SearchInput);
diff --git a/devtools/client/debugger/src/components/shared/SmartGap.js b/devtools/client/debugger/src/components/shared/SmartGap.js
new file mode 100644
index 0000000000..d76d018987
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SmartGap.js
@@ -0,0 +1,170 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ svg,
+ polygon,
+} from "devtools/client/shared/vendor/react-dom-factories";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+function shorten(coordinates) {
+ // In cases where the token is wider than the preview, the smartGap
+ // gets distorted. This shortens the coordinate array so that the smartGap
+ // is only touching 2 corners of the token (instead of all 4 corners)
+ coordinates.splice(0, 2);
+ coordinates.splice(4, 2);
+ return coordinates;
+}
+
+function getSmartGapCoordinates(
+ preview,
+ token,
+ offset,
+ orientation,
+ gapHeight,
+ coords
+) {
+ if (orientation === "up") {
+ const coordinates = [
+ token.left - coords.left + offset,
+ token.top + token.height - (coords.top + preview.height) + gapHeight,
+ 0,
+ 0,
+ preview.width + offset,
+ 0,
+ token.left + token.width - coords.left + offset,
+ token.top + token.height - (coords.top + preview.height) + gapHeight,
+ token.left + token.width - coords.left + offset,
+ token.top - (coords.top + preview.height) + gapHeight,
+ token.left - coords.left + offset,
+ token.top - (coords.top + preview.height) + gapHeight,
+ ];
+ return preview.width > token.width ? coordinates : shorten(coordinates);
+ }
+ if (orientation === "down") {
+ const coordinates = [
+ token.left + token.width - (coords.left + preview.top) + offset,
+ 0,
+ preview.width + offset,
+ coords.top - token.top + gapHeight,
+ 0,
+ coords.top - token.top + gapHeight,
+ token.left - (coords.left + preview.top) + offset,
+ 0,
+ token.left - (coords.left + preview.top) + offset,
+ token.height,
+ token.left + token.width - (coords.left + preview.top) + offset,
+ token.height,
+ ];
+ return preview.width > token.width ? coordinates : shorten(coordinates);
+ }
+ return [
+ 0,
+ token.top - coords.top,
+ gapHeight + token.width,
+ 0,
+ gapHeight + token.width,
+ preview.height - gapHeight,
+ 0,
+ token.top + token.height - coords.top,
+ token.width,
+ token.top + token.height - coords.top,
+ token.width,
+ token.top - coords.top,
+ ];
+}
+
+function getSmartGapDimensions(
+ previewRect,
+ tokenRect,
+ offset,
+ orientation,
+ gapHeight,
+ coords
+) {
+ if (orientation === "up") {
+ return {
+ height:
+ tokenRect.top +
+ tokenRect.height -
+ coords.top -
+ previewRect.height +
+ gapHeight,
+ width: Math.max(previewRect.width, tokenRect.width) + offset,
+ };
+ }
+ if (orientation === "down") {
+ return {
+ height: coords.top - tokenRect.top + gapHeight,
+ width: Math.max(previewRect.width, tokenRect.width) + offset,
+ };
+ }
+ return {
+ height: previewRect.height - gapHeight,
+ width: coords.left - tokenRect.left + gapHeight,
+ };
+}
+
+export default function SmartGap({
+ token,
+ preview,
+ type,
+ gapHeight,
+ coords,
+ offset,
+}) {
+ const tokenRect = token.getBoundingClientRect();
+ const previewRect = preview.getBoundingClientRect();
+ const { orientation } = coords;
+ let optionalMarginLeft, optionalMarginTop;
+
+ if (orientation === "down") {
+ optionalMarginTop = -tokenRect.height;
+ } else if (orientation === "right") {
+ optionalMarginLeft = -tokenRect.width;
+ }
+
+ const { height, width } = getSmartGapDimensions(
+ previewRect,
+ tokenRect,
+ -offset,
+ orientation,
+ gapHeight,
+ coords
+ );
+ const coordinates = getSmartGapCoordinates(
+ previewRect,
+ tokenRect,
+ -offset,
+ orientation,
+ gapHeight,
+ coords
+ );
+ return svg(
+ {
+ version: "1.1",
+ xmlns: "http://www.w3.org/2000/svg",
+ style: {
+ height,
+ width,
+ position: "absolute",
+ marginLeft: optionalMarginLeft,
+ marginTop: optionalMarginTop,
+ },
+ },
+ polygon({
+ points: coordinates,
+ fill: "transparent",
+ })
+ );
+}
+
+SmartGap.propTypes = {
+ coords: PropTypes.object.isRequired,
+ gapHeight: PropTypes.number.isRequired,
+ offset: PropTypes.number.isRequired,
+ preview: PropTypes.object.isRequired,
+ token: PropTypes.object.isRequired,
+ type: PropTypes.oneOf(["popover", "tooltip"]).isRequired,
+};
diff --git a/devtools/client/debugger/src/components/shared/SourceIcon.css b/devtools/client/debugger/src/components/shared/SourceIcon.css
new file mode 100644
index 0000000000..0b9bf3e79e
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SourceIcon.css
@@ -0,0 +1,176 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+/**
+ * Variant of AccessibleImage used in sources list and tabs.
+ * Define the different source type / framework / library icons here.
+ */
+
+.source-icon {
+ margin-inline-end: 4px;
+}
+
+/* Icons for frameworks and libs */
+
+.img.aframe {
+ background-image: url(chrome://devtools/content/debugger/images/sources/aframe.svg);
+ background-color: transparent !important;
+}
+
+.img.angular {
+ background-image: url(chrome://devtools/content/debugger/images/sources/angular.svg);
+ background-color: transparent !important;
+}
+
+.img.babel {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/babel.svg);
+}
+
+.img.backbone {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/backbone.svg);
+}
+
+.img.choo {
+ background-image: url(chrome://devtools/content/debugger/images/sources/choo.svg);
+ background-color: transparent !important;
+}
+
+.img.coffeescript {
+ background-image: url(chrome://devtools/content/debugger/images/sources/coffeescript.svg);
+ background-color: transparent !important;
+ fill: var(--theme-icon-color);
+ -moz-context-properties: fill;
+}
+
+.img.dojo {
+ background-image: url(chrome://devtools/content/debugger/images/sources/dojo.svg);
+ background-color: transparent !important;
+}
+
+.img.ember {
+ background-image: url(chrome://devtools/content/debugger/images/sources/ember.svg);
+ background-color: transparent !important;
+}
+
+.img.express {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/express.svg);
+}
+
+.img.extension {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/extension.svg);
+}
+
+.img.immutable {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/immutable.svg);
+}
+
+.img.javascript {
+ background-image: url(chrome://devtools/content/debugger/images/sources/javascript.svg);
+ background-size: 14px 14px;
+ background-color: transparent !important;
+ fill: var(--theme-icon-color);
+ -moz-context-properties: fill;
+}
+
+.img.override::after {
+ content: "";
+ display: block;
+ height: 5px;
+ width: 5px;
+ background-color: var(--purple-30);
+ border-radius: 100%;
+ outline: 1px solid var(--theme-sidebar-background);
+ translate: 12px 10px;
+}
+
+.node.focused .img.override::after {
+ outline-color: var(--theme-selection-background);
+}
+
+.img.jquery {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/jquery.svg);
+}
+
+.img.lodash {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/lodash.svg);
+}
+
+.img.marko {
+ background-image: url(chrome://devtools/content/debugger/images/sources/marko.svg);
+ background-color: transparent !important;
+}
+
+.img.mobx {
+ background-image: url(chrome://devtools/content/debugger/images/sources/mobx.svg);
+ background-color: transparent !important;
+}
+
+.img.nextjs {
+ background-image: url(chrome://devtools/content/debugger/images/sources/nextjs.svg);
+ background-color: transparent !important;
+}
+
+.img.node {
+ background-image: url(chrome://devtools/content/debugger/images/sources/node.svg);
+ background-color: transparent !important;
+}
+
+.img.nuxtjs {
+ background-image: url(chrome://devtools/content/debugger/images/sources/nuxtjs.svg);
+ background-color: transparent !important;
+}
+
+.img.preact {
+ background-image: url(chrome://devtools/content/debugger/images/sources/preact.svg);
+ background-color: transparent !important;
+}
+
+.img.pug {
+ background-image: url(chrome://devtools/content/debugger/images/sources/pug.svg);
+ background-color: transparent !important;
+}
+
+.img.react {
+ background-image: url(chrome://devtools/content/debugger/images/sources/react.svg);
+ background-color: transparent !important;
+ fill: var(--theme-highlight-bluegrey);
+ -moz-context-properties: fill;
+}
+
+.img.redux {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/redux.svg);
+}
+
+.img.rxjs {
+ background-image: url(chrome://devtools/content/debugger/images/sources/rxjs.svg);
+ background-color: transparent !important;
+}
+
+.img.sencha-extjs {
+ background-image: url(chrome://devtools/content/debugger/images/sources/sencha-extjs.svg);
+ background-color: transparent !important;
+}
+
+.img.typescript {
+ background-image: url(chrome://devtools/content/debugger/images/sources/typescript.svg);
+ background-color: transparent !important;
+ fill: var(--theme-icon-color);
+ -moz-context-properties: fill;
+}
+
+.img.underscore {
+ mask-image: url(chrome://devtools/content/debugger/images/sources/underscore.svg);
+}
+
+/* We use both 'Vue' and 'VueJS' when identifying frameworks */
+.img.vue,
+.img.vuejs {
+ background-image: url(chrome://devtools/content/debugger/images/sources/vuejs.svg);
+ background-color: transparent !important;
+}
+
+.img.webpack {
+ background-image: url(chrome://devtools/content/debugger/images/sources/webpack.svg);
+ background-color: transparent !important;
+}
diff --git a/devtools/client/debugger/src/components/shared/SourceIcon.js b/devtools/client/debugger/src/components/shared/SourceIcon.js
new file mode 100644
index 0000000000..b2a7486bd6
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SourceIcon.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "devtools/client/shared/vendor/react";
+import PropTypes from "devtools/client/shared/vendor/react-prop-types";
+
+import { connect } from "devtools/client/shared/vendor/react-redux";
+
+import AccessibleImage from "./AccessibleImage";
+
+import { getSourceClassnames } from "../../utils/source";
+import {
+ getSymbols,
+ isSourceBlackBoxed,
+ hasPrettyTab,
+} from "../../selectors/index";
+
+class SourceIcon extends PureComponent {
+ static get propTypes() {
+ return {
+ modifier: PropTypes.func.isRequired,
+ location: PropTypes.object.isRequired,
+ iconClass: PropTypes.string,
+ forTab: PropTypes.bool,
+ };
+ }
+
+ render() {
+ const { modifier } = this.props;
+ let { iconClass } = this.props;
+
+ if (modifier) {
+ const modified = modifier(iconClass);
+ if (!modified) {
+ return null;
+ }
+ iconClass = modified;
+ }
+ return React.createElement(AccessibleImage, {
+ className: `source-icon ${iconClass}`,
+ });
+ }
+}
+
+export default connect((state, props) => {
+ const { forTab, location } = props;
+ // BreakpointHeading sometimes spawn locations without source actor for generated sources
+ // which disallows fetching symbols. In such race condition return the default icon.
+ // (this reproduces when running browser_dbg-breakpoints-popup.js)
+ if (!location.source.isOriginal && !location.sourceActor) {
+ return "file";
+ }
+ const symbols = getSymbols(state, location);
+ const isBlackBoxed = isSourceBlackBoxed(state, location.source);
+ // For the tab icon, we don't want to show the pretty icon for the non-pretty tab
+ const hasMatchingPrettyTab = !forTab && hasPrettyTab(state, location.source);
+
+ // This is the key function that will compute the icon type,
+ // In addition to the "modifier" implemented by each callsite.
+ const iconClass = getSourceClassnames(
+ location.source,
+ symbols,
+ isBlackBoxed,
+ hasMatchingPrettyTab
+ );
+
+ return {
+ iconClass,
+ };
+})(SourceIcon);
diff --git a/devtools/client/debugger/src/components/shared/menu.css b/devtools/client/debugger/src/components/shared/menu.css
new file mode 100644
index 0000000000..37dfbc2e8f
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/menu.css
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+menupopup {
+ position: fixed;
+ z-index: 10000;
+ border: 1px solid #cccccc;
+ padding: 5px 0;
+ background: #f2f2f2;
+ border-radius: 5px;
+ color: #585858;
+ box-shadow: 0 0 4px 0 rgba(190, 190, 190, 0.8);
+ min-width: 130px;
+}
+
+menuitem {
+ display: block;
+ padding: 0 20px;
+ line-height: 20px;
+ font-weight: 500;
+ font-size: 13px;
+ user-select: none;
+}
+
+menuitem:hover {
+ background: #3780fb;
+ color: white;
+}
+
+menuitem[disabled="true"] {
+ color: #cccccc;
+}
+
+menuitem[disabled="true"]:hover {
+ background-color: transparent;
+ cursor: default;
+}
+
+menuseparator {
+ border-bottom: 1px solid #cacdd3;
+ width: 100%;
+ height: 5px;
+ display: block;
+ margin-bottom: 5px;
+}
+
+#contextmenu-mask.show {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 999;
+}
diff --git a/devtools/client/debugger/src/components/shared/moz.build b/devtools/client/debugger/src/components/shared/moz.build
new file mode 100644
index 0000000000..b30ea0ab4f
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/moz.build
@@ -0,0 +1,23 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "Button",
+]
+
+CompiledModules(
+ "AccessibleImage.js",
+ "Accordion.js",
+ "Badge.js",
+ "BracketArrow.js",
+ "Dropdown.js",
+ "Modal.js",
+ "Popover.js",
+ "PreviewFunction.js",
+ "ResultList.js",
+ "SearchInput.js",
+ "SourceIcon.js",
+ "SmartGap.js",
+)
diff --git a/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js b/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js
new file mode 100644
index 0000000000..cbe5ab12bf
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Accordion.spec.js
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import Accordion from "../Accordion";
+
+describe("Accordion", () => {
+ const testItems = [
+ {
+ header: "Test Accordion Item 1",
+ id: "accordion-item-1",
+ className: "accordion-item-1",
+ component: React.createElement("div", null),
+ opened: false,
+ onToggle: jest.fn(),
+ },
+ {
+ header: "Test Accordion Item 2",
+ id: "accordion-item-2",
+ className: "accordion-item-2",
+ component: React.createElement("div", null),
+ buttons: React.createElement("button", null),
+ opened: false,
+ onToggle: jest.fn(),
+ },
+ {
+ header: "Test Accordion Item 3",
+ id: "accordion-item-3",
+ className: "accordion-item-3",
+ component: React.createElement("div", null),
+ opened: true,
+ onToggle: jest.fn(),
+ },
+ ];
+ const wrapper = shallow(
+ React.createElement(Accordion, {
+ items: testItems,
+ })
+ );
+ it("basic render", () => expect(wrapper).toMatchSnapshot());
+ wrapper.find(".accordion-item-1 button").simulate("click");
+ it("handleClick and onToggle", () =>
+ expect(testItems[0].onToggle).toHaveBeenCalledWith(true));
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/Badge.spec.js b/devtools/client/debugger/src/components/shared/tests/Badge.spec.js
new file mode 100644
index 0000000000..a19b35a7c2
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Badge.spec.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import Badge from "../Badge";
+
+describe("Badge", () => {
+ it("render", () =>
+ expect(
+ shallow(
+ React.createElement(Badge, {
+ badgeText: 3,
+ })
+ )
+ ).toMatchSnapshot());
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js b/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js
new file mode 100644
index 0000000000..4ce9a5b5ce
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import BracketArrow from "../BracketArrow";
+
+describe("BracketArrow", () => {
+ const wrapper = shallow(
+ React.createElement(BracketArrow, {
+ orientation: "down",
+ left: 10,
+ top: 20,
+ bottom: 50,
+ })
+ );
+ it("render", () => expect(wrapper).toMatchSnapshot());
+ it("render up", () => {
+ wrapper.setProps({ orientation: null });
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js b/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js
new file mode 100644
index 0000000000..9b001ba9e5
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import Dropdown from "../Dropdown";
+
+describe("Dropdown", () => {
+ const wrapper = shallow(
+ React.createElement(Dropdown, {
+ panel: React.createElement("div", null),
+ icon: "✅",
+ })
+ );
+ it("render", () => expect(wrapper).toMatchSnapshot());
+ wrapper.find(".dropdown").simulate("click");
+ it("handle toggleDropdown", () =>
+ expect(wrapper.state().dropdownShown).toEqual(true));
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/Modal.spec.js b/devtools/client/debugger/src/components/shared/tests/Modal.spec.js
new file mode 100644
index 0000000000..58c38502e7
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Modal.spec.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import Modal from "../Modal";
+
+describe("Modal", () => {
+ it("renders", () => {
+ const wrapper = shallow(
+ React.createElement(Modal, {
+ handleClose: () => {},
+ })
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("handles close modal click", () => {
+ const handleCloseSpy = jest.fn();
+ const wrapper = shallow(
+ React.createElement(Modal, {
+ handleClose: handleCloseSpy,
+ })
+ );
+ wrapper.find(".modal-wrapper").simulate("click");
+ expect(handleCloseSpy).toHaveBeenCalled();
+ });
+
+ it("renders children", () => {
+ const wrapper = shallow(
+ React.createElement(
+ Modal,
+ {
+ handleClose: () => {},
+ },
+ React.createElement("div", {
+ className: "aChild",
+ })
+ )
+ );
+ expect(wrapper.find(".aChild")).toHaveLength(1);
+ });
+
+ it("passes additionalClass to child div class", () => {
+ const additionalClass = "testAddon";
+ const wrapper = shallow(
+ React.createElement(Modal, {
+ additionalClass,
+ handleClose: () => {},
+ })
+ );
+ expect(wrapper.find(`.modal-wrapper .${additionalClass}`)).toHaveLength(1);
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/Popover.spec.js b/devtools/client/debugger/src/components/shared/tests/Popover.spec.js
new file mode 100644
index 0000000000..7150f4afe8
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Popover.spec.js
@@ -0,0 +1,212 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { mount } from "enzyme";
+
+import Popover from "../Popover";
+
+describe("Popover", () => {
+ const onMouseLeave = jest.fn();
+ const onKeyDown = jest.fn();
+ const editorRef = {
+ getBoundingClientRect() {
+ return {
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ top: 250,
+ right: 0,
+ bottom: 0,
+ left: 20,
+ };
+ },
+ };
+
+ const targetRef = {
+ getBoundingClientRect() {
+ return {
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ top: 250,
+ right: 0,
+ bottom: 0,
+ left: 20,
+ };
+ },
+ };
+ const targetPosition = {
+ x: 100,
+ y: 200,
+ width: 300,
+ height: 300,
+ top: 50,
+ right: 0,
+ bottom: 0,
+ left: 200,
+ };
+ const popover = mount(
+ React.createElement(
+ Popover,
+ {
+ onMouseLeave: onMouseLeave,
+ onKeyDown: onKeyDown,
+ editorRef: editorRef,
+ targetPosition: targetPosition,
+ mouseout: () => {},
+ target: targetRef,
+ },
+ React.createElement("h1", null, "Poppy!")
+ )
+ );
+
+ const tooltip = mount(
+ React.createElement(
+ Popover,
+ {
+ type: "tooltip",
+ onMouseLeave: onMouseLeave,
+ onKeyDown: onKeyDown,
+ editorRef: editorRef,
+ targetPosition: targetPosition,
+ mouseout: () => {},
+ target: targetRef,
+ },
+ React.createElement("h1", null, "Toolie!")
+ )
+ );
+
+ beforeEach(() => {
+ onMouseLeave.mockClear();
+ onKeyDown.mockClear();
+ });
+
+ it("render", () => expect(popover).toMatchSnapshot());
+
+ it("render (tooltip)", () => expect(tooltip).toMatchSnapshot());
+
+ it("mount popover", () => {
+ const mountedPopover = mount(
+ React.createElement(
+ Popover,
+ {
+ onMouseLeave: onMouseLeave,
+ onKeyDown: onKeyDown,
+ editorRef: editorRef,
+ targetPosition: targetPosition,
+ mouseout: () => {},
+ target: targetRef,
+ },
+ React.createElement("h1", null, "Poppy!")
+ )
+ );
+ expect(mountedPopover).toMatchSnapshot();
+ });
+
+ it("mount tooltip", () => {
+ const mountedTooltip = mount(
+ React.createElement(
+ Popover,
+ {
+ type: "tooltip",
+ onMouseLeave: onMouseLeave,
+ onKeyDown: onKeyDown,
+ editorRef: editorRef,
+ targetPosition: targetPosition,
+ mouseout: () => {},
+ target: targetRef,
+ },
+ React.createElement("h1", null, "Toolie!")
+ )
+ );
+ expect(mountedTooltip).toMatchSnapshot();
+ });
+
+ it("tooltip normally displays above the target", () => {
+ const editor = {
+ getBoundingClientRect() {
+ return {
+ width: 500,
+ height: 500,
+ top: 0,
+ bottom: 500,
+ left: 0,
+ right: 500,
+ };
+ },
+ };
+ const target = {
+ width: 30,
+ height: 10,
+ top: 100,
+ bottom: 110,
+ left: 20,
+ right: 50,
+ };
+
+ const mountedTooltip = mount(
+ React.createElement(
+ Popover,
+ {
+ type: "tooltip",
+ onMouseLeave: onMouseLeave,
+ onKeyDown: onKeyDown,
+ editorRef: editor,
+ targetPosition: target,
+ mouseout: () => {},
+ target: targetRef,
+ },
+ React.createElement("h1", null, "Toolie!")
+ )
+ );
+
+ const toolTipTop = parseInt(mountedTooltip.getDOMNode().style.top, 10);
+ expect(toolTipTop).toBeLessThanOrEqual(target.top);
+ });
+
+ it("tooltop won't display above the target when insufficient space", () => {
+ const editor = {
+ getBoundingClientRect() {
+ return {
+ width: 100,
+ height: 100,
+ top: 0,
+ bottom: 100,
+ left: 0,
+ right: 100,
+ };
+ },
+ };
+ const target = {
+ width: 30,
+ height: 10,
+ top: 0,
+ bottom: 10,
+ left: 20,
+ right: 50,
+ };
+
+ const mountedTooltip = mount(
+ React.createElement(
+ Popover,
+ {
+ type: "tooltip",
+ onMouseLeave: onMouseLeave,
+ onKeyDown: onKeyDown,
+ editorRef: editor,
+ targetPosition: target,
+ mouseout: () => {},
+ target: targetRef,
+ },
+ React.createElement("h1", null, "Toolie!")
+ )
+ );
+
+ const toolTipTop = parseInt(mountedTooltip.getDOMNode().style.top, 10);
+ expect(toolTipTop).toBeGreaterThanOrEqual(target.bottom);
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js b/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js
new file mode 100644
index 0000000000..62f635acc1
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js
@@ -0,0 +1,130 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import PreviewFunction from "../PreviewFunction";
+
+function render(props) {
+ return shallow(React.createElement(PreviewFunction, props), {
+ context: {
+ l10n: L10N,
+ },
+ });
+}
+
+describe("PreviewFunction", () => {
+ it("should return a span", () => {
+ const item = { name: "" };
+ const returnedSpan = render({ func: item });
+ expect(returnedSpan).toMatchSnapshot();
+ expect(returnedSpan.name()).toEqual("span");
+ });
+
+ it('should return a span with a class of "function-signature"', () => {
+ const item = { name: "" };
+ const returnedSpan = render({ func: item });
+ expect(returnedSpan.hasClass("function-signature")).toBe(true);
+ });
+
+ it("should return a span with 3 children", () => {
+ const item = { name: "" };
+ const returnedSpan = render({ func: item });
+ expect(returnedSpan.children()).toHaveLength(3);
+ });
+
+ describe("function name", () => {
+ it("should be a span", () => {
+ const item = { name: "" };
+ const returnedSpan = render({ func: item });
+ expect(returnedSpan.children().first().name()).toEqual("span");
+ });
+
+ it('should have a "function-name" class', () => {
+ const item = { name: "" };
+ const returnedSpan = render({ func: item });
+ expect(returnedSpan.children().first().hasClass("function-name")).toBe(
+ true
+ );
+ });
+
+ it("should be be set to userDisplayName if defined", () => {
+ const item = {
+ name: "",
+ displayName: "chuck",
+ };
+ const returnedSpan = render({ func: item });
+ expect(returnedSpan.children().first().first().text()).toEqual("chuck");
+ });
+
+ it('should use displayName if defined & no "userDisplayName" exist', () => {
+ const item = {
+ displayName: "norris",
+ name: "last",
+ };
+ const returnedSpan = render({ func: item });
+ expect(returnedSpan.children().first().first().text()).toEqual("norris");
+ });
+
+ it('should use to name if no "userDisplayName"/"displayName" exist', () => {
+ const item = {
+ name: "last",
+ };
+ const returnedSpan = render({ func: item });
+ expect(returnedSpan.children().first().first().text()).toEqual("last");
+ });
+ });
+
+ describe("render parentheses", () => {
+ let leftParen;
+ let rightParen;
+
+ beforeAll(() => {
+ const item = { name: "" };
+ const returnedSpan = render({ func: item });
+ const children = returnedSpan.children();
+ leftParen = returnedSpan.childAt(1);
+ rightParen = returnedSpan.childAt(children.length - 1);
+ });
+
+ it("should be spans", () => {
+ expect(leftParen.name()).toEqual("span");
+ expect(rightParen.name()).toEqual("span");
+ });
+
+ it("should create a left paren", () => {
+ expect(leftParen.text()).toEqual("(");
+ });
+
+ it("should create a right paren", () => {
+ expect(rightParen.text()).toEqual(")");
+ });
+ });
+
+ describe("render parameters", () => {
+ let returnedSpan;
+ let children;
+
+ beforeAll(() => {
+ const item = {
+ name: "",
+ parameterNames: ["one", "two", "three"],
+ };
+ returnedSpan = render({ func: item });
+ children = returnedSpan.children();
+ });
+
+ it("should render spans according to the dynamic params given", () => {
+ expect(children).toHaveLength(8);
+ });
+
+ it("should render the parameters names", () => {
+ expect(returnedSpan.childAt(2).text()).toEqual("one");
+ });
+
+ it("should render the parameters commas", () => {
+ expect(returnedSpan.childAt(3).text()).toEqual(", ");
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js b/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js
new file mode 100644
index 0000000000..4cdc85fb23
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import ResultList from "../ResultList";
+
+const selectItem = jest.fn();
+const selectedIndex = 1;
+const payload = {
+ items: [
+ {
+ id: 0,
+ subtitle: "subtitle",
+ title: "title",
+ value: "value",
+ },
+ {
+ id: 1,
+ subtitle: "subtitle 1",
+ title: "title 1",
+ value: "value 1",
+ },
+ ],
+ selected: selectedIndex,
+ selectItem,
+};
+
+describe("Result list", () => {
+ it("should call onClick function", () => {
+ const wrapper = shallow(React.createElement(ResultList, payload));
+ wrapper.childAt(selectedIndex).simulate("click");
+ expect(selectItem).toHaveBeenCalled();
+ });
+
+ it("should render the component", () => {
+ const wrapper = shallow(React.createElement(ResultList, payload));
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("selected index should have 'selected class'", () => {
+ const wrapper = shallow(React.createElement(ResultList, payload));
+ const childHasClass = wrapper.childAt(selectedIndex).hasClass("selected");
+
+ expect(childHasClass).toEqual(true);
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js b/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js
new file mode 100644
index 0000000000..c4c3990771
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/SearchInput.spec.js
@@ -0,0 +1,126 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import configureStore from "redux-mock-store";
+
+import SearchInput from "../SearchInput";
+
+describe("SearchInput", () => {
+ // !! wrapper is defined outside test scope
+ // so it will keep values between tests
+ const mockStore = configureStore([]);
+ const store = mockStore({
+ ui: { mutableSearchOptions: { "foo-search": {} } },
+ });
+ const wrapper = shallow(
+ React.createElement(SearchInput, {
+ store: store,
+ query: "",
+ count: 5,
+ placeholder: "A placeholder",
+ summaryMsg: "So many results",
+ showErrorEmoji: false,
+ isLoading: false,
+ onChange: () => {},
+ onKeyDown: () => {},
+ searchKey: "foo-search",
+ showSearchModifiers: false,
+ showExcludePatterns: false,
+ showClose: true,
+ handleClose: jest.fn(),
+ setSearchOptions: jest.fn(),
+ })
+ ).dive();
+
+ it("renders", () => expect(wrapper).toMatchSnapshot());
+
+ it("shows nav buttons", () => {
+ wrapper.setProps({
+ handleNext: jest.fn(),
+ handlePrev: jest.fn(),
+ });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("shows svg error emoji", () => {
+ wrapper.setProps({ showErrorEmoji: true });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("shows svg magnifying glass", () => {
+ wrapper.setProps({ showErrorEmoji: false });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ describe("with optional onHistoryScroll", () => {
+ const searches = ["foo", "bar", "baz"];
+ const createSearch = term => ({
+ target: { value: term },
+ key: "Enter",
+ });
+
+ const scrollUp = currentTerm => ({
+ key: "ArrowUp",
+ target: { value: currentTerm },
+ preventDefault: jest.fn(),
+ });
+ const scrollDown = currentTerm => ({
+ key: "ArrowDown",
+ target: { value: currentTerm },
+ preventDefault: jest.fn(),
+ });
+
+ it("stores entered history in state", () => {
+ wrapper.setProps({
+ onHistoryScroll: jest.fn(),
+ onKeyDown: jest.fn(),
+ });
+ wrapper.find("input").simulate("keyDown", createSearch(searches[0]));
+ expect(wrapper.state().history[0]).toEqual(searches[0]);
+ });
+
+ it("stores scroll history in state", () => {
+ const onHistoryScroll = jest.fn();
+ wrapper.setProps({
+ onHistoryScroll,
+ onKeyDown: jest.fn(),
+ });
+ wrapper.find("input").simulate("keyDown", createSearch(searches[0]));
+ wrapper.find("input").simulate("keyDown", createSearch(searches[1]));
+ expect(wrapper.state().history[0]).toEqual(searches[0]);
+ expect(wrapper.state().history[1]).toEqual(searches[1]);
+ });
+
+ it("scrolls up stored history on arrow up", () => {
+ const onHistoryScroll = jest.fn();
+ wrapper.setProps({
+ onHistoryScroll,
+ onKeyDown: jest.fn(),
+ });
+ wrapper.find("input").simulate("keyDown", createSearch(searches[0]));
+ wrapper.find("input").simulate("keyDown", createSearch(searches[1]));
+ wrapper.find("input").simulate("keyDown", scrollUp(searches[1]));
+ expect(wrapper.state().history[0]).toEqual(searches[0]);
+ expect(wrapper.state().history[1]).toEqual(searches[1]);
+ expect(onHistoryScroll).toHaveBeenCalledWith(searches[0]);
+ });
+
+ it("scrolls down stored history on arrow down", () => {
+ const onHistoryScroll = jest.fn();
+ wrapper.setProps({
+ onHistoryScroll,
+ onKeyDown: jest.fn(),
+ });
+ wrapper.find("input").simulate("keyDown", createSearch(searches[0]));
+ wrapper.find("input").simulate("keyDown", createSearch(searches[1]));
+ wrapper.find("input").simulate("keyDown", createSearch(searches[2]));
+ wrapper.find("input").simulate("keyDown", scrollUp(searches[2]));
+ wrapper.find("input").simulate("keyDown", scrollUp(searches[1]));
+ wrapper.find("input").simulate("keyDown", scrollDown(searches[0]));
+ expect(onHistoryScroll.mock.calls[2][0]).toBe(searches[1]);
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap
new file mode 100644
index 0000000000..abd8f10f51
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap
@@ -0,0 +1,81 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Accordion basic render 1`] = `
+<div
+ className="accordion"
+>
+ <aside
+ aria-labelledby="accordion-item-1"
+ className="accordion-item-1"
+ key="accordion-item-1"
+ >
+ <h2
+ className="_header"
+ >
+ <button
+ aria-controls="accordion-item-1-content"
+ aria-expanded="true"
+ className="header-label"
+ id="accordion-item-1"
+ onClick={[Function]}
+ >
+ Test Accordion Item 1
+ </button>
+ </h2>
+ <div
+ className="_content"
+ id="accordion-item-1-content"
+ >
+ <div />
+ </div>
+ </aside>
+ <aside
+ aria-labelledby="accordion-item-2"
+ className="accordion-item-2"
+ key="accordion-item-2"
+ >
+ <h2
+ className="_header"
+ >
+ <button
+ aria-expanded="false"
+ className="header-label"
+ id="accordion-item-2"
+ onClick={[Function]}
+ >
+ Test Accordion Item 2
+ </button>
+ <div
+ className="header-buttons"
+ >
+ <button />
+ </div>
+ </h2>
+ </aside>
+ <aside
+ aria-labelledby="accordion-item-3"
+ className="accordion-item-3"
+ key="accordion-item-3"
+ >
+ <h2
+ className="_header"
+ >
+ <button
+ aria-controls="accordion-item-3-content"
+ aria-expanded="true"
+ className="header-label"
+ id="accordion-item-3"
+ onClick={[Function]}
+ >
+ Test Accordion Item 3
+ </button>
+ </h2>
+ <div
+ className="_content"
+ id="accordion-item-3-content"
+ >
+ <div />
+ </div>
+ </aside>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap
new file mode 100644
index 0000000000..cbeeeaa3f2
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Badge.spec.js.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Badge render 1`] = `
+<span
+ className="badge text-white text-center"
+>
+ 3
+</span>
+`;
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap
new file mode 100644
index 0000000000..5078cebc9e
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/BracketArrow.spec.js.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`BracketArrow render 1`] = `
+<div
+ className="bracket-arrow down"
+ style={
+ Object {
+ "bottom": 50,
+ "left": 10,
+ "top": 20,
+ }
+ }
+/>
+`;
+
+exports[`BracketArrow render up 1`] = `
+<div
+ className="bracket-arrow up"
+ style={
+ Object {
+ "bottom": 50,
+ "left": 10,
+ "top": 20,
+ }
+ }
+/>
+`;
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap
new file mode 100644
index 0000000000..fd60784327
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Dropdown.spec.js.snap
@@ -0,0 +1,34 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Dropdown render 1`] = `
+<div
+ className="dropdown-block"
+>
+ <div
+ className="dropdown"
+ onClick={[Function]}
+ style={
+ Object {
+ "display": "block",
+ }
+ }
+ >
+ <div />
+ </div>
+ <button
+ className="dropdown-button"
+ onClick={[Function]}
+ >
+ ✅
+ </button>
+ <div
+ className="dropdown-mask"
+ onClick={[Function]}
+ style={
+ Object {
+ "display": "block",
+ }
+ }
+ />
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap
new file mode 100644
index 0000000000..c8534c4032
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Modal.spec.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Modal renders 1`] = `
+<div
+ className="modal-wrapper"
+ onClick={[Function]}
+>
+ <div
+ className="modal"
+ onClick={[Function]}
+ />
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap
new file mode 100644
index 0000000000..1c3589a6f8
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Popover.spec.js.snap
@@ -0,0 +1,549 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Popover mount popover 1`] = `
+<Popover
+ editorRef={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ mouseout={[Function]}
+ onKeyDown={[MockFunction]}
+ onMouseLeave={[MockFunction]}
+ target={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ targetPosition={
+ Object {
+ "bottom": 0,
+ "height": 300,
+ "left": 200,
+ "right": 0,
+ "top": 50,
+ "width": 300,
+ "x": 100,
+ "y": 200,
+ }
+ }
+ type="popover"
+>
+ <div
+ className="popover orientation-right"
+ style={
+ Object {
+ "left": 500,
+ "top": -50,
+ }
+ }
+ >
+ <BracketArrow
+ left={-4}
+ orientation="left"
+ top={98}
+ >
+ <div
+ className="bracket-arrow left"
+ style={
+ Object {
+ "bottom": undefined,
+ "left": -4,
+ "top": 98,
+ }
+ }
+ />
+ </BracketArrow>
+ <div
+ className="gap"
+ key="gap"
+ >
+ <SmartGap
+ coords={
+ Object {
+ "left": 500,
+ "orientation": "right",
+ "targetMid": Object {
+ "x": -14,
+ "y": 98,
+ },
+ "top": -50,
+ }
+ }
+ gapHeight={0}
+ offset={0}
+ preview={
+ <div
+ class="popover orientation-right"
+ style="top: -50px; left: 500px;"
+ >
+ <div
+ class="bracket-arrow left"
+ style="left: -4px; top: 98px;"
+ />
+ <div
+ class="gap"
+ >
+ <svg
+ style="height: 0px; width: 480px; position: absolute; margin-left: -100px;"
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <polygon
+ fill="transparent"
+ points="0,300,100,0,100,0,0,400,100,400,100,300"
+ />
+ </svg>
+ </div>
+ <h1>
+ Poppy!
+ </h1>
+ </div>
+ }
+ token={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ type="popover"
+ >
+ <svg
+ style={
+ Object {
+ "height": 0,
+ "marginLeft": -100,
+ "marginTop": undefined,
+ "position": "absolute",
+ "width": 480,
+ }
+ }
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <polygon
+ fill="transparent"
+ points={
+ Array [
+ 0,
+ 300,
+ 100,
+ 0,
+ 100,
+ 0,
+ 0,
+ 400,
+ 100,
+ 400,
+ 100,
+ 300,
+ ]
+ }
+ />
+ </svg>
+ </SmartGap>
+ </div>
+ <h1>
+ Poppy!
+ </h1>
+ </div>
+</Popover>
+`;
+
+exports[`Popover mount tooltip 1`] = `
+<Popover
+ editorRef={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ mouseout={[Function]}
+ onKeyDown={[MockFunction]}
+ onMouseLeave={[MockFunction]}
+ target={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ targetPosition={
+ Object {
+ "bottom": 0,
+ "height": 300,
+ "left": 200,
+ "right": 0,
+ "top": 50,
+ "width": 300,
+ "x": 100,
+ "y": 200,
+ }
+ }
+ type="tooltip"
+>
+ <div
+ className="tooltip orientation-down"
+ style={
+ Object {
+ "left": -8,
+ "top": 0,
+ }
+ }
+ >
+ <div
+ className="gap"
+ key="gap"
+ >
+ <SmartGap
+ coords={
+ Object {
+ "left": -8,
+ "orientation": "down",
+ "targetMid": Object {
+ "x": 0,
+ "y": 0,
+ },
+ "top": 0,
+ }
+ }
+ gapHeight={0}
+ offset={0}
+ preview={
+ <div
+ class="tooltip orientation-down"
+ style="top: 0px; left: -8px;"
+ >
+ <div
+ class="gap"
+ >
+ <svg
+ style="height: -250px; width: 100px; position: absolute; margin-top: -100px;"
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <polygon
+ fill="transparent"
+ points="0,-250,0,-250,28,100,128,100"
+ />
+ </svg>
+ </div>
+ <h1>
+ Toolie!
+ </h1>
+ </div>
+ }
+ token={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ type="tooltip"
+ >
+ <svg
+ style={
+ Object {
+ "height": -250,
+ "marginLeft": undefined,
+ "marginTop": -100,
+ "position": "absolute",
+ "width": 100,
+ }
+ }
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <polygon
+ fill="transparent"
+ points={
+ Array [
+ 0,
+ -250,
+ 0,
+ -250,
+ 28,
+ 100,
+ 128,
+ 100,
+ ]
+ }
+ />
+ </svg>
+ </SmartGap>
+ </div>
+ <h1>
+ Toolie!
+ </h1>
+ </div>
+</Popover>
+`;
+
+exports[`Popover render (tooltip) 1`] = `
+<Popover
+ editorRef={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ mouseout={[Function]}
+ onKeyDown={[MockFunction]}
+ onMouseLeave={[MockFunction]}
+ target={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ targetPosition={
+ Object {
+ "bottom": 0,
+ "height": 300,
+ "left": 200,
+ "right": 0,
+ "top": 50,
+ "width": 300,
+ "x": 100,
+ "y": 200,
+ }
+ }
+ type="tooltip"
+>
+ <div
+ className="tooltip orientation-down"
+ style={
+ Object {
+ "left": -8,
+ "top": 0,
+ }
+ }
+ >
+ <div
+ className="gap"
+ key="gap"
+ >
+ <SmartGap
+ coords={
+ Object {
+ "left": -8,
+ "orientation": "down",
+ "targetMid": Object {
+ "x": 0,
+ "y": 0,
+ },
+ "top": 0,
+ }
+ }
+ gapHeight={0}
+ offset={0}
+ preview={
+ <div
+ class="tooltip orientation-down"
+ style="top: 0px; left: -8px;"
+ >
+ <div
+ class="gap"
+ >
+ <svg
+ style="height: -250px; width: 100px; position: absolute; margin-top: -100px;"
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <polygon
+ fill="transparent"
+ points="0,-250,0,-250,28,100,128,100"
+ />
+ </svg>
+ </div>
+ <h1>
+ Toolie!
+ </h1>
+ </div>
+ }
+ token={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ type="tooltip"
+ >
+ <svg
+ style={
+ Object {
+ "height": -250,
+ "marginLeft": undefined,
+ "marginTop": -100,
+ "position": "absolute",
+ "width": 100,
+ }
+ }
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <polygon
+ fill="transparent"
+ points={
+ Array [
+ 0,
+ -250,
+ 0,
+ -250,
+ 28,
+ 100,
+ 128,
+ 100,
+ ]
+ }
+ />
+ </svg>
+ </SmartGap>
+ </div>
+ <h1>
+ Toolie!
+ </h1>
+ </div>
+</Popover>
+`;
+
+exports[`Popover render 1`] = `
+<Popover
+ editorRef={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ mouseout={[Function]}
+ onKeyDown={[MockFunction]}
+ onMouseLeave={[MockFunction]}
+ target={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ targetPosition={
+ Object {
+ "bottom": 0,
+ "height": 300,
+ "left": 200,
+ "right": 0,
+ "top": 50,
+ "width": 300,
+ "x": 100,
+ "y": 200,
+ }
+ }
+ type="popover"
+>
+ <div
+ className="popover orientation-right"
+ style={
+ Object {
+ "left": 500,
+ "top": -50,
+ }
+ }
+ >
+ <BracketArrow
+ left={-4}
+ orientation="left"
+ top={98}
+ >
+ <div
+ className="bracket-arrow left"
+ style={
+ Object {
+ "bottom": undefined,
+ "left": -4,
+ "top": 98,
+ }
+ }
+ />
+ </BracketArrow>
+ <div
+ className="gap"
+ key="gap"
+ >
+ <SmartGap
+ coords={
+ Object {
+ "left": 500,
+ "orientation": "right",
+ "targetMid": Object {
+ "x": -14,
+ "y": 98,
+ },
+ "top": -50,
+ }
+ }
+ gapHeight={0}
+ offset={0}
+ preview={
+ <div
+ class="popover orientation-right"
+ style="top: -50px; left: 500px;"
+ >
+ <div
+ class="bracket-arrow left"
+ style="left: -4px; top: 98px;"
+ />
+ <div
+ class="gap"
+ >
+ <svg
+ style="height: 0px; width: 480px; position: absolute; margin-left: -100px;"
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <polygon
+ fill="transparent"
+ points="0,300,100,0,100,0,0,400,100,400,100,300"
+ />
+ </svg>
+ </div>
+ <h1>
+ Poppy!
+ </h1>
+ </div>
+ }
+ token={
+ Object {
+ "getBoundingClientRect": [Function],
+ }
+ }
+ type="popover"
+ >
+ <svg
+ style={
+ Object {
+ "height": 0,
+ "marginLeft": -100,
+ "marginTop": undefined,
+ "position": "absolute",
+ "width": 480,
+ }
+ }
+ version="1.1"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <polygon
+ fill="transparent"
+ points={
+ Array [
+ 0,
+ 300,
+ 100,
+ 0,
+ 100,
+ 0,
+ 0,
+ 400,
+ 100,
+ 400,
+ 100,
+ 300,
+ ]
+ }
+ />
+ </svg>
+ </SmartGap>
+ </div>
+ <h1>
+ Poppy!
+ </h1>
+ </div>
+</Popover>
+`;
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap
new file mode 100644
index 0000000000..e766bd45aa
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/PreviewFunction.spec.js.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PreviewFunction should return a span 1`] = `
+<span
+ className="function-signature"
+>
+ <span
+ className="function-name"
+ >
+ &lt;anonymous&gt;
+ </span>
+ <span
+ className="paren"
+ >
+ (
+ </span>
+ <span
+ className="paren"
+ >
+ )
+ </span>
+</span>
+`;
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap
new file mode 100644
index 0000000000..d3d8b27575
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/ResultList.spec.js.snap
@@ -0,0 +1,55 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Result list should render the component 1`] = `
+<ul
+ aria-live="polite"
+ className="result-list small"
+ id="result-list"
+ role="listbox"
+>
+ <li
+ aria-describedby="0-subtitle"
+ aria-labelledby="0-title"
+ className="result-item"
+ key="0value0"
+ onClick={[Function]}
+ role="option"
+ title="value"
+ >
+ <div
+ className="title"
+ id="0-title"
+ >
+ title
+ </div>
+ <div
+ className="subtitle"
+ id="0-subtitle"
+ >
+ subtitle
+ </div>
+ </li>
+ <li
+ aria-describedby="1-subtitle"
+ aria-labelledby="1-title"
+ className="result-item selected"
+ key="1value 11"
+ onClick={[Function]}
+ role="option"
+ title="value 1"
+ >
+ <div
+ className="title"
+ id="1-title"
+ >
+ title 1
+ </div>
+ <div
+ className="subtitle"
+ id="1-subtitle"
+ >
+ subtitle 1
+ </div>
+ </li>
+</ul>
+`;
diff --git a/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap b/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap
new file mode 100644
index 0000000000..c56a13dc3b
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/SearchInput.spec.js.snap
@@ -0,0 +1,267 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SearchInput renders 1`] = `
+<div
+ className="search-outline"
+>
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ />
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="A placeholder"
+ spellCheck={false}
+ value=""
+ />
+ <div
+ className="search-field-summary"
+ >
+ So many results
+ </div>
+ <div
+ className="search-buttons-bar"
+ >
+ <span
+ className="pipe-divider"
+ />
+ <CloseButton
+ buttonClass=""
+ handleClick={[MockFunction]}
+ />
+ </div>
+ </div>
+</div>
+`;
+
+exports[`SearchInput shows nav buttons 1`] = `
+<div
+ className="search-outline"
+>
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ />
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="A placeholder"
+ spellCheck={false}
+ value=""
+ />
+ <div
+ className="search-field-summary"
+ >
+ So many results
+ </div>
+ <div
+ className="search-nav-buttons"
+ >
+ <button
+ className="nav-btn prev"
+ key="arrow-up"
+ onClick={[MockFunction]}
+ title="Previous result"
+ type="arrow-up"
+ >
+ <AccessibleImage
+ className="arrow-up"
+ />
+ </button>
+ <button
+ className="nav-btn next"
+ key="arrow-down"
+ onClick={[MockFunction]}
+ title="Next result"
+ type="arrow-down"
+ >
+ <AccessibleImage
+ className="arrow-down"
+ />
+ </button>
+ </div>
+ <div
+ className="search-buttons-bar"
+ >
+ <span
+ className="pipe-divider"
+ />
+ <CloseButton
+ buttonClass=""
+ handleClick={[MockFunction]}
+ />
+ </div>
+ </div>
+</div>
+`;
+
+exports[`SearchInput shows svg error emoji 1`] = `
+<div
+ className="search-outline"
+>
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ />
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className="empty"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="A placeholder"
+ spellCheck={false}
+ value=""
+ />
+ <div
+ className="search-field-summary"
+ >
+ So many results
+ </div>
+ <div
+ className="search-nav-buttons"
+ >
+ <button
+ className="nav-btn prev"
+ key="arrow-up"
+ onClick={[MockFunction]}
+ title="Previous result"
+ type="arrow-up"
+ >
+ <AccessibleImage
+ className="arrow-up"
+ />
+ </button>
+ <button
+ className="nav-btn next"
+ key="arrow-down"
+ onClick={[MockFunction]}
+ title="Next result"
+ type="arrow-down"
+ >
+ <AccessibleImage
+ className="arrow-down"
+ />
+ </button>
+ </div>
+ <div
+ className="search-buttons-bar"
+ >
+ <span
+ className="pipe-divider"
+ />
+ <CloseButton
+ buttonClass=""
+ handleClick={[MockFunction]}
+ />
+ </div>
+ </div>
+</div>
+`;
+
+exports[`SearchInput shows svg magnifying glass 1`] = `
+<div
+ className="search-outline"
+>
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ />
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="A placeholder"
+ spellCheck={false}
+ value=""
+ />
+ <div
+ className="search-field-summary"
+ >
+ So many results
+ </div>
+ <div
+ className="search-nav-buttons"
+ >
+ <button
+ className="nav-btn prev"
+ key="arrow-up"
+ onClick={[MockFunction]}
+ title="Previous result"
+ type="arrow-up"
+ >
+ <AccessibleImage
+ className="arrow-up"
+ />
+ </button>
+ <button
+ className="nav-btn next"
+ key="arrow-down"
+ onClick={[MockFunction]}
+ title="Next result"
+ type="arrow-down"
+ >
+ <AccessibleImage
+ className="arrow-down"
+ />
+ </button>
+ </div>
+ <div
+ className="search-buttons-bar"
+ >
+ <span
+ className="pipe-divider"
+ />
+ <CloseButton
+ buttonClass=""
+ handleClick={[MockFunction]}
+ />
+ </div>
+ </div>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js b/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js
new file mode 100644
index 0000000000..74bc7c75bc
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js
@@ -0,0 +1,803 @@
+/* eslint max-nested-callbacks: ["error", 4] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { Provider } from "devtools/client/shared/vendor/react-redux";
+import configureStore from "redux-mock-store";
+import { shallow, mount } from "enzyme";
+import { getDisplayURL } from "../../utils/sources-tree/getURL";
+import { searchKeys } from "../../constants";
+
+// it's important to mock the module before importing the QuickOpenModal
+jest.mock("devtools/client/shared/vendor/fuzzaldrin-plus.js", () => {
+ return {
+ filter: jest.fn(() => []),
+ prepareQuery: jest.fn(() => {}),
+ wrap: jest.fn(() => {}),
+ };
+});
+import { QuickOpenModal } from "../QuickOpenModal";
+const { filter } = require("devtools/client/shared/vendor/fuzzaldrin-plus.js");
+
+function generateModal(propOverrides, renderType = "shallow") {
+ const mockStore = configureStore([]);
+ const store = mockStore({
+ ui: {
+ mutableSearchOptions: {
+ [searchKeys.QUICKOPEN_SEARCH]: {
+ regexMatch: false,
+ wholeWord: false,
+ caseSensitive: false,
+ excludePatterns: "",
+ },
+ },
+ },
+ });
+ const props = {
+ enabled: false,
+ query: "",
+ searchType: "sources",
+ displayedSources: [],
+ blackBoxRanges: {},
+ openedTabUrls: [],
+ selectedLocation: { source: { id: "foo" } },
+ selectSpecificLocation: jest.fn(),
+ setQuickOpenQuery: jest.fn(),
+ highlightLineRange: jest.fn(),
+ clearHighlightLineRange: jest.fn(),
+ closeQuickOpen: jest.fn(),
+ getFunctionSymbols: jest.fn(() => []),
+ shortcutsModalEnabled: false,
+ toggleShortcutsModal: jest.fn(),
+ isOriginal: false,
+ thread: "FakeThread",
+ ...propOverrides,
+ };
+ return {
+ wrapper:
+ renderType === "shallow"
+ ? shallow(
+ <Provider store={store}>
+ <QuickOpenModal {...props} />
+ </Provider>
+ ).dive()
+ : mount(
+ <Provider store={store}>
+ <QuickOpenModal {...props} />
+ </Provider>
+ ),
+ props,
+ };
+}
+
+async function waitForUpdateResultsThrottle() {
+ await new Promise(res =>
+ setTimeout(res, QuickOpenModal.UPDATE_RESULTS_THROTTLE)
+ );
+}
+
+describe("QuickOpenModal", () => {
+ beforeEach(() => {
+ filter.mockClear();
+ });
+ test("Doesn't render when disabled", () => {
+ const { wrapper } = generateModal();
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ test("Renders when enabled", () => {
+ const { wrapper } = generateModal({ enabled: true });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ test("Basic render with mount", () => {
+ const { wrapper } = generateModal({ enabled: true }, "mount");
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ test("Basic render with mount & searchType = functions", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "@",
+ searchType: "functions",
+ },
+ "mount"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ test("toggles shortcut modal if enabled", () => {
+ const { props } = generateModal(
+ {
+ enabled: true,
+ query: "test",
+ shortcutsModalEnabled: true,
+ toggleShortcutsModal: jest.fn(),
+ },
+ "shallow"
+ );
+ expect(props.toggleShortcutsModal).toHaveBeenCalled();
+ });
+
+ test("shows top sources", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "",
+ displayedSources: [
+ {
+ url: "mozilla.com",
+ displayURL: getDisplayURL("mozilla.com"),
+ },
+ ],
+ openedTabUrls: ["mozilla.com"],
+ },
+ "shallow"
+ );
+ expect(wrapper.state("results")).toEqual([
+ {
+ id: undefined,
+ icon: "tab result-item-icon",
+ subtitle: "mozilla.com",
+ title: "mozilla.com",
+ url: "mozilla.com",
+ value: "mozilla.com",
+ source: {
+ url: "mozilla.com",
+ displayURL: getDisplayURL("mozilla.com"),
+ },
+ },
+ ]);
+ });
+
+ describe("shows loading", () => {
+ it("loads with function type search", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "",
+ searchType: "functions",
+ },
+ "shallow"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+ });
+
+ test("Basic render with mount & searchType = variables", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "#",
+ searchType: "variables",
+ },
+ "mount"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ test("Basic render with mount & searchType = shortcuts", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "?",
+ searchType: "shortcuts",
+ },
+ "mount"
+ );
+ expect(wrapper.find("ResultList")).toHaveLength(1);
+ expect(wrapper.find("li")).toHaveLength(3);
+ });
+
+ test("updateResults on enable", () => {
+ const { wrapper } = generateModal({}, "mount");
+ expect(wrapper).toMatchSnapshot();
+ wrapper.setProps({ enabled: true });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ test("basic source search", async () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ },
+ "mount"
+ );
+ wrapper.find("input").simulate("change", { target: { value: "somefil" } });
+ await waitForUpdateResultsThrottle();
+ expect(filter).toHaveBeenCalledWith([], "somefil", {
+ key: "value",
+ maxResults: 100,
+ });
+ });
+
+ test("basic gotoSource search", async () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ searchType: "gotoSource",
+ },
+ "mount"
+ );
+ wrapper
+ .find("input")
+ .simulate("change", { target: { value: "somefil:33" } });
+
+ await waitForUpdateResultsThrottle();
+
+ expect(filter).toHaveBeenCalledWith([], "somefil", {
+ key: "value",
+ maxResults: 100,
+ });
+ });
+
+ describe("empty symbol search", () => {
+ it("basic symbol search", async () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ searchType: "functions",
+ // symbol searching relies on a source being selected.
+ // So we dummy out the source and the API.
+ selectedLocation: { source: { id: "foo", text: "yo" } },
+ selectedContentLoaded: true,
+ },
+ "mount"
+ );
+
+ wrapper
+ .find("input")
+ .simulate("change", { target: { value: "@someFunc" } });
+ await waitForUpdateResultsThrottle();
+ expect(filter).toHaveBeenCalledWith([], "someFunc", {
+ key: "name",
+ maxResults: 100,
+ preparedQuery: undefined,
+ });
+ });
+
+ it("does not do symbol search if no selected source", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ searchType: "functions",
+
+ // symbol searching relies on a source being selected.
+ // So we dummy out the source and the API.
+ selectedLocation: null,
+ selectedContentLoaded: false,
+ },
+ "mount"
+ );
+ wrapper
+ .find("input")
+ .simulate("change", { target: { value: "@someFunc" } });
+ expect(filter).not.toHaveBeenCalled();
+ });
+ });
+
+ test("Simple goto search query = :abc & searchType = goto", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: ":abc",
+ searchType: "goto",
+ },
+ "mount"
+ );
+ expect(wrapper.childAt(0)).toMatchSnapshot();
+ expect(wrapper.childAt(0).state().results).toEqual(null);
+ });
+
+ describe("onEnter", () => {
+ it("on Enter go to location", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: ":34:12",
+ searchType: "goto",
+ selectedLocation: { source: { id: "foo" } },
+ },
+ "shallow"
+ );
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith({
+ column: 11,
+ line: 34,
+ source: {
+ id: "foo",
+ },
+ sourceActorId: undefined,
+ sourceActor: null,
+ });
+ });
+
+ it("on Enter go to location with sourceId", () => {
+ const sourceId = "source_id";
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: ":34:12",
+ searchType: "goto",
+ selectedLocation: { source: { id: sourceId } },
+ selectedContentLoaded: true,
+ },
+ "shallow"
+ );
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith({
+ column: 11,
+ line: 34,
+ source: {
+ id: sourceId,
+ },
+ sourceActorId: undefined,
+ sourceActor: null,
+ });
+ });
+
+ it("on Enter with no location, does no action", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: ":",
+ searchType: "goto",
+ },
+ "shallow"
+ );
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.setQuickOpenQuery).not.toHaveBeenCalled();
+ expect(props.selectSpecificLocation).not.toHaveBeenCalled();
+ expect(props.highlightLineRange).not.toHaveBeenCalled();
+ });
+
+ it("on Enter with empty results, handle no item", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "",
+ searchType: "shortcuts",
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [],
+ selectedIndex: 0,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.setQuickOpenQuery).not.toHaveBeenCalled();
+ expect(props.selectSpecificLocation).not.toHaveBeenCalled();
+ expect(props.highlightLineRange).not.toHaveBeenCalled();
+ });
+
+ it("on Enter with results, handle symbol shortcut", () => {
+ const symbols = [":", "#", "@"];
+ for (const symbol of symbols) {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "",
+ searchType: "shortcuts",
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [{ id: symbol }],
+ selectedIndex: 0,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.setQuickOpenQuery).toHaveBeenCalledWith(symbol);
+ }
+ });
+
+ it("on Enter, returns the result with the selected index", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "@test",
+ searchType: "shortcuts",
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [{ id: "@" }, { id: ":" }, { id: "#" }],
+ selectedIndex: 1,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.setQuickOpenQuery).toHaveBeenCalledWith(":");
+ });
+
+ it("on Enter with results, handle result item", () => {
+ const id = "test_id";
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "@test",
+ searchType: "other",
+ selectedLocation: { source: { id } },
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [{}, { id }],
+ selectedIndex: 1,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith({
+ column: undefined,
+ line: 0,
+ source: { id },
+ sourceActorId: undefined,
+ sourceActor: null,
+ });
+ expect(props.setQuickOpenQuery).not.toHaveBeenCalled();
+ });
+
+ it("on Enter with results, handle functions result item", () => {
+ const id = "test_id";
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "@test",
+ searchType: "functions",
+ selectedLocation: { source: { id } },
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [{}, { id }],
+ selectedIndex: 1,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith({
+ column: undefined,
+ line: 0,
+ source: { id },
+ sourceActorId: undefined,
+ sourceActor: null,
+ });
+ expect(props.setQuickOpenQuery).not.toHaveBeenCalled();
+ });
+
+ it("on Enter with results, handle gotoSource search", () => {
+ const id = "test_id";
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: ":3:4",
+ searchType: "gotoSource",
+ selectedLocation: { source: { id } },
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [{}, { id }],
+ selectedIndex: 1,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith({
+ column: 3,
+ line: 3,
+ source: { id },
+ sourceActorId: undefined,
+ sourceActor: null,
+ });
+ expect(props.setQuickOpenQuery).not.toHaveBeenCalled();
+ });
+
+ it("on Enter with results, handle shortcuts search", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "@",
+ searchType: "shortcuts",
+ },
+ "shallow"
+ );
+ const id = "#";
+ wrapper.setState(() => ({
+ results: [{}, { id }],
+ selectedIndex: 1,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).not.toHaveBeenCalled();
+ expect(props.setQuickOpenQuery).toHaveBeenCalledWith(id);
+ });
+ });
+
+ describe("onKeyDown", () => {
+ it("does nothing if search type is not goto", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "test",
+ searchType: "other",
+ },
+ "shallow"
+ );
+ wrapper.find("Connect(SearchInput)").simulate("keydown", {});
+ expect(props.selectSpecificLocation).not.toHaveBeenCalled();
+ expect(props.setQuickOpenQuery).not.toHaveBeenCalled();
+ });
+
+ it("on Tab, close modal", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: ":34:12",
+ searchType: "goto",
+ },
+ "shallow"
+ );
+ const event = {
+ key: "Tab",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.closeQuickOpen).toHaveBeenCalled();
+ expect(props.selectSpecificLocation).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("with arrow keys", () => {
+ it("on ArrowUp, traverse results up with functions", () => {
+ const sourceId = "sourceId";
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "test",
+ searchType: "functions",
+ selectedLocation: { source: { id: sourceId } },
+ selectedContentLoaded: true,
+ },
+ "shallow"
+ );
+ const event = {
+ preventDefault: jest.fn(),
+ key: "ArrowUp",
+ };
+ const location = {
+ sourceId: "sourceId",
+ start: {
+ line: 1,
+ },
+ end: {
+ line: 3,
+ },
+ };
+
+ wrapper.setState(() => ({
+ results: [{ id: "0", location }, { id: "1" }, { id: "2" }],
+ selectedIndex: 1,
+ }));
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(wrapper.state().selectedIndex).toEqual(0);
+ expect(props.highlightLineRange).toHaveBeenCalledWith({
+ sourceId: "sourceId",
+ end: 3,
+ start: 1,
+ });
+ });
+
+ it("on ArrowDown, traverse down with no results", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "test",
+ searchType: "goto",
+ },
+ "shallow"
+ );
+ const event = {
+ preventDefault: jest.fn(),
+ key: "ArrowDown",
+ };
+ wrapper.setState(() => ({
+ results: null,
+ selectedIndex: 1,
+ }));
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(wrapper.state().selectedIndex).toEqual(0);
+ expect(props.selectSpecificLocation).not.toHaveBeenCalledWith();
+ expect(props.highlightLineRange).not.toHaveBeenCalled();
+ });
+
+ it("on ArrowUp, traverse results up to function with no location", () => {
+ const sourceId = "sourceId";
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "test",
+ searchType: "functions",
+ selectedLocation: { source: { id: sourceId } },
+ selectedContentLoaded: true,
+ },
+ "shallow"
+ );
+ const event = {
+ preventDefault: jest.fn(),
+ key: "ArrowUp",
+ };
+ wrapper.setState(() => ({
+ results: [{ id: "0", location: null }, { id: "1" }, { id: "2" }],
+ selectedIndex: 1,
+ }));
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(wrapper.state().selectedIndex).toEqual(0);
+ expect(props.highlightLineRange).not.toHaveBeenCalled();
+ expect(props.clearHighlightLineRange).toHaveBeenCalled();
+ });
+
+ it(
+ "on ArrowDown, traverse down results, without " +
+ "taking action if no selectedSource",
+ () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "test",
+ searchType: "variables",
+ selectedLocation: null,
+ selectedContentLoaded: true,
+ },
+ "shallow"
+ );
+ const event = {
+ preventDefault: jest.fn(),
+ key: "ArrowDown",
+ };
+ const location = {
+ sourceId: "sourceId",
+ start: {
+ line: 7,
+ },
+ };
+ wrapper.setState(() => ({
+ results: [{ id: "0", location }, { id: "1" }, { id: "2" }],
+ selectedIndex: 1,
+ }));
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(wrapper.state().selectedIndex).toEqual(2);
+ expect(props.selectSpecificLocation).not.toHaveBeenCalled();
+ expect(props.highlightLineRange).not.toHaveBeenCalled();
+ }
+ );
+
+ it(
+ "on ArrowUp, traverse up results, without taking action if " +
+ "the query is not for variables or functions",
+ () => {
+ const sourceId = "sourceId";
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "test",
+ searchType: "other",
+ selectedLocation: { source: { id: sourceId } },
+ selectedContentLoaded: true,
+ },
+ "shallow"
+ );
+ const event = {
+ preventDefault: jest.fn(),
+ key: "ArrowUp",
+ };
+ const location = {
+ sourceId: "sourceId",
+ start: {
+ line: 7,
+ },
+ };
+ wrapper.setState(() => ({
+ results: [{ id: "0", location }, { id: "1" }, { id: "2" }],
+ selectedIndex: 1,
+ }));
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(wrapper.state().selectedIndex).toEqual(0);
+ expect(props.selectSpecificLocation).not.toHaveBeenCalled();
+ expect(props.highlightLineRange).not.toHaveBeenCalled();
+ }
+ );
+ });
+
+ describe("showErrorEmoji", () => {
+ it("true when no count + query", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "test",
+ searchType: "other",
+ },
+ "mount"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("false when count + query", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "dasdasdas",
+ },
+ "mount"
+ );
+ wrapper.setState(() => ({
+ results: [1, 2],
+ }));
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("false when no query", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "",
+ searchType: "other",
+ },
+ "mount"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("false when goto numeric ':2222'", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: ":2222",
+ searchType: "goto",
+ },
+ "mount"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("true when goto not numeric ':22k22'", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: ":22k22",
+ searchType: "goto",
+ },
+ "mount"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/test/WelcomeBox.spec.js b/devtools/client/debugger/src/components/test/WelcomeBox.spec.js
new file mode 100644
index 0000000000..5599c416fe
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/WelcomeBox.spec.js
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+
+import { WelcomeBox } from "../WelcomeBox";
+
+function render(overrides = {}) {
+ const props = {
+ horizontal: false,
+ togglePaneCollapse: jest.fn(),
+ endPanelCollapsed: false,
+ setActiveSearch: jest.fn(),
+ openQuickOpen: jest.fn(),
+ toggleShortcutsModal: jest.fn(),
+ setPrimaryPaneTab: jest.fn(),
+ ...overrides,
+ };
+ const component = shallow(React.createElement(WelcomeBox, props));
+ return { component, props };
+}
+
+describe("WelomeBox", () => {
+ it("renders with default values", () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("doesn't render toggle button in horizontal mode", () => {
+ const { component } = render({
+ horizontal: true,
+ });
+ expect(component.find("PaneToggleButton")).toHaveLength(0);
+ });
+
+ it("calls correct function on searchSources click", () => {
+ const { component, props } = render();
+
+ component.find(".welcomebox__searchSources").simulate("click");
+ expect(props.openQuickOpen).toHaveBeenCalled();
+ });
+
+ it("calls correct function on searchProject click", () => {
+ const { component, props } = render();
+
+ component.find(".welcomebox__searchProject").simulate("click");
+ expect(props.setActiveSearch).toHaveBeenCalled();
+ });
+
+ it("calls correct function on allShotcuts click", () => {
+ const { component, props } = render();
+
+ component.find(".welcomebox__allShortcuts").simulate("click");
+ expect(props.toggleShortcutsModal).toHaveBeenCalled();
+ });
+});
diff --git a/devtools/client/debugger/src/components/test/WhyPaused.spec.js b/devtools/client/debugger/src/components/test/WhyPaused.spec.js
new file mode 100644
index 0000000000..5466976dfb
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/WhyPaused.spec.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "devtools/client/shared/vendor/react";
+import { shallow } from "enzyme";
+import WhyPaused from "../SecondaryPanes/WhyPaused.js";
+
+function render(why, delay) {
+ const props = { why, delay };
+ const component = shallow(
+ React.createElement(WhyPaused.WrappedComponent, props)
+ );
+
+ return { component, props };
+}
+
+describe("WhyPaused", () => {
+ it("should pause reason with message", () => {
+ const why = {
+ type: "breakpoint",
+ message: "bla is hit",
+ };
+ const { component } = render(why);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should show pause reason with exception details", () => {
+ const why = {
+ type: "exception",
+ exception: {
+ class: "ReferenceError",
+ isError: true,
+ preview: {
+ name: "ReferenceError",
+ message: "o is not defined",
+ },
+ },
+ };
+
+ const { component } = render(why);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should show pause reason with exception string", () => {
+ const why = {
+ type: "exception",
+ exception: "Not Available",
+ };
+
+ const { component } = render(why);
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should show an empty div when there is no pause reason", () => {
+ const why = undefined;
+
+ const { component } = render(why);
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap
new file mode 100644
index 0000000000..d58d2d58a0
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap
@@ -0,0 +1,1777 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`QuickOpenModal Basic render with mount & searchType = functions 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={
+ [MockFunction] {
+ "calls": Array [
+ Array [
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ },
+ 100,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Array [],
+ },
+ ],
+ }
+ }
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query="@"
+ searchType="functions"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query="@"
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ summaryMsg="Loading…"
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query="@"
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size=""
+ summaryMsg="Loading…"
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className="empty"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value="@"
+ />
+ <div
+ className="search-field-summary"
+ >
+ Loading…
+ </div>
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal Basic render with mount & searchType = variables 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query="#"
+ searchType="variables"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query="#"
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ summaryMsg=""
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query="#"
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size=""
+ summaryMsg=""
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className="empty"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value="#"
+ />
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="small"
+ >
+ <ul
+ aria-live="polite"
+ className="result-list small"
+ id="result-list"
+ role="listbox"
+ />
+ </ResultList>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal Basic render with mount 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query=""
+ searchType="sources"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field big"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value=""
+ />
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="big"
+ >
+ <ul
+ aria-live="polite"
+ className="result-list big"
+ id="result-list"
+ role="listbox"
+ />
+ </ResultList>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal Doesn't render when disabled 1`] = `
+<Modal
+ handleClose={[Function]}
+>
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ />
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="big"
+ />
+</Modal>
+`;
+
+exports[`QuickOpenModal Renders when enabled 1`] = `
+<Modal
+ handleClose={[Function]}
+>
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ />
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="big"
+ />
+</Modal>
+`;
+
+exports[`QuickOpenModal Simple goto search query = :abc & searchType = goto 1`] = `
+<QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query=":abc"
+ searchType="goto"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+>
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=":abc"
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ summaryMsg="Go to line"
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=":abc"
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size=""
+ summaryMsg="Go to line"
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className="empty"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value=":abc"
+ />
+ <div
+ className="search-field-summary"
+ >
+ Go to line
+ </div>
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ </div>
+ </div>
+ </Modal>
+</QuickOpenModal>
+`;
+
+exports[`QuickOpenModal showErrorEmoji false when count + query 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query="dasdasdas"
+ searchType="sources"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query="dasdasdas"
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query="dasdasdas"
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field big"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className="empty"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value="dasdasdas"
+ />
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="big"
+ >
+ <ul
+ aria-live="polite"
+ className="result-list big"
+ id="result-list"
+ role="listbox"
+ />
+ </ResultList>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal showErrorEmoji false when goto numeric ':2222' 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query=":2222"
+ searchType="goto"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=":2222"
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ summaryMsg="Go to line"
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=":2222"
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size=""
+ summaryMsg="Go to line"
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value=":2222"
+ />
+ <div
+ className="search-field-summary"
+ >
+ Go to line
+ </div>
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal showErrorEmoji false when no query 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query=""
+ searchType="other"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ summaryMsg=""
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size=""
+ summaryMsg=""
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value=""
+ />
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="small"
+ >
+ <ul
+ aria-live="polite"
+ className="result-list small"
+ id="result-list"
+ role="listbox"
+ />
+ </ResultList>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal showErrorEmoji true when goto not numeric ':22k22' 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query=":22k22"
+ searchType="goto"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=":22k22"
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ summaryMsg="Go to line"
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=":22k22"
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size=""
+ summaryMsg="Go to line"
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className="empty"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value=":22k22"
+ />
+ <div
+ className="search-field-summary"
+ >
+ Go to line
+ </div>
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal showErrorEmoji true when no count + query 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={true}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query="test"
+ searchType="other"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query="test"
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ summaryMsg=""
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query="test"
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size=""
+ summaryMsg=""
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className="empty"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value="test"
+ />
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="small"
+ >
+ <ul
+ aria-live="polite"
+ className="result-list small"
+ id="result-list"
+ role="listbox"
+ />
+ </ResultList>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal shows loading loads with function type search 1`] = `
+<Modal
+ handleClose={[Function]}
+>
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ summaryMsg=""
+ />
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="small"
+ />
+</Modal>
+`;
+
+exports[`QuickOpenModal updateResults on enable 1`] = `
+<Provider
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={false}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query=""
+ searchType="sources"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field big"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value=""
+ />
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="big"
+ >
+ <ul
+ aria-live="polite"
+ className="result-list big"
+ id="result-list"
+ role="listbox"
+ />
+ </ResultList>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal updateResults on enable 2`] = `
+<Provider
+ enabled={true}
+ store={
+ Object {
+ "clearActions": [Function],
+ "dispatch": [Function],
+ "getActions": [Function],
+ "getState": [Function],
+ "replaceReducer": [Function],
+ "subscribe": [Function],
+ }
+ }
+>
+ <QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ displayedSources={Array []}
+ enabled={false}
+ getFunctionSymbols={[MockFunction]}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ openedTabUrls={Array []}
+ query=""
+ searchType="sources"
+ selectSpecificLocation={[MockFunction]}
+ selectedLocation={
+ Object {
+ "source": Object {
+ "id": "foo",
+ },
+ }
+ }
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Modal
+ handleClose={[Function]}
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal"
+ onClick={[Function]}
+ >
+ <Connect(SearchInput)
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ selectedItemId=""
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ >
+ <SearchInput
+ count={0}
+ expanded={false}
+ handleClose={[Function]}
+ hasPrefix={true}
+ isLoading={false}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ query=""
+ searchKey="quickopen-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={false}
+ showSearchModifiers={false}
+ size="big"
+ summaryMsg=""
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field big"
+ role="combobox"
+ >
+ <AccessibleImage
+ className="search"
+ >
+ <span
+ className="img search"
+ />
+ </AccessibleImage>
+ <input
+ aria-activedescendant=""
+ aria-autocomplete="list"
+ aria-controls="result-list"
+ className=""
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Go to file…"
+ spellCheck={false}
+ value=""
+ />
+ <div
+ className="search-buttons-bar"
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="big"
+ >
+ <ul
+ aria-live="polite"
+ className="result-list big"
+ id="result-list"
+ role="listbox"
+ />
+ </ResultList>
+ </div>
+ </div>
+ </Modal>
+ </QuickOpenModal>
+</Provider>
+`;
diff --git a/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap
new file mode 100644
index 0000000000..9828e88ef4
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/__snapshots__/WelcomeBox.spec.js.snap
@@ -0,0 +1,67 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`WelomeBox renders with default values 1`] = `
+<div
+ className="welcomebox"
+>
+ <div
+ className="alignlabel"
+ >
+ <div
+ className="shortcutFunction"
+ >
+ <p
+ className="welcomebox__searchSources"
+ onClick={[Function]}
+ role="button"
+ tabIndex="0"
+ >
+ <span
+ className="shortcutKey"
+ >
+ Ctrl+P
+ </span>
+ <span
+ className="shortcutLabel"
+ >
+ Go to file
+ </span>
+ </p>
+ <p
+ className="welcomebox__searchProject"
+ onClick={[Function]}
+ role="button"
+ tabIndex="0"
+ >
+ <span
+ className="shortcutKey"
+ >
+ Ctrl+Shift+F
+ </span>
+ <span
+ className="shortcutLabel"
+ >
+ Find in files
+ </span>
+ </p>
+ <p
+ className="welcomebox__allShortcuts"
+ onClick={[Function]}
+ role="button"
+ tabIndex="0"
+ >
+ <span
+ className="shortcutKey"
+ >
+ Ctrl+/
+ </span>
+ <span
+ className="shortcutLabel"
+ >
+ Show all shortcuts
+ </span>
+ </p>
+ </div>
+ </div>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap
new file mode 100644
index 0000000000..0762a0b69d
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/__snapshots__/WhyPaused.spec.js.snap
@@ -0,0 +1,103 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`WhyPaused should pause reason with message 1`] = `
+<LocalizationProvider
+ bundles={Array []}
+>
+ <div
+ className="pane why-paused"
+ >
+ <div>
+ <div
+ className="info icon"
+ >
+ <AccessibleImage
+ className="info"
+ />
+ </div>
+ <div
+ className="pause reason"
+ >
+ <Localized
+ id="whypaused-breakpoint"
+ />
+ <div
+ className="message"
+ >
+ bla is hit
+ </div>
+ </div>
+ </div>
+ </div>
+</LocalizationProvider>
+`;
+
+exports[`WhyPaused should show an empty div when there is no pause reason 1`] = `
+<div
+ className=""
+/>
+`;
+
+exports[`WhyPaused should show pause reason with exception details 1`] = `
+<LocalizationProvider
+ bundles={Array []}
+>
+ <div
+ className="pane why-paused"
+ >
+ <div>
+ <div
+ className="info icon"
+ >
+ <AccessibleImage
+ className="info"
+ />
+ </div>
+ <div
+ className="pause reason"
+ >
+ <Localized
+ id="whypaused-exception"
+ />
+ <div
+ className="message warning"
+ >
+ ReferenceError: o is not defined
+ </div>
+ </div>
+ </div>
+ </div>
+</LocalizationProvider>
+`;
+
+exports[`WhyPaused should show pause reason with exception string 1`] = `
+<LocalizationProvider
+ bundles={Array []}
+>
+ <div
+ className="pane why-paused"
+ >
+ <div>
+ <div
+ className="info icon"
+ >
+ <AccessibleImage
+ className="info"
+ />
+ </div>
+ <div
+ className="pause reason"
+ >
+ <Localized
+ id="whypaused-exception"
+ />
+ <div
+ className="message warning"
+ >
+ Not Available
+ </div>
+ </div>
+ </div>
+ </div>
+</LocalizationProvider>
+`;
diff --git a/devtools/client/debugger/src/components/variables.css b/devtools/client/debugger/src/components/variables.css
new file mode 100644
index 0000000000..628c590714
--- /dev/null
+++ b/devtools/client/debugger/src/components/variables.css
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+:root {
+ /* header height is 28px + 1px for its border */
+ --editor-header-height: 29px;
+ /* footer height is 24px + 1px for its border */
+ --editor-footer-height: 25px;
+ /* searchbar height is 24px + 1px for its top border */
+ --editor-searchbar-height: 25px;
+ /* Remove once https://bugzilla.mozilla.org/show_bug.cgi?id=1520440 lands */
+ --theme-code-line-height: calc(15 / 11);
+ /* Background and text colors and opacity for skipped breakpoint panes */
+ --skip-pausing-background-color: var(--theme-toolbar-hover);
+ --skip-pausing-opacity: 0.6;
+ --skip-pausing-color: var(--theme-body-color);
+}
+
+:root.theme-light,
+:root .theme-light {
+ --search-overlays-semitransparent: rgba(221, 225, 228, 0.66);
+ --popup-shadow-color: #d0d0d0;
+ --theme-inline-preview-background: rgba(192, 105, 255, 0.05);
+ --theme-inline-preview-border-color: #ebd1ff;
+ --theme-inline-preview-label-color: #6300a6;
+ --theme-inline-preview-label-background: rgb(244, 230, 255);
+}
+
+:root.theme-dark,
+:root .theme-dark {
+ --search-overlays-semitransparent: rgba(42, 46, 56, 0.66);
+ --popup-shadow-color: #5c667b;
+ --theme-inline-preview-background: rgba(192, 105, 255, 0.05);
+ --theme-inline-preview-border-color: #47326c;
+ --theme-inline-preview-label-color: #dfccff;
+ --theme-inline-preview-label-background: #3f2e5f;
+}
+
+/* Animations */
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}