summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/components')
-rw-r--r--devtools/client/debugger/src/components/A11yIntention.css7
-rw-r--r--devtools/client/debugger/src/components/A11yIntention.js37
-rw-r--r--devtools/client/debugger/src/components/App.css130
-rw-r--r--devtools/client/debugger/src/components/App.js336
-rw-r--r--devtools/client/debugger/src/components/Editor/BlackboxLines.js138
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoint.js183
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoints.css153
-rw-r--r--devtools/client/debugger/src/components/Editor/Breakpoints.js96
-rw-r--r--devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js140
-rw-r--r--devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js75
-rw-r--r--devtools/client/debugger/src/components/Editor/ConditionalPanel.css39
-rw-r--r--devtools/client/debugger/src/components/Editor/ConditionalPanel.js274
-rw-r--r--devtools/client/debugger/src/components/Editor/DebugLine.js138
-rw-r--r--devtools/client/debugger/src/components/Editor/Editor.css220
-rw-r--r--devtools/client/debugger/src/components/Editor/EditorMenu.js111
-rw-r--r--devtools/client/debugger/src/components/Editor/EmptyLines.js88
-rw-r--r--devtools/client/debugger/src/components/Editor/Exception.js96
-rw-r--r--devtools/client/debugger/src/components/Editor/Exceptions.js67
-rw-r--r--devtools/client/debugger/src/components/Editor/Footer.css85
-rw-r--r--devtools/client/debugger/src/components/Editor/Footer.js302
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightCalls.css15
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightCalls.js110
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightLine.js183
-rw-r--r--devtools/client/debugger/src/components/Editor/HighlightLines.js74
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreview.css29
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreview.js66
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreviewRow.js101
-rw-r--r--devtools/client/debugger/src/components/Editor/InlinePreviews.js83
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview.css111
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js164
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/Popup.css209
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/Popup.js382
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/index.js136
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/moz.build12
-rw-r--r--devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js107
-rw-r--r--devtools/client/debugger/src/components/Editor/SearchInFileBar.css39
-rw-r--r--devtools/client/debugger/src/components/Editor/SearchInFileBar.js371
-rw-r--r--devtools/client/debugger/src/components/Editor/Tab.js282
-rw-r--r--devtools/client/debugger/src/components/Editor/Tabs.css125
-rw-r--r--devtools/client/debugger/src/components/Editor/Tabs.js332
-rw-r--r--devtools/client/debugger/src/components/Editor/index.js808
-rw-r--r--devtools/client/debugger/src/components/Editor/menus/breakpoints.js293
-rw-r--r--devtools/client/debugger/src/components/Editor/menus/editor.js403
-rw-r--r--devtools/client/debugger/src/components/Editor/menus/moz.build12
-rw-r--r--devtools/client/debugger/src/components/Editor/menus/source.js3
-rw-r--r--devtools/client/debugger/src/components/Editor/moz.build34
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js54
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js77
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js85
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/Footer.spec.js67
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap35
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap630
-rw-r--r--devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap105
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Outline.css205
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Outline.js372
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css30
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js63
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css165
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js327
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/Sources.css219
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js510
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js457
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/index.js132
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/moz.build15
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js326
-rw-r--r--devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap1111
-rw-r--r--devtools/client/debugger/src/components/QuickOpenModal.css28
-rw-r--r--devtools/client/debugger/src/components/QuickOpenModal.js524
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js219
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js88
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js77
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css249
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js365
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.js31
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js152
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/moz.build15
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js104
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js134
-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__/Breakpoint.spec.js.snap231
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap19
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/CommandBar.css33
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js433
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.css76
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js175
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css154
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js295
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Expressions.css175
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Expressions.js395
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js197
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js13
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js105
-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.js197
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js231
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build14
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.js155
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js117
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js295
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js134
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap1196
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap1651
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap2440
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Scopes.css104
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Scopes.js311
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css86
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Thread.js70
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Threads.css63
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/Threads.js38
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css58
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js183
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.css131
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js361
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/index.js537
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/moz.build22
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.spec.js77
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js134
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js75
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js345
-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.snap199
-rw-r--r--devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap621
-rw-r--r--devtools/client/debugger/src/components/ShortcutsModal.css47
-rw-r--r--devtools/client/debugger/src/components/ShortcutsModal.js135
-rw-r--r--devtools/client/debugger/src/components/WelcomeBox.css83
-rw-r--r--devtools/client/debugger/src/components/WelcomeBox.js94
-rw-r--r--devtools/client/debugger/src/components/moz.build19
-rw-r--r--devtools/client/debugger/src/components/shared/AccessibleImage.css194
-rw-r--r--devtools/client/debugger/src/components/shared/AccessibleImage.js24
-rw-r--r--devtools/client/debugger/src/components/shared/Accordion.css73
-rw-r--r--devtools/client/debugger/src/components/shared/Accordion.js74
-rw-r--r--devtools/client/debugger/src/components/shared/Badge.css16
-rw-r--r--devtools/client/debugger/src/components/shared/Badge.js17
-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.js56
-rw-r--r--devtools/client/debugger/src/components/shared/Button/PaneToggleButton.js61
-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.css36
-rw-r--r--devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css61
-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.js24
-rw-r--r--devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js36
-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.css96
-rw-r--r--devtools/client/debugger/src/components/shared/Dropdown.js71
-rw-r--r--devtools/client/debugger/src/components/shared/Modal.css51
-rw-r--r--devtools/client/debugger/src/components/shared/Modal.js73
-rw-r--r--devtools/client/debugger/src/components/shared/Popover.css32
-rw-r--r--devtools/client/debugger/src/components/shared/Popover.js299
-rw-r--r--devtools/client/debugger/src/components/shared/PreviewFunction.css23
-rw-r--r--devtools/client/debugger/src/components/shared/PreviewFunction.js82
-rw-r--r--devtools/client/debugger/src/components/shared/ResultList.css131
-rw-r--r--devtools/client/debugger/src/components/shared/ResultList.js82
-rw-r--r--devtools/client/debugger/src/components/shared/SearchInput.css225
-rw-r--r--devtools/client/debugger/src/components/shared/SearchInput.js339
-rw-r--r--devtools/client/debugger/src/components/shared/SmartGap.js166
-rw-r--r--devtools/client/debugger/src/components/shared/SourceIcon.css176
-rw-r--r--devtools/client/debugger/src/components/shared/SourceIcon.js69
-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.js40
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Badge.spec.js12
-rw-r--r--devtools/client/debugger/src/components/shared/tests/BracketArrow.spec.js19
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js16
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Modal.spec.js50
-rw-r--r--devtools/client/debugger/src/components/shared/tests/Popover.spec.js200
-rw-r--r--devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js127
-rw-r--r--devtools/client/debugger/src/components/shared/tests/ResultList.spec.js49
-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.snap84
-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/A11yIntention.spec.js33
-rw-r--r--devtools/client/debugger/src/components/test/Outline.spec.js304
-rw-r--r--devtools/client/debugger/src/components/test/OutlineFilter.spec.js45
-rw-r--r--devtools/client/debugger/src/components/test/QuickOpenModal.spec.js898
-rw-r--r--devtools/client/debugger/src/components/test/ShortcutsModal.spec.js32
-rw-r--r--devtools/client/debugger/src/components/test/WelcomeBox.spec.js59
-rw-r--r--devtools/client/debugger/src/components/test/WhyPaused.spec.js59
-rw-r--r--devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap13
-rw-r--r--devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap505
-rw-r--r--devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap39
-rw-r--r--devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap1694
-rw-r--r--devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap190
-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
201 files changed, 36076 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/components/A11yIntention.css b/devtools/client/debugger/src/components/A11yIntention.css
new file mode 100644
index 0000000000..e97a03ad32
--- /dev/null
+++ b/devtools/client/debugger/src/components/A11yIntention.css
@@ -0,0 +1,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/. */
+
+.A11y-mouse :focus {
+ outline: 0;
+}
diff --git a/devtools/client/debugger/src/components/A11yIntention.js b/devtools/client/debugger/src/components/A11yIntention.js
new file mode 100644
index 0000000000..fab894b216
--- /dev/null
+++ b/devtools/client/debugger/src/components/A11yIntention.js
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import PropTypes from "prop-types";
+import "./A11yIntention.css";
+
+export default class A11yIntention extends React.Component {
+ static get propTypes() {
+ return {
+ children: PropTypes.array.isRequired,
+ };
+ }
+
+ state = { keyboard: false };
+
+ handleKeyDown = () => {
+ this.setState({ keyboard: true });
+ };
+
+ handleMouseDown = () => {
+ this.setState({ keyboard: false });
+ };
+
+ render() {
+ return (
+ <div
+ className={this.state.keyboard ? "A11y-keyboard" : "A11y-mouse"}
+ onKeyDown={this.handleKeyDown}
+ onMouseDown={this.handleMouseDown}
+ >
+ {this.props.children}
+ </div>
+ );
+ }
+}
diff --git a/devtools/client/debugger/src/components/App.css b/devtools/client/debugger/src/components/App.css
new file mode 100644
index 0000000000..6a793c2f48
--- /dev/null
+++ b/devtools/client/debugger/src/components/App.css
@@ -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/>. */
+
+* {
+ 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,
+button:focus {
+ background-color: var(--theme-toolbar-background-hover);
+}
+
+.theme-dark button:hover,
+.theme-dark button:focus {
+ 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%;
+}
+
+/* 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..011d743cd9
--- /dev/null
+++ b/devtools/client/debugger/src/components/App.js
@@ -0,0 +1,336 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../utils/connect";
+import { prefs } from "../utils/prefs";
+import { primaryPaneTabs } from "../constants";
+import actions from "../actions";
+import A11yIntention from "./A11yIntention";
+import { ShortcutsModal } from "./ShortcutsModal";
+
+import {
+ getSelectedSource,
+ getPaneCollapse,
+ getActiveSearch,
+ getQuickOpenEnabled,
+ getOrientation,
+} from "../selectors";
+
+const KeyShortcuts = require("devtools/client/shared/key-shortcuts");
+const SplitBox = require("devtools/client/shared/components/splitter/SplitBox");
+const AppErrorBoundary = require("devtools/client/shared/components/AppErrorBoundary");
+
+const shortcuts = new KeyShortcuts({ window });
+
+const horizontalLayoutBreakpoint = window.matchMedia("(min-width: 800px)");
+const verticalLayoutBreakpoint = window.matchMedia(
+ "(min-width: 10px) and (max-width: 799px)"
+);
+
+import "./variables.css";
+import "./App.css";
+
+import "./shared/menu.css";
+
+import PrimaryPanes from "./PrimaryPanes";
+import Editor from "./Editor";
+import SecondaryPanes from "./SecondaryPanes";
+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,
+ selectedSource: PropTypes.object,
+ setActiveSearch: PropTypes.func.isRequired,
+ setOrientation: PropTypes.func.isRequired,
+ setPrimaryPaneTab: PropTypes.func.isRequired,
+ startPanelCollapsed: PropTypes.bool.isRequired,
+ toolboxDoc: PropTypes.object.isRequired,
+ };
+ }
+
+ 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");
+ }
+ }
+
+ renderEditorPane = () => {
+ const { startPanelCollapsed, endPanelCollapsed } = this.props;
+ const { endPanelSize, startPanelSize } = this.state;
+ const horizontal = this.isHorizontal();
+
+ return (
+ <div className="editor-pane">
+ <div className="editor-container">
+ <EditorTabs
+ startPanelCollapsed={startPanelCollapsed}
+ endPanelCollapsed={endPanelCollapsed}
+ horizontal={horizontal}
+ />
+ <Editor startPanelSize={startPanelSize} endPanelSize={endPanelSize} />
+ {!this.props.selectedSource ? (
+ <WelcomeBox
+ horizontal={horizontal}
+ toggleShortcutsModal={() => this.toggleShortcutsModal()}
+ />
+ ) : null}
+ <EditorFooter horizontal={horizontal} />
+ </div>
+ </div>
+ );
+ };
+
+ 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 (
+ <SplitBox
+ style={{ width: "100vw" }}
+ initialSize={prefs.endPanelSize}
+ minSize={30}
+ maxSize="70%"
+ splitterSize={1}
+ vert={horizontal}
+ onResizeEnd={num => {
+ prefs.endPanelSize = num;
+ this.triggerEditorPaneResize();
+ }}
+ startPanel={
+ <SplitBox
+ style={{ width: "100vw" }}
+ initialSize={prefs.startPanelSize}
+ minSize={30}
+ maxSize="85%"
+ splitterSize={1}
+ onResizeEnd={num => {
+ prefs.startPanelSize = num;
+ }}
+ startPanelCollapsed={startPanelCollapsed}
+ startPanel={<PrimaryPanes horizontal={horizontal} />}
+ endPanel={this.renderEditorPane()}
+ />
+ }
+ endPanelControl={true}
+ endPanel={<SecondaryPanes horizontal={horizontal} />}
+ endPanelCollapsed={endPanelCollapsed}
+ />
+ );
+ };
+
+ render() {
+ const { quickOpenEnabled } = this.props;
+ return (
+ <div className="debugger">
+ <AppErrorBoundary
+ componentName="Debugger"
+ panel={L10N.getStr("ToolboxDebugger.label")}
+ >
+ <A11yIntention>
+ {this.renderLayout()}
+ {quickOpenEnabled === true && (
+ <QuickOpenModal
+ shortcutsModalEnabled={this.state.shortcutsModalEnabled}
+ toggleShortcutsModal={() => this.toggleShortcutsModal()}
+ />
+ )}
+ <ShortcutsModal
+ enabled={this.state.shortcutsModalEnabled}
+ handleClose={() => this.toggleShortcutsModal()}
+ />
+ </A11yIntention>
+ </AppErrorBoundary>
+ </div>
+ );
+ }
+}
+
+App.childContextTypes = {
+ toolboxDoc: PropTypes.object,
+ shortcuts: PropTypes.object,
+ l10n: PropTypes.object,
+ fluentBundles: PropTypes.array,
+};
+
+const mapStateToProps = state => ({
+ selectedSource: getSelectedSource(state),
+ startPanelCollapsed: getPaneCollapse(state, "start"),
+ endPanelCollapsed: getPaneCollapse(state, "end"),
+ activeSearch: getActiveSearch(state),
+ quickOpenEnabled: getQuickOpenEnabled(state),
+ orientation: getOrientation(state),
+});
+
+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..c81db9c598
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/BlackboxLines.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import { Component } from "react";
+import { toEditorLine, fromEditorLine } from "../../utils/editor";
+import { isLineBlackboxed } from "../../utils/source";
+import { isWasm } from "../../utils/wasm";
+
+// This renders blackbox line highlighting in the editor
+class BlackboxLines extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ blackboxedRangesForSelectedSource: PropTypes.array,
+ isSourceOnIgnoreList: PropTypes.bool,
+ };
+ }
+
+ componentDidMount() {
+ const { selectedSource, blackboxedRangesForSelectedSource, editor } =
+ this.props;
+
+ if (this.props.isSourceOnIgnoreList) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ // When `blackboxedRangesForSelectedSource` is defined and the array is empty,
+ // the whole source was blackboxed.
+ if (!blackboxedRangesForSelectedSource.length) {
+ this.setAllBlackboxLines(editor);
+ } else {
+ editor.codeMirror.operation(() => {
+ blackboxedRangesForSelectedSource.forEach(range => {
+ const start = toEditorLine(selectedSource.id, range.start.line);
+ const end = toEditorLine(selectedSource.id, range.end.line);
+ editor.codeMirror.eachLine(start, end, lineHandle => {
+ this.setBlackboxLine(editor, lineHandle);
+ });
+ });
+ });
+ }
+ }
+
+ componentDidUpdate() {
+ const {
+ selectedSource,
+ blackboxedRangesForSelectedSource,
+ editor,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ if (this.props.isSourceOnIgnoreList) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ // when unblackboxed
+ if (!blackboxedRangesForSelectedSource) {
+ this.clearAllBlackboxLines(editor);
+ return;
+ }
+
+ // When the whole source is blackboxed
+ if (!blackboxedRangesForSelectedSource.length) {
+ this.setAllBlackboxLines(editor);
+ return;
+ }
+
+ const sourceIsWasm = isWasm(selectedSource.id);
+
+ // TODO: Possible perf improvement. Instead of going
+ // over all the lines each time get diffs of what has
+ // changed and update those.
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ const line = fromEditorLine(
+ selectedSource.id,
+ editor.codeMirror.getLineNumber(lineHandle),
+ sourceIsWasm
+ );
+
+ if (
+ isLineBlackboxed(
+ blackboxedRangesForSelectedSource,
+ line,
+ isSourceOnIgnoreList
+ )
+ ) {
+ this.setBlackboxLine(editor, lineHandle);
+ } else {
+ this.clearBlackboxLine(editor, lineHandle);
+ }
+ });
+ });
+ }
+
+ componentWillUnmount() {
+ // Lets make sure we remove everything relating to
+ // blackboxing lines when this component is unmounted.
+ this.clearAllBlackboxLines(this.props.editor);
+ }
+
+ clearAllBlackboxLines(editor) {
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ this.clearBlackboxLine(editor, lineHandle);
+ });
+ });
+ }
+
+ setAllBlackboxLines(editor) {
+ //TODO:We might be able to handle the whole source
+ // than adding the blackboxing line by line
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ this.setBlackboxLine(editor, lineHandle);
+ });
+ });
+ }
+
+ clearBlackboxLine(editor, lineHandle) {
+ editor.codeMirror.removeLineClass(lineHandle, "wrap", "blackboxed-line");
+ }
+
+ setBlackboxLine(editor, lineHandle) {
+ editor.codeMirror.addLineClass(lineHandle, "wrap", "blackboxed-line");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default BlackboxLines;
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoint.js b/devtools/client/debugger/src/components/Editor/Breakpoint.js
new file mode 100644
index 0000000000..cce23c199f
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoint.js
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "react";
+import PropTypes from "prop-types";
+
+import { getDocument, toEditorLine } from "../../utils/editor";
+import { getSelectedLocation } from "../../utils/selected-location";
+import { features } from "../../utils/prefs";
+import { showMenu } from "../../context-menu/menu";
+import { breakpointItems } from "./menus/breakpoints";
+const classnames = require("devtools/client/shared/classnames.js");
+
+const breakpointSvg = document.createElement("div");
+breakpointSvg.innerHTML =
+ '<svg viewBox="0 0 60 15" width="60" height="15"><path d="M53.07.5H1.5c-.54 0-1 .46-1 1v12c0 .54.46 1 1 1h51.57c.58 0 1.15-.26 1.53-.7l4.7-6.3-4.7-6.3c-.38-.44-.95-.7-1.53-.7z"/></svg>';
+
+class Breakpoint extends PureComponent {
+ static get propTypes() {
+ return {
+ cx: PropTypes.object.isRequired,
+ breakpoint: PropTypes.object.isRequired,
+ breakpointActions: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ editorActions: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object,
+ blackboxedRangesForSelectedSource: PropTypes.array,
+ isSelectedSourceOnIgnoreList: PropTypes.bool.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addBreakpoint(this.props);
+ }
+
+ componentDidUpdate(prevProps) {
+ this.removeBreakpoint(prevProps);
+ this.addBreakpoint(this.props);
+ }
+
+ componentWillUnmount() {
+ this.removeBreakpoint(this.props);
+ }
+
+ makeMarker() {
+ const { breakpoint } = this.props;
+ const bp = breakpointSvg.cloneNode(true);
+
+ bp.className = classnames("editor new-breakpoint", {
+ "breakpoint-disabled": breakpoint.disabled,
+ "folding-enabled": features.codeFolding,
+ });
+ bp.onmousedown = this.onClick;
+ bp.oncontextmenu = this.onContextMenu;
+
+ return bp;
+ }
+
+ onClick = event => {
+ const { cx, breakpointActions, editorActions, breakpoint, selectedSource } =
+ this.props;
+
+ // ignore right clicks
+ if ((event.ctrlKey && event.button === 0) || event.button === 2) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+ if (event.metaKey) {
+ editorActions.continueToHere(cx, selectedLocation);
+ return;
+ }
+
+ if (event.shiftKey) {
+ breakpointActions.toggleBreakpointsAtLine(
+ cx,
+ !breakpoint.disabled,
+ selectedLocation.line
+ );
+ return;
+ }
+
+ breakpointActions.removeBreakpointsAtLine(
+ cx,
+ selectedLocation.sourceId,
+ selectedLocation.line
+ );
+ };
+
+ onContextMenu = event => {
+ const {
+ cx,
+ breakpoint,
+ selectedSource,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList,
+ } = this.props;
+ event.stopPropagation();
+ event.preventDefault();
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+
+ showMenu(
+ event,
+ breakpointItems(
+ cx,
+ breakpoint,
+ selectedLocation,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+ )
+ );
+ };
+
+ addBreakpoint(props) {
+ const { breakpoint, editor, selectedSource } = props;
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+
+ // Hidden Breakpoints are never rendered on the client
+ if (breakpoint.options.hidden) {
+ return;
+ }
+
+ if (!selectedSource) {
+ return;
+ }
+
+ const sourceId = selectedSource.id;
+ const line = toEditorLine(sourceId, selectedLocation.line);
+ const doc = getDocument(sourceId);
+
+ doc.setGutterMarker(line, "breakpoints", this.makeMarker());
+
+ editor.codeMirror.addLineClass(line, "wrap", "new-breakpoint");
+ editor.codeMirror.removeLineClass(line, "wrap", "breakpoint-disabled");
+ editor.codeMirror.removeLineClass(line, "wrap", "has-condition");
+ editor.codeMirror.removeLineClass(line, "wrap", "has-log");
+
+ if (breakpoint.disabled) {
+ editor.codeMirror.addLineClass(line, "wrap", "breakpoint-disabled");
+ }
+
+ if (breakpoint.options.logValue) {
+ editor.codeMirror.addLineClass(line, "wrap", "has-log");
+ } else if (breakpoint.options.condition) {
+ editor.codeMirror.addLineClass(line, "wrap", "has-condition");
+ }
+ }
+
+ removeBreakpoint(props) {
+ const { selectedSource, breakpoint } = props;
+ if (!selectedSource) {
+ return;
+ }
+
+ const sourceId = selectedSource.id;
+ const doc = getDocument(sourceId);
+
+ if (!doc) {
+ return;
+ }
+
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+ const line = toEditorLine(sourceId, selectedLocation.line);
+
+ doc.setGutterMarker(line, "breakpoints", null);
+ doc.removeLineClass(line, "wrap", "new-breakpoint");
+ doc.removeLineClass(line, "wrap", "breakpoint-disabled");
+ doc.removeLineClass(line, "wrap", "has-condition");
+ doc.removeLineClass(line, "wrap", "has-log");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default Breakpoint;
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.css b/devtools/client/debugger/src/components/Editor/Breakpoints.css
new file mode 100644
index 0000000000..1269f73f82
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoints.css
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.theme-light {
+ --gutter-hover-background-color: #dde1e4;
+ --breakpoint-fill: var(--blue-50);
+ --breakpoint-stroke: var(--blue-60);
+}
+
+.theme-dark {
+ --gutter-hover-background-color: #414141;
+ --breakpoint-fill: var(--blue-55);
+ --breakpoint-stroke: var(--blue-40);
+}
+
+.theme-light,
+.theme-dark {
+ --logpoint-fill: var(--theme-graphs-purple);
+ --logpoint-stroke: var(--purple-60);
+ --breakpoint-condition-fill: var(--theme-graphs-yellow);
+ --breakpoint-condition-stroke: var(--theme-graphs-orange);
+ --breakpoint-skipped-opacity: 0.15;
+ --breakpoint-inactive-opacity: 0.3;
+ --breakpoint-disabled-opacity: 0.6;
+}
+
+/* Standard gutter breakpoints */
+.editor-wrapper .breakpoints {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
+
+.new-breakpoint .CodeMirror-linenumber {
+ pointer-events: none;
+}
+
+.editor-wrapper :not(.empty-line, .new-breakpoint)
+ > .CodeMirror-gutter-wrapper
+ > .CodeMirror-linenumber:hover::after {
+ content: "";
+ position: absolute;
+ /* paint below the number */
+ z-index: -1;
+ top: 0;
+ left: 0;
+ right: -4px;
+ bottom: 0;
+ height: 15px;
+ background-color: var(--gutter-hover-background-color);
+ mask: url(chrome://devtools/content/debugger/images/breakpoint.svg)
+ no-repeat;
+ mask-size: auto 15px;
+ mask-position: right;
+}
+
+.editor.new-breakpoint svg {
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ width: 60px;
+ height: 15px;
+ position: absolute;
+ top: 0px;
+ right: -4px;
+}
+
+.editor .breakpoint {
+ position: absolute;
+ right: -2px;
+}
+
+.editor.new-breakpoint.folding-enabled svg {
+ right: -16px;
+}
+
+.new-breakpoint.has-condition .CodeMirror-gutter-wrapper svg {
+ fill: var(--breakpoint-condition-fill);
+ stroke: var(--breakpoint-condition-stroke);
+}
+
+.new-breakpoint.has-log .CodeMirror-gutter-wrapper svg {
+ fill: var(--logpoint-fill);
+ stroke: var(--logpoint-stroke);
+}
+
+.editor.new-breakpoint.breakpoint-disabled svg,
+.blackboxed-line .editor.new-breakpoint svg {
+ fill-opacity: var(--breakpoint-disabled-opacity);
+ stroke-opacity: var(--breakpoint-disabled-opacity);
+}
+
+.editor-wrapper.skip-pausing .editor.new-breakpoint svg {
+ fill-opacity: var(--breakpoint-skipped-opacity);
+}
+
+/* Columnn breakpoints */
+.column-breakpoint {
+ display: inline;
+ padding-inline-start: 1px;
+ padding-inline-end: 1px;
+}
+
+.column-breakpoint:hover {
+ background-color: transparent;
+}
+
+.column-breakpoint svg {
+ display: inline-block;
+ cursor: pointer;
+ height: 13px;
+ width: 11px;
+ vertical-align: top;
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ fill-opacity: var(--breakpoint-inactive-opacity);
+ stroke-opacity: var(--breakpoint-inactive-opacity);
+}
+
+.column-breakpoint.active svg {
+ fill: var(--breakpoint-fill);
+ stroke: var(--breakpoint-stroke);
+ fill-opacity: 1;
+ stroke-opacity: 1;
+}
+
+.column-breakpoint.disabled svg {
+ fill-opacity: var(--breakpoint-disabled-opacity);
+ stroke-opacity: var(--breakpoint-disabled-opacity);
+}
+
+.column-breakpoint.has-log.disabled svg {
+ fill-opacity: 0.5;
+ stroke-opacity: 0.5;
+}
+
+.column-breakpoint.has-condition svg {
+ fill: var(--breakpoint-condition-fill);
+ stroke: var(--breakpoint-condition-stroke);
+}
+
+.column-breakpoint.has-log svg {
+ fill: var(--logpoint-fill);
+ stroke: var(--logpoint-stroke);
+}
+
+.editor-wrapper.skip-pausing .column-breakpoint svg {
+ fill-opacity: var(--breakpoint-skipped-opacity);
+}
+
+.img.column-marker {
+ background-image: url(chrome://devtools/content/debugger/images/column-marker.svg);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Breakpoints.js b/devtools/client/debugger/src/components/Editor/Breakpoints.js
new file mode 100644
index 0000000000..36added4ee
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Breakpoints.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import React, { Component } from "react";
+import Breakpoint from "./Breakpoint";
+
+import {
+ getSelectedSource,
+ getFirstVisibleBreakpoints,
+ getBlackBoxRanges,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+import { makeBreakpointId } from "../../utils/breakpoint";
+import { connect } from "../../utils/connect";
+import { breakpointItemActions } from "./menus/breakpoints";
+import { editorItemActions } from "./menus/editor";
+
+class Breakpoints extends Component {
+ static get propTypes() {
+ return {
+ cx: PropTypes.object,
+ breakpoints: PropTypes.array,
+ editor: PropTypes.object,
+ breakpointActions: PropTypes.object,
+ editorActions: PropTypes.object,
+ selectedSource: PropTypes.object,
+ blackboxedRanges: PropTypes.object,
+ isSelectedSourceOnIgnoreList: PropTypes.bool,
+ blackboxedRangesForSelectedSource: PropTypes.array,
+ };
+ }
+ render() {
+ const {
+ cx,
+ breakpoints,
+ selectedSource,
+ editor,
+ breakpointActions,
+ editorActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList,
+ } = this.props;
+
+ if (!selectedSource || !breakpoints) {
+ return null;
+ }
+
+ return (
+ <div>
+ {breakpoints.map(bp => {
+ return (
+ <Breakpoint
+ cx={cx}
+ key={makeBreakpointId(bp.location)}
+ breakpoint={bp}
+ selectedSource={selectedSource}
+ blackboxedRangesForSelectedSource={
+ blackboxedRangesForSelectedSource
+ }
+ isSelectedSourceOnIgnoreList={isSelectedSourceOnIgnoreList}
+ editor={editor}
+ breakpointActions={breakpointActions}
+ editorActions={editorActions}
+ />
+ );
+ })}
+ </div>
+ );
+ }
+}
+
+export default connect(
+ state => {
+ const selectedSource = getSelectedSource(state);
+ const blackboxedRanges = getBlackBoxRanges(state);
+ return {
+ // Retrieves only the first breakpoint per line so that the
+ // breakpoint marker represents only the first breakpoint
+ breakpoints: getFirstVisibleBreakpoints(state),
+ selectedSource,
+ blackboxedRangesForSelectedSource:
+ selectedSource && blackboxedRanges[selectedSource.url],
+ isSelectedSourceOnIgnoreList:
+ selectedSource &&
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, selectedSource),
+ };
+ },
+ dispatch => ({
+ breakpointActions: breakpointItemActions(dispatch),
+ editorActions: editorItemActions(dispatch),
+ })
+)(Breakpoints);
diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js
new file mode 100644
index 0000000000..0577a61f5c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoint.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { showMenu } from "../../context-menu/menu";
+
+import { getDocument } from "../../utils/editor";
+import { breakpointItems, createBreakpointItems } from "./menus/breakpoints";
+import { getSelectedLocation } from "../../utils/selected-location";
+const classnames = require("devtools/client/shared/classnames.js");
+
+// eslint-disable-next-line max-len
+
+const breakpointButton = document.createElement("button");
+breakpointButton.innerHTML =
+ '<svg viewBox="0 0 11 13" width="11" height="13"><path d="M5.07.5H1.5c-.54 0-1 .46-1 1v10c0 .54.46 1 1 1h3.57c.58 0 1.15-.26 1.53-.7l3.7-5.3-3.7-5.3C6.22.76 5.65.5 5.07.5z"/></svg>';
+
+function makeBookmark({ breakpoint }, { onClick, onContextMenu }) {
+ const bp = breakpointButton.cloneNode(true);
+
+ const isActive = breakpoint && !breakpoint.disabled;
+ const isDisabled = breakpoint?.disabled;
+ const condition = breakpoint?.options.condition;
+ const logValue = breakpoint?.options.logValue;
+
+ bp.className = classnames("column-breakpoint", {
+ "has-condition": condition,
+ "has-log": logValue,
+ active: isActive,
+ disabled: isDisabled,
+ });
+
+ bp.setAttribute("title", logValue || condition || "");
+ bp.onclick = onClick;
+ bp.oncontextmenu = onContextMenu;
+
+ return bp;
+}
+
+export default class ColumnBreakpoint extends PureComponent {
+ bookmark;
+
+ static get propTypes() {
+ return {
+ breakpointActions: PropTypes.object.isRequired,
+ columnBreakpoint: PropTypes.object.isRequired,
+ cx: PropTypes.object.isRequired,
+ source: PropTypes.object.isRequired,
+ };
+ }
+
+ addColumnBreakpoint = nextProps => {
+ const { columnBreakpoint, source } = nextProps || this.props;
+
+ const sourceId = source.id;
+ const doc = getDocument(sourceId);
+ if (!doc) {
+ return;
+ }
+
+ const { line, column } = columnBreakpoint.location;
+ const widget = makeBookmark(columnBreakpoint, {
+ onClick: this.onClick,
+ onContextMenu: this.onContextMenu,
+ });
+
+ this.bookmark = doc.setBookmark({ line: line - 1, ch: column }, { widget });
+ };
+
+ clearColumnBreakpoint = () => {
+ if (this.bookmark) {
+ this.bookmark.clear();
+ this.bookmark = null;
+ }
+ };
+
+ onClick = event => {
+ event.stopPropagation();
+ event.preventDefault();
+ const { cx, columnBreakpoint, breakpointActions } = this.props;
+
+ // disable column breakpoint on shift-click.
+ if (event.shiftKey) {
+ const breakpoint = columnBreakpoint.breakpoint;
+ breakpointActions.toggleDisabledBreakpoint(cx, breakpoint);
+ return;
+ }
+
+ if (columnBreakpoint.breakpoint) {
+ breakpointActions.removeBreakpoint(cx, columnBreakpoint.breakpoint);
+ } else {
+ breakpointActions.addBreakpoint(cx, columnBreakpoint.location);
+ }
+ };
+
+ onContextMenu = event => {
+ event.stopPropagation();
+ event.preventDefault();
+ const {
+ cx,
+ columnBreakpoint: { breakpoint, location },
+ source,
+ breakpointActions,
+ } = this.props;
+
+ let items = createBreakpointItems(cx, location, breakpointActions);
+
+ if (breakpoint) {
+ const selectedLocation = getSelectedLocation(breakpoint, source);
+
+ items = breakpointItems(
+ cx,
+ breakpoint,
+ selectedLocation,
+ breakpointActions
+ );
+ }
+
+ showMenu(event, items);
+ };
+
+ componentDidMount() {
+ this.addColumnBreakpoint();
+ }
+
+ componentWillUnmount() {
+ this.clearColumnBreakpoint();
+ }
+
+ componentDidUpdate() {
+ this.clearColumnBreakpoint();
+ this.addColumnBreakpoint();
+ }
+
+ render() {
+ return null;
+ }
+}
diff --git a/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js
new file mode 100644
index 0000000000..62c2ab29e3
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ColumnBreakpoints.js
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import ColumnBreakpoint from "./ColumnBreakpoint";
+
+import {
+ getSelectedSource,
+ visibleColumnBreakpoints,
+ getContext,
+ isSourceBlackBoxed,
+} from "../../selectors";
+import { connect } from "../../utils/connect";
+import { makeBreakpointId } from "../../utils/breakpoint";
+import { breakpointItemActions } from "./menus/breakpoints";
+
+// eslint-disable-next-line max-len
+
+class ColumnBreakpoints extends Component {
+ static get propTypes() {
+ return {
+ breakpointActions: PropTypes.object.isRequired,
+ columnBreakpoints: PropTypes.array.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object,
+ };
+ }
+
+ render() {
+ const { cx, editor, columnBreakpoints, selectedSource, breakpointActions } =
+ this.props;
+
+ if (!selectedSource || columnBreakpoints.length === 0) {
+ return null;
+ }
+
+ let breakpoints;
+ editor.codeMirror.operation(() => {
+ breakpoints = columnBreakpoints.map(breakpoint => (
+ <ColumnBreakpoint
+ cx={cx}
+ key={makeBreakpointId(breakpoint.location)}
+ columnBreakpoint={breakpoint}
+ editor={editor}
+ source={selectedSource}
+ breakpointActions={breakpointActions}
+ />
+ ));
+ });
+ return <div>{breakpoints}</div>;
+ }
+}
+
+const mapStateToProps = state => {
+ // Avoid rendering this component is there is no selected source,
+ // or if the selected source is blackboxed.
+ // Also avoid computing visible column breakpoint when this happens.
+ const selectedSource = getSelectedSource(state);
+ if (!selectedSource || isSourceBlackBoxed(state, selectedSource)) {
+ return {};
+ }
+ return {
+ cx: getContext(state),
+ selectedSource,
+ columnBreakpoints: visibleColumnBreakpoints(state),
+ };
+};
+
+export default connect(mapStateToProps, dispatch => ({
+ breakpointActions: breakpointItemActions(dispatch),
+}))(ColumnBreakpoints);
diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.css b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css
new file mode 100644
index 0000000000..4ce8dbcd8c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.css
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.conditional-breakpoint-panel {
+ cursor: initial;
+ margin: 1em 0;
+ position: relative;
+ display: flex;
+ align-items: center;
+ background: var(--theme-toolbar-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.conditional-breakpoint-panel .prompt {
+ font-size: 1.8em;
+ color: var(--theme-graphs-orange);
+ padding-left: 3px;
+ padding-right: 3px;
+ padding-bottom: 3px;
+ text-align: right;
+ width: 30px;
+ align-self: baseline;
+ margin-top: 3px;
+}
+
+.conditional-breakpoint-panel.log-point .prompt {
+ color: var(--purple-60);
+}
+
+.conditional-breakpoint-panel .CodeMirror {
+ margin: 6px 10px;
+}
+
+.conditional-breakpoint-panel .CodeMirror pre.CodeMirror-placeholder {
+ /* Match the color of the placeholder text to existing inputs in the Debugger */
+ color: var(--theme-text-color-alt);
+}
diff --git a/devtools/client/debugger/src/components/Editor/ConditionalPanel.js b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js
new file mode 100644
index 0000000000..e451ffa960
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/ConditionalPanel.js
@@ -0,0 +1,274 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import ReactDOM from "react-dom";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import "./ConditionalPanel.css";
+import { toEditorLine } from "../../utils/editor";
+import { prefs } from "../../utils/prefs";
+import actions from "../../actions";
+
+import {
+ getClosestBreakpoint,
+ getConditionalPanelLocation,
+ getLogPointStatus,
+ getContext,
+} from "../../selectors";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+function addNewLine(doc) {
+ const cursor = doc.getCursor();
+ const pos = { line: cursor.line, ch: cursor.ch };
+ doc.replaceRange("\n", pos);
+}
+
+export class ConditionalPanel extends PureComponent {
+ cbPanel;
+ input;
+ codeMirror;
+ panelNode;
+ scrollParent;
+
+ constructor() {
+ super();
+ this.cbPanel = null;
+ }
+
+ static get propTypes() {
+ return {
+ breakpoint: PropTypes.object,
+ closeConditionalPanel: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ location: PropTypes.any.isRequired,
+ log: PropTypes.bool.isRequired,
+ openConditionalPanel: PropTypes.func.isRequired,
+ setBreakpointOptions: PropTypes.func.isRequired,
+ };
+ }
+
+ keepFocusOnInput() {
+ if (this.input) {
+ this.input.focus();
+ }
+ }
+
+ saveAndClose = () => {
+ if (this.input) {
+ this.setBreakpoint(this.input.value.trim());
+ }
+
+ this.props.closeConditionalPanel();
+ };
+
+ onKey = e => {
+ if (e.key === "Enter") {
+ if (this.codeMirror && e.altKey) {
+ addNewLine(this.codeMirror.doc);
+ } else {
+ this.saveAndClose();
+ }
+ } else if (e.key === "Escape") {
+ this.props.closeConditionalPanel();
+ }
+ };
+
+ setBreakpoint(value) {
+ const { cx, log, breakpoint } = this.props;
+ // If breakpoint is `pending`, props will not contain a breakpoint.
+ // If source is a URL without location, breakpoint will contain no generatedLocation.
+ const location =
+ breakpoint && breakpoint.generatedLocation
+ ? breakpoint.generatedLocation
+ : this.props.location;
+ const options = breakpoint ? breakpoint.options : {};
+ const type = log ? "logValue" : "condition";
+ return this.props.setBreakpointOptions(cx, location, {
+ ...options,
+ [type]: value,
+ });
+ }
+
+ clearConditionalPanel() {
+ if (this.cbPanel) {
+ this.cbPanel.clear();
+ this.cbPanel = null;
+ }
+ if (this.scrollParent) {
+ this.scrollParent.removeEventListener("scroll", this.repositionOnScroll);
+ }
+ }
+
+ repositionOnScroll = () => {
+ if (this.panelNode && this.scrollParent) {
+ const { scrollLeft } = this.scrollParent;
+ this.panelNode.style.transform = `translateX(${scrollLeft}px)`;
+ }
+ };
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillMount() {
+ return this.renderToWidget(this.props);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate() {
+ return this.clearConditionalPanel();
+ }
+
+ componentDidUpdate(prevProps) {
+ this.keepFocusOnInput();
+ }
+
+ componentWillUnmount() {
+ // This is called if CodeMirror is re-initializing itself before the
+ // user closes the conditional panel. Clear the widget, and re-render it
+ // as soon as this component gets remounted
+ return this.clearConditionalPanel();
+ }
+
+ renderToWidget(props) {
+ if (this.cbPanel) {
+ this.clearConditionalPanel();
+ }
+ const { location, editor } = props;
+
+ const editorLine = toEditorLine(location.sourceId, location.line || 0);
+ this.cbPanel = editor.codeMirror.addLineWidget(
+ editorLine,
+ this.renderConditionalPanel(props),
+ {
+ coverGutter: true,
+ noHScroll: true,
+ }
+ );
+
+ if (this.input) {
+ let parent = this.input.parentNode;
+ while (parent) {
+ if (
+ parent instanceof HTMLElement &&
+ parent.classList.contains("CodeMirror-scroll")
+ ) {
+ this.scrollParent = parent;
+ break;
+ }
+ parent = parent.parentNode;
+ }
+
+ if (this.scrollParent) {
+ this.scrollParent.addEventListener("scroll", this.repositionOnScroll);
+ this.repositionOnScroll();
+ }
+ }
+ }
+
+ createEditor = input => {
+ const { log, editor, closeConditionalPanel } = this.props;
+ const codeMirror = editor.CodeMirror.fromTextArea(input, {
+ mode: "javascript",
+ theme: "mozilla",
+ placeholder: L10N.getStr(
+ log
+ ? "editor.conditionalPanel.logPoint.placeholder2"
+ : "editor.conditionalPanel.placeholder2"
+ ),
+ cursorBlinkRate: prefs.cursorBlinkRate,
+ });
+
+ codeMirror.on("keydown", (cm, e) => {
+ if (e.key === "Enter") {
+ e.codemirrorIgnore = true;
+ }
+ });
+
+ codeMirror.on("blur", (cm, e) => {
+ if (
+ e?.relatedTarget &&
+ e.relatedTarget.closest(".conditional-breakpoint-panel")
+ ) {
+ return;
+ }
+
+ closeConditionalPanel();
+ });
+
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirrorWrapper.addEventListener("keydown", e => {
+ codeMirror.save();
+ this.onKey(e);
+ });
+
+ this.input = input;
+ this.codeMirror = codeMirror;
+ codeMirror.focus();
+ codeMirror.setCursor(codeMirror.lineCount(), 0);
+ };
+
+ getDefaultValue() {
+ const { breakpoint, log } = this.props;
+ const options = breakpoint?.options || {};
+ return log ? options.logValue : options.condition;
+ }
+
+ renderConditionalPanel(props) {
+ const { log } = props;
+ const defaultValue = this.getDefaultValue();
+
+ const panel = document.createElement("div");
+ ReactDOM.render(
+ <div
+ className={classnames("conditional-breakpoint-panel", {
+ "log-point": log,
+ })}
+ onClick={() => this.keepFocusOnInput()}
+ ref={node => (this.panelNode = node)}
+ >
+ <div className="prompt">»</div>
+ <textarea
+ defaultValue={defaultValue}
+ ref={input => this.createEditor(input)}
+ />
+ </div>,
+ panel
+ );
+ return panel;
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = state => {
+ const location = getConditionalPanelLocation(state);
+
+ if (!location) {
+ throw new Error("Conditional panel location needed.");
+ }
+
+ const breakpoint = getClosestBreakpoint(state, location);
+
+ return {
+ cx: getContext(state),
+ breakpoint,
+ location,
+ log: getLogPointStatus(state),
+ };
+};
+
+const { setBreakpointOptions, openConditionalPanel, closeConditionalPanel } =
+ actions;
+
+const mapDispatchToProps = {
+ setBreakpointOptions,
+ openConditionalPanel,
+ closeConditionalPanel,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ConditionalPanel);
diff --git a/devtools/client/debugger/src/components/Editor/DebugLine.js b/devtools/client/debugger/src/components/Editor/DebugLine.js
new file mode 100644
index 0000000000..95cfc5a94d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/DebugLine.js
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "react";
+import PropTypes from "prop-types";
+import {
+ toEditorPosition,
+ getDocument,
+ hasDocument,
+ startOperation,
+ endOperation,
+ getTokenEnd,
+} from "../../utils/editor";
+import { isException } from "../../utils/pause";
+import { getIndentation } from "../../utils/indentation";
+import { connect } from "../../utils/connect";
+import {
+ getVisibleSelectedFrame,
+ getPauseReason,
+ getSourceTextContent,
+ getCurrentThread,
+} from "../../selectors";
+
+export class DebugLine extends PureComponent {
+ debugExpression;
+
+ static get propTypes() {
+ return {
+ location: PropTypes.object,
+ why: PropTypes.object,
+ };
+ }
+
+ componentDidMount() {
+ const { why, location } = this.props;
+ this.setDebugLine(why, location);
+ }
+
+ componentWillUnmount() {
+ const { why, location } = this.props;
+ this.clearDebugLine(why, location);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { why, location } = this.props;
+
+ startOperation();
+ this.clearDebugLine(prevProps.why, prevProps.location);
+ this.setDebugLine(why, location);
+ endOperation();
+ }
+
+ setDebugLine(why, location) {
+ if (!location) {
+ return;
+ }
+ const { sourceId } = location;
+ const doc = getDocument(sourceId);
+
+ let { line, column } = toEditorPosition(location);
+ let { markTextClass, lineClass } = this.getTextClasses(why);
+ doc.addLineClass(line, "wrap", lineClass);
+
+ const lineText = doc.getLine(line);
+ column = Math.max(column, getIndentation(lineText));
+
+ // If component updates because user clicks on
+ // another source tab, codeMirror will be null.
+ const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null;
+
+ if (columnEnd === null) {
+ markTextClass += " to-line-end";
+ }
+
+ this.debugExpression = doc.markText(
+ { ch: column, line },
+ { ch: columnEnd, line },
+ { className: markTextClass }
+ );
+ }
+
+ clearDebugLine(why, location) {
+ // Avoid clearing the line if we didn't set a debug line before,
+ // or, if the document is no longer available
+ if (!location || !hasDocument(location.sourceId)) {
+ return;
+ }
+
+ if (this.debugExpression) {
+ this.debugExpression.clear();
+ }
+
+ const { line } = toEditorPosition(location);
+ const doc = getDocument(location.sourceId);
+ const { lineClass } = this.getTextClasses(why);
+ doc.removeLineClass(line, "wrap", lineClass);
+ }
+
+ getTextClasses(why) {
+ if (why && isException(why)) {
+ return {
+ markTextClass: "debug-expression-error",
+ lineClass: "new-debug-line-error",
+ };
+ }
+
+ return { markTextClass: "debug-expression", lineClass: "new-debug-line" };
+ }
+
+ render() {
+ return null;
+ }
+}
+
+function isDocumentReady(location, sourceTextContent) {
+ return location && sourceTextContent && hasDocument(location.sourceId);
+}
+
+const mapStateToProps = state => {
+ // Avoid unecessary intermediate updates when there is no location
+ // or the source text content isn't yet fully loaded
+ const frame = getVisibleSelectedFrame(state);
+ const location = frame?.location;
+ if (!location) {
+ return {};
+ }
+ const sourceTextContent = getSourceTextContent(state, location);
+ if (!isDocumentReady(location, sourceTextContent)) {
+ return {};
+ }
+ return {
+ location,
+ why: getPauseReason(state, getCurrentThread(state)),
+ };
+};
+
+export default connect(mapStateToProps)(DebugLine);
diff --git a/devtools/client/debugger/src/components/Editor/Editor.css b/devtools/client/debugger/src/components/Editor/Editor.css
new file mode 100644
index 0000000000..7ea45c629d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Editor.css
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.editor-wrapper {
+ --debug-line-border: rgb(145, 188, 219);
+ --debug-expression-background: rgba(202, 227, 255, 0.5);
+ --debug-line-error-border: rgb(255, 0, 0);
+ --debug-expression-error-background: rgba(231, 116, 113, 0.3);
+ --line-exception-background: hsl(344, 73%, 97%);
+ --highlight-line-duration: 5000ms;
+}
+
+.theme-dark .editor-wrapper {
+ --debug-expression-background: rgba(202, 227, 255, 0.3);
+ --debug-line-border: #7786a2;
+ --line-exception-background: hsl(345, 23%, 24%);
+}
+
+.editor-wrapper .CodeMirror-linewidget {
+ margin-right: -7px;
+}
+
+.editor-wrapper {
+ min-width: 0 !important;
+}
+
+.CodeMirror.cm-s-mozilla,
+.CodeMirror-scroll,
+.CodeMirror-sizer {
+ overflow-anchor: none;
+}
+
+/* Prevents inline preview from shifting source height (#1576163) */
+.CodeMirror-linewidget {
+ padding: 0;
+ display: flow-root;
+}
+
+/**
+ * There's a known codemirror flex issue with chrome that this addresses.
+ * BUG https://github.com/firefox-devtools/debugger/issues/63
+ */
+.editor-wrapper {
+ position: absolute;
+ width: calc(100% - 1px);
+ top: var(--editor-header-height);
+ bottom: var(--editor-footer-height);
+ left: 0px;
+}
+
+html[dir="rtl"] .editor-mount {
+ direction: ltr;
+}
+
+.function-search {
+ max-height: 300px;
+ overflow: hidden;
+}
+
+.function-search .results {
+ height: auto;
+}
+
+.editor.hit-marker {
+ height: 15px;
+}
+
+.editor-wrapper .highlight-lines {
+ background: var(--theme-selection-background-hover);
+}
+
+.CodeMirror {
+ width: 100%;
+ height: 100%;
+}
+
+.editor-wrapper .editor-mount {
+ width: 100%;
+ background-color: var(--theme-body-background);
+ font-size: var(--theme-code-font-size);
+ line-height: var(--theme-code-line-height);
+}
+
+/* set the linenumber white when there is a breakpoint */
+.editor-wrapper:not(.skip-pausing)
+ .new-breakpoint
+ .CodeMirror-gutter-wrapper
+ .CodeMirror-linenumber {
+ color: white;
+}
+
+/* move the breakpoint below the other gutter elements */
+.new-breakpoint .CodeMirror-gutter-elt:nth-child(2) {
+ z-index: 0;
+}
+
+.theme-dark .editor-wrapper .CodeMirror-line .cm-comment {
+ color: var(--theme-comment);
+}
+
+.debug-expression {
+ background-color: var(--debug-expression-background);
+ border-style: solid;
+ border-color: var(--debug-expression-background);
+ border-width: 1px 0px 1px 0px;
+ position: relative;
+}
+
+.debug-expression::before {
+ content: "";
+ line-height: 1px;
+ border-top: 1px solid var(--blue-50);
+ background: transparent;
+ position: absolute;
+ top: -2px;
+ left: 0px;
+ width: 100%;
+ }
+
+.debug-expression::after {
+ content: "";
+ line-height: 1px;
+ border-bottom: 1px solid var(--blue-50);
+ position: absolute;
+ bottom: -2px;
+ left: 0px;
+ width: 100%;
+ }
+
+.to-line-end ~ .CodeMirror-widget {
+ background-color: var(--debug-expression-background);
+}
+
+.debug-expression-error {
+ background-color: var(--debug-expression-error-background);
+}
+
+.new-debug-line > .CodeMirror-line {
+ background-color: transparent !important;
+ outline: var(--debug-line-border) solid 1px;
+}
+
+/* Don't display the highlight color since the debug line
+ is already highlighted */
+.new-debug-line .CodeMirror-activeline-background {
+ display: none;
+}
+
+.new-debug-line-error > .CodeMirror-line {
+ background-color: var(--debug-expression-error-background) !important;
+ outline: var(--debug-line-error-border) solid 1px;
+}
+
+/* Don't display the highlight color since the debug line
+ is already highlighted */
+.new-debug-line-error .CodeMirror-activeline-background {
+ display: none;
+}
+.highlight-line .CodeMirror-line {
+ animation-name: fade-highlight-out;
+ animation-duration: var(--highlight-line-duration);
+ animation-timing-function: ease-out;
+ animation-direction: forwards;
+}
+
+@keyframes fade-highlight-out {
+ 0% {
+ background-color: var(--theme-contrast-background);
+ }
+ 30% {
+ background-color: var(--theme-contrast-background);
+ }
+ 100% {
+ background-color: transparent;
+ }
+}
+
+.visible {
+ visibility: visible;
+}
+
+/* Code folding */
+.editor-wrapper .CodeMirror-foldgutter-open {
+ color: var(--grey-40);
+}
+
+.editor-wrapper .CodeMirror-foldgutter-open,
+.editor-wrapper .CodeMirror-foldgutter-folded {
+ fill: var(--grey-40);
+}
+
+.editor-wrapper .CodeMirror-foldgutter-open::before,
+.editor-wrapper .CodeMirror-foldgutter-open::after {
+ border-top: none;
+}
+
+.editor-wrapper .CodeMirror-foldgutter-folded::before,
+.editor-wrapper .CodeMirror-foldgutter-folded::after {
+ border-left: none;
+}
+
+.editor-wrapper .CodeMirror-foldgutter .CodeMirror-guttermarker-subtle {
+ visibility: visible;
+}
+
+.editor-wrapper .CodeMirror-foldgutter .CodeMirror-linenumber {
+ text-align: left;
+ padding: 0 0 0 2px;
+}
+
+/* Exception line */
+.line-exception {
+ background-color: var(--line-exception-background);
+}
+
+.mark-text-exception {
+ text-decoration: var(--red-50) wavy underline;
+ text-decoration-skip-ink: none;
+}
diff --git a/devtools/client/debugger/src/components/Editor/EditorMenu.js b/devtools/client/debugger/src/components/Editor/EditorMenu.js
new file mode 100644
index 0000000000..a865fcc9bd
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/EditorMenu.js
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "react";
+import PropTypes from "prop-types";
+
+import { connect } from "../../utils/connect";
+import { showMenu } from "../../context-menu/menu";
+
+import { getSourceLocationFromMouseEvent } from "../../utils/editor";
+import { isPretty } from "../../utils/source";
+import {
+ getPrettySource,
+ getIsCurrentThreadPaused,
+ getThreadContext,
+ isSourceWithMap,
+ getBlackBoxRanges,
+ isSourceOnSourceMapIgnoreList,
+ isSourceMapIgnoreListEnabled,
+} from "../../selectors";
+
+import { editorMenuItems, editorItemActions } from "./menus/editor";
+
+class EditorMenu extends Component {
+ static get propTypes() {
+ return {
+ clearContextMenu: PropTypes.func.isRequired,
+ contextMenu: PropTypes.object,
+ isSourceOnIgnoreList: PropTypes.bool,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate(nextProps) {
+ this.props.clearContextMenu();
+ if (nextProps.contextMenu) {
+ this.showMenu(nextProps);
+ }
+ }
+
+ showMenu(props) {
+ const {
+ cx,
+ editor,
+ selectedSource,
+ blackboxedRanges,
+ editorActions,
+ hasMappedLocation,
+ isPaused,
+ editorWrappingEnabled,
+ contextMenu: event,
+ isSourceOnIgnoreList,
+ } = props;
+
+ const location = getSourceLocationFromMouseEvent(
+ editor,
+ selectedSource,
+ // Use a coercion, as contextMenu is optional
+ event
+ );
+
+ showMenu(
+ event,
+ editorMenuItems({
+ cx,
+ editorActions,
+ selectedSource,
+ blackboxedRanges,
+ hasMappedLocation,
+ location,
+ isPaused,
+ editorWrappingEnabled,
+ selectionText: editor.codeMirror.getSelection().trim(),
+ isTextSelected: editor.codeMirror.somethingSelected(),
+ editor,
+ isSourceOnIgnoreList,
+ })
+ );
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = (state, props) => {
+ // This component is a no-op when contextmenu is false
+ if (!props.contextMenu) {
+ return {};
+ }
+ return {
+ cx: getThreadContext(state),
+ blackboxedRanges: getBlackBoxRanges(state),
+ isPaused: getIsCurrentThreadPaused(state),
+ hasMappedLocation:
+ (props.selectedSource.isOriginal ||
+ isSourceWithMap(state, props.selectedSource.id) ||
+ isPretty(props.selectedSource)) &&
+ !getPrettySource(state, props.selectedSource.id),
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, props.selectedSource),
+ };
+};
+
+const mapDispatchToProps = dispatch => ({
+ editorActions: editorItemActions(dispatch),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditorMenu);
diff --git a/devtools/client/debugger/src/components/Editor/EmptyLines.js b/devtools/client/debugger/src/components/Editor/EmptyLines.js
new file mode 100644
index 0000000000..70a8c9c0a7
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/EmptyLines.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { connect } from "../../utils/connect";
+import { Component } from "react";
+import PropTypes from "prop-types";
+import { getSelectedSource, getSelectedBreakableLines } from "../../selectors";
+import { fromEditorLine } from "../../utils/editor";
+import { isWasm } from "../../utils/wasm";
+
+class EmptyLines extends Component {
+ static get propTypes() {
+ return {
+ breakableLines: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.disableEmptyLines();
+ }
+
+ componentDidUpdate() {
+ this.disableEmptyLines();
+ }
+
+ componentWillUnmount() {
+ const { editor } = this.props;
+
+ editor.codeMirror.operation(() => {
+ editor.codeMirror.eachLine(lineHandle => {
+ editor.codeMirror.removeLineClass(lineHandle, "wrap", "empty-line");
+ });
+ });
+ }
+
+ shouldComponentUpdate(nextProps) {
+ const { breakableLines, selectedSource } = this.props;
+ return (
+ // Breakable lines are something that evolves over time,
+ // but we either have them loaded or not. So only compare the size
+ // as sometimes we always get a blank new empty Set instance.
+ breakableLines.size != nextProps.breakableLines.size ||
+ selectedSource.id != nextProps.selectedSource.id
+ );
+ }
+
+ disableEmptyLines() {
+ const { breakableLines, selectedSource, editor } = this.props;
+
+ const { codeMirror } = editor;
+ const isSourceWasm = isWasm(selectedSource.id);
+
+ codeMirror.operation(() => {
+ const lineCount = codeMirror.lineCount();
+ for (let i = 0; i < lineCount; i++) {
+ const line = fromEditorLine(selectedSource.id, i, isSourceWasm);
+
+ if (breakableLines.has(line)) {
+ codeMirror.removeLineClass(i, "wrap", "empty-line");
+ } else {
+ codeMirror.addLineClass(i, "wrap", "empty-line");
+ }
+ }
+ });
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ if (!selectedSource) {
+ throw new Error("no selectedSource");
+ }
+ const breakableLines = getSelectedBreakableLines(state);
+
+ return {
+ selectedSource,
+ breakableLines,
+ };
+};
+
+export default connect(mapStateToProps)(EmptyLines);
diff --git a/devtools/client/debugger/src/components/Editor/Exception.js b/devtools/client/debugger/src/components/Editor/Exception.js
new file mode 100644
index 0000000000..8527cfed07
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Exception.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { PureComponent } from "react";
+import PropTypes from "prop-types";
+
+import { toEditorPosition, getTokenEnd, hasDocument } from "../../utils/editor";
+
+import { getIndentation } from "../../utils/indentation";
+import { createLocation } from "../../utils/location";
+
+export default class Exception extends PureComponent {
+ exceptionLine;
+ markText;
+
+ static get propTypes() {
+ return {
+ exception: PropTypes.object.isRequired,
+ doc: PropTypes.object.isRequired,
+ selectedSource: PropTypes.string.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addEditorExceptionLine();
+ }
+
+ componentDidUpdate() {
+ this.clearEditorExceptionLine();
+ this.addEditorExceptionLine();
+ }
+
+ componentWillUnmount() {
+ this.clearEditorExceptionLine();
+ }
+
+ setEditorExceptionLine(doc, line, column, lineText) {
+ doc.addLineClass(line, "wrap", "line-exception");
+
+ column = Math.max(column, getIndentation(lineText));
+ const columnEnd = doc.cm ? getTokenEnd(doc.cm, line, column) : null;
+
+ const markText = doc.markText(
+ { ch: column, line },
+ { ch: columnEnd, line },
+ { className: "mark-text-exception" }
+ );
+
+ this.exceptionLine = line;
+ this.markText = markText;
+ }
+
+ addEditorExceptionLine() {
+ const { exception, doc, selectedSource } = this.props;
+ const { columnNumber, lineNumber } = exception;
+
+ if (!hasDocument(selectedSource.id)) {
+ return;
+ }
+
+ const location = createLocation({
+ column: columnNumber - 1,
+ line: lineNumber,
+ source: selectedSource,
+ });
+
+ const { line, column } = toEditorPosition(location);
+ const lineText = doc.getLine(line);
+
+ this.setEditorExceptionLine(doc, line, column, lineText);
+ }
+
+ clearEditorExceptionLine() {
+ if (this.markText) {
+ const { selectedSource } = this.props;
+
+ this.markText.clear();
+
+ if (hasDocument(selectedSource.id)) {
+ this.props.doc.removeLineClass(
+ this.exceptionLine,
+ "wrap",
+ "line-exception"
+ );
+ }
+ this.exceptionLine = null;
+ this.markText = null;
+ }
+ }
+
+ // This component is only used as a "proxy" to manipulate the editor.
+ render() {
+ return null;
+ }
+}
diff --git a/devtools/client/debugger/src/components/Editor/Exceptions.js b/devtools/client/debugger/src/components/Editor/Exceptions.js
new file mode 100644
index 0000000000..d1bac48b1b
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Exceptions.js
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+import Exception from "./Exception";
+
+import {
+ getSelectedSource,
+ getSelectedSourceExceptions,
+} from "../../selectors";
+import { getDocument } from "../../utils/editor";
+
+class Exceptions extends Component {
+ static get propTypes() {
+ return {
+ exceptions: PropTypes.array,
+ selectedSource: PropTypes.object,
+ };
+ }
+
+ render() {
+ const { exceptions, selectedSource } = this.props;
+
+ if (!selectedSource || !exceptions.length) {
+ return null;
+ }
+
+ const doc = getDocument(selectedSource.id);
+
+ return (
+ <>
+ {exceptions.map(exc => (
+ <Exception
+ exception={exc}
+ doc={doc}
+ key={`${exc.sourceActorId}:${exc.lineNumber}`}
+ selectedSource={selectedSource}
+ />
+ ))}
+ </>
+ );
+ }
+}
+
+export default connect(state => {
+ const selectedSource = getSelectedSource(state);
+
+ // Avoid calling getSelectedSourceExceptions when there is no source selected.
+ if (!selectedSource) {
+ return {};
+ }
+
+ // Avoid causing any update until we start having exceptions
+ const exceptions = getSelectedSourceExceptions(state);
+ if (!exceptions.length) {
+ return {};
+ }
+
+ return {
+ exceptions: getSelectedSourceExceptions(state),
+ selectedSource,
+ };
+})(Exceptions);
diff --git a/devtools/client/debugger/src/components/Editor/Footer.css b/devtools/client/debugger/src/components/Editor/Footer.css
new file mode 100644
index 0000000000..aee6c51d38
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Footer.css
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.source-footer {
+ background: var(--theme-body-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ position: absolute;
+ display: flex;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ opacity: 1;
+ z-index: 1;
+ width: calc(100% - 1px);
+ user-select: none;
+ height: var(--editor-footer-height);
+ box-sizing: border-box;
+}
+
+.source-footer-start {
+ display: flex;
+ align-items: center;
+ justify-self: start;
+}
+
+.source-footer-end {
+ display: flex;
+ margin-left: auto;
+}
+
+.source-footer .commands * {
+ user-select: none;
+}
+
+.source-footer .commands {
+ display: flex;
+}
+
+.source-footer .commands .action {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: opacity 200ms;
+ border: none;
+ background: transparent;
+ padding: 4px 6px;
+}
+
+.source-footer .commands button.action:hover {
+ background: var(--theme-toolbar-background-hover);
+}
+
+:root.theme-dark .source-footer .commands .action {
+ fill: var(--theme-body-color);
+}
+
+:root.theme-dark .source-footer .commands .action:hover {
+ fill: var(--theme-selection-color);
+}
+
+.source-footer .blackboxed .img.blackBox {
+ background-color: #806414;
+}
+
+.source-footer .commands button.prettyPrint:disabled {
+ opacity: 0.6;
+}
+
+.source-footer .mapped-source,
+.source-footer .cursor-position {
+ color: var(--theme-body-color);
+ padding-right: 2.5px;
+}
+
+.source-footer .mapped-source {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.source-footer .cursor-position {
+ padding: 5px;
+ white-space: nowrap;
+}
diff --git a/devtools/client/debugger/src/components/Editor/Footer.js b/devtools/client/debugger/src/components/Editor/Footer.js
new file mode 100644
index 0000000000..ea9acbc6f6
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Footer.js
@@ -0,0 +1,302 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import { createLocation } from "../../utils/location";
+import actions from "../../actions";
+import {
+ getSelectedSource,
+ getSelectedLocation,
+ getSelectedSourceTextContent,
+ getPrettySource,
+ getPaneCollapse,
+ getContext,
+ getGeneratedSource,
+ isSourceBlackBoxed,
+ canPrettyPrintSource,
+ getPrettyPrintMessage,
+ isSourceOnSourceMapIgnoreList,
+ isSourceMapIgnoreListEnabled,
+} from "../../selectors";
+
+import { isPretty, getFilename, shouldBlackbox } from "../../utils/source";
+
+import { PaneToggleButton } from "../shared/Button";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./Footer.css";
+
+class SourceFooter extends PureComponent {
+ constructor() {
+ super();
+
+ this.state = { cursorPosition: { line: 0, column: 0 } };
+ }
+
+ static get propTypes() {
+ return {
+ canPrettyPrint: PropTypes.bool.isRequired,
+ prettyPrintMessage: PropTypes.string.isRequired,
+ cx: PropTypes.object.isRequired,
+ endPanelCollapsed: PropTypes.bool.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ jumpToMappedLocation: PropTypes.func.isRequired,
+ mappedSource: PropTypes.object,
+ selectedSource: PropTypes.object,
+ isSelectedSourceBlackBoxed: PropTypes.bool.isRequired,
+ sourceLoaded: PropTypes.bool.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ togglePaneCollapse: PropTypes.func.isRequired,
+ togglePrettyPrint: PropTypes.func.isRequired,
+ isSourceOnIgnoreList: PropTypes.bool.isRequired,
+ };
+ }
+
+ componentDidUpdate() {
+ const eventDoc = document.querySelector(".editor-mount .CodeMirror");
+ // querySelector can return null
+ if (eventDoc) {
+ this.toggleCodeMirror(eventDoc, true);
+ }
+ }
+
+ componentWillUnmount() {
+ const eventDoc = document.querySelector(".editor-mount .CodeMirror");
+
+ if (eventDoc) {
+ this.toggleCodeMirror(eventDoc, false);
+ }
+ }
+
+ toggleCodeMirror(eventDoc, toggle) {
+ if (toggle === true) {
+ eventDoc.CodeMirror.on("cursorActivity", this.onCursorChange);
+ } else {
+ eventDoc.CodeMirror.off("cursorActivity", this.onCursorChange);
+ }
+ }
+
+ prettyPrintButton() {
+ const {
+ cx,
+ selectedSource,
+ canPrettyPrint,
+ prettyPrintMessage,
+ togglePrettyPrint,
+ sourceLoaded,
+ } = this.props;
+
+ if (!selectedSource) {
+ return null;
+ }
+
+ if (!sourceLoaded && selectedSource.isPrettyPrinted) {
+ return (
+ <div className="action" key="pretty-loader">
+ <AccessibleImage className="loader spin" />
+ </div>
+ );
+ }
+
+ const type = "prettyPrint";
+ return (
+ <button
+ onClick={() => {
+ if (!canPrettyPrint) {
+ return;
+ }
+ togglePrettyPrint(cx, selectedSource.id);
+ }}
+ className={classnames("action", type, {
+ active: sourceLoaded && canPrettyPrint,
+ pretty: isPretty(selectedSource),
+ })}
+ key={type}
+ title={prettyPrintMessage}
+ aria-label={prettyPrintMessage}
+ disabled={!canPrettyPrint}
+ >
+ <AccessibleImage className={type} />
+ </button>
+ );
+ }
+
+ blackBoxButton() {
+ const {
+ cx,
+ selectedSource,
+ isSelectedSourceBlackBoxed,
+ toggleBlackBox,
+ sourceLoaded,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ if (!selectedSource || !shouldBlackbox(selectedSource)) {
+ return null;
+ }
+
+ let tooltip = isSelectedSourceBlackBoxed
+ ? L10N.getStr("sourceFooter.unignore")
+ : L10N.getStr("sourceFooter.ignore");
+
+ if (isSourceOnIgnoreList) {
+ tooltip = L10N.getStr("sourceFooter.ignoreList");
+ }
+
+ const type = "black-box";
+
+ return (
+ <button
+ onClick={() => toggleBlackBox(cx, selectedSource)}
+ className={classnames("action", type, {
+ active: sourceLoaded,
+ blackboxed: isSelectedSourceBlackBoxed || isSourceOnIgnoreList,
+ })}
+ key={type}
+ title={tooltip}
+ aria-label={tooltip}
+ disabled={isSourceOnIgnoreList}
+ >
+ <AccessibleImage className="blackBox" />
+ </button>
+ );
+ }
+
+ renderToggleButton() {
+ if (this.props.horizontal) {
+ return null;
+ }
+
+ return (
+ <PaneToggleButton
+ key="toggle"
+ collapsed={this.props.endPanelCollapsed}
+ horizontal={this.props.horizontal}
+ handleClick={this.props.togglePaneCollapse}
+ position="end"
+ />
+ );
+ }
+
+ renderCommands() {
+ const commands = [this.blackBoxButton(), this.prettyPrintButton()].filter(
+ Boolean
+ );
+
+ return commands.length ? <div className="commands">{commands}</div> : null;
+ }
+
+ renderSourceSummary() {
+ const { cx, mappedSource, jumpToMappedLocation, selectedSource } =
+ this.props;
+
+ if (!mappedSource || !selectedSource || !selectedSource.isOriginal) {
+ return null;
+ }
+
+ const filename = getFilename(mappedSource);
+ const tooltip = L10N.getFormatStr(
+ "sourceFooter.mappedSourceTooltip",
+ filename
+ );
+ const title = L10N.getFormatStr("sourceFooter.mappedSource", filename);
+ const mappedSourceLocation = createLocation({
+ source: selectedSource,
+ line: 1,
+ column: 1,
+ });
+ return (
+ <button
+ className="mapped-source"
+ onClick={() => jumpToMappedLocation(cx, mappedSourceLocation)}
+ title={tooltip}
+ >
+ <span>{title}</span>
+ </button>
+ );
+ }
+
+ onCursorChange = event => {
+ const { line, ch } = event.doc.getCursor();
+ this.setState({ cursorPosition: { line, column: ch } });
+ };
+
+ renderCursorPosition() {
+ if (!this.props.selectedSource) {
+ return null;
+ }
+
+ const { line, column } = this.state.cursorPosition;
+
+ const text = L10N.getFormatStr(
+ "sourceFooter.currentCursorPosition",
+ line + 1,
+ column + 1
+ );
+ const title = L10N.getFormatStr(
+ "sourceFooter.currentCursorPosition.tooltip",
+ line + 1,
+ column + 1
+ );
+ return (
+ <div className="cursor-position" title={title}>
+ {text}
+ </div>
+ );
+ }
+
+ render() {
+ return (
+ <div className="source-footer">
+ <div className="source-footer-start">{this.renderCommands()}</div>
+ <div className="source-footer-end">
+ {this.renderSourceSummary()}
+ {this.renderCursorPosition()}
+ {this.renderToggleButton()}
+ </div>
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ const selectedLocation = getSelectedLocation(state);
+ const sourceTextContent = getSelectedSourceTextContent(state);
+
+ return {
+ cx: getContext(state),
+ selectedSource,
+ isSelectedSourceBlackBoxed: selectedSource
+ ? isSourceBlackBoxed(state, selectedSource)
+ : null,
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, selectedSource),
+ sourceLoaded: !!sourceTextContent,
+ mappedSource: getGeneratedSource(state, selectedSource),
+ prettySource: getPrettySource(
+ state,
+ selectedSource ? selectedSource.id : null
+ ),
+ endPanelCollapsed: getPaneCollapse(state, "end"),
+ canPrettyPrint: selectedLocation
+ ? canPrettyPrintSource(state, selectedLocation)
+ : false,
+ prettyPrintMessage: selectedLocation
+ ? getPrettyPrintMessage(state, selectedLocation)
+ : null,
+ };
+};
+
+export default connect(mapStateToProps, {
+ togglePrettyPrint: actions.togglePrettyPrint,
+ toggleBlackBox: actions.toggleBlackBox,
+ jumpToMappedLocation: actions.jumpToMappedLocation,
+ togglePaneCollapse: actions.togglePaneCollapse,
+})(SourceFooter);
diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.css b/devtools/client/debugger/src/components/Editor/HighlightCalls.css
new file mode 100644
index 0000000000..b7e0402cab
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.css
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.highlight-function-calls {
+ background-color: rgba(202, 227, 255, 0.5);
+}
+
+.theme-dark .highlight-function-calls {
+ background-color: #743884;
+}
+
+.highlight-function-calls:hover {
+ cursor: default;
+}
diff --git a/devtools/client/debugger/src/components/Editor/HighlightCalls.js b/devtools/client/debugger/src/components/Editor/HighlightCalls.js
new file mode 100644
index 0000000000..0063f66c7a
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightCalls.js
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import {
+ getHighlightedCalls,
+ getThreadContext,
+ getCurrentThread,
+} from "../../selectors";
+import { getSourceLocationFromMouseEvent } from "../../utils/editor";
+import actions from "../../actions";
+import "./HighlightCalls.css";
+
+export class HighlightCalls extends Component {
+ previousCalls = null;
+
+ static get propTypes() {
+ return {
+ continueToHere: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ highlightedCalls: PropTypes.array,
+ selectedSource: PropTypes.object,
+ };
+ }
+
+ componentDidUpdate() {
+ this.unhighlightFunctionCalls();
+ this.highlightFunctioCalls();
+ }
+
+ markCall = call => {
+ const { editor } = this.props;
+ const startLine = call.location.start.line - 1;
+ const endLine = call.location.end.line - 1;
+ const startColumn = call.location.start.column;
+ const endColumn = call.location.end.column;
+ const markedCall = editor.codeMirror.markText(
+ { line: startLine, ch: startColumn },
+ { line: endLine, ch: endColumn },
+ { className: "highlight-function-calls" }
+ );
+ return markedCall;
+ };
+
+ onClick = e => {
+ const { editor, selectedSource, cx, continueToHere } = this.props;
+
+ if (selectedSource) {
+ const location = getSourceLocationFromMouseEvent(
+ editor,
+ selectedSource,
+ e
+ );
+ continueToHere(cx, location);
+ editor.codeMirror.execCommand("singleSelection");
+ editor.codeMirror.execCommand("goGroupLeft");
+ }
+ };
+
+ highlightFunctioCalls() {
+ const { highlightedCalls } = this.props;
+
+ if (!highlightedCalls) {
+ return;
+ }
+
+ let markedCalls = [];
+ markedCalls = highlightedCalls.map(this.markCall);
+
+ const allMarkedElements = document.getElementsByClassName(
+ "highlight-function-calls"
+ );
+
+ for (let i = 0; i < allMarkedElements.length; i++) {
+ allMarkedElements[i].addEventListener("click", this.onClick);
+ }
+
+ this.previousCalls = markedCalls;
+ }
+
+ unhighlightFunctionCalls() {
+ if (!this.previousCalls) {
+ return;
+ }
+ this.previousCalls.forEach(call => call.clear());
+ this.previousCalls = null;
+ }
+
+ render() {
+ return null;
+ }
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ return {
+ highlightedCalls: getHighlightedCalls(state, thread),
+ cx: getThreadContext(state),
+ };
+};
+
+const { continueToHere } = actions;
+
+const mapDispatchToProps = { continueToHere };
+
+export default connect(mapStateToProps, mapDispatchToProps)(HighlightCalls);
diff --git a/devtools/client/debugger/src/components/Editor/HighlightLine.js b/devtools/client/debugger/src/components/Editor/HighlightLine.js
new file mode 100644
index 0000000000..3df0142127
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightLine.js
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "react";
+import PropTypes from "prop-types";
+import { toEditorLine, endOperation, startOperation } from "../../utils/editor";
+import { getDocument, hasDocument } from "../../utils/editor/source-documents";
+
+import { connect } from "../../utils/connect";
+import {
+ getVisibleSelectedFrame,
+ getSelectedLocation,
+ getSelectedSourceTextContent,
+ getPauseCommand,
+ getCurrentThread,
+} from "../../selectors";
+
+function isDebugLine(selectedFrame, selectedLocation) {
+ if (!selectedFrame) {
+ return false;
+ }
+
+ return (
+ selectedFrame.location.sourceId == selectedLocation.sourceId &&
+ selectedFrame.location.line == selectedLocation.line
+ );
+}
+
+function isDocumentReady(selectedLocation, selectedSourceTextContent) {
+ return (
+ selectedLocation &&
+ selectedSourceTextContent &&
+ hasDocument(selectedLocation.sourceId)
+ );
+}
+
+export class HighlightLine extends Component {
+ isStepping = false;
+ previousEditorLine = null;
+
+ static get propTypes() {
+ return {
+ pauseCommand: PropTypes.oneOf([
+ "expression",
+ "resume",
+ "stepOver",
+ "stepIn",
+ "stepOut",
+ ]),
+ selectedFrame: PropTypes.object,
+ selectedLocation: PropTypes.object.isRequired,
+ selectedSourceTextContent: PropTypes.object.isRequired,
+ };
+ }
+
+ shouldComponentUpdate(nextProps) {
+ const { selectedLocation, selectedSourceTextContent } = nextProps;
+ return this.shouldSetHighlightLine(
+ selectedLocation,
+ selectedSourceTextContent
+ );
+ }
+
+ componentDidUpdate(prevProps) {
+ this.completeHighlightLine(prevProps);
+ }
+
+ componentDidMount() {
+ this.completeHighlightLine(null);
+ }
+
+ shouldSetHighlightLine(selectedLocation, selectedSourceTextContent) {
+ const { sourceId, line } = selectedLocation;
+ const editorLine = toEditorLine(sourceId, line);
+
+ if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) {
+ return false;
+ }
+
+ if (this.isStepping && editorLine === this.previousEditorLine) {
+ return false;
+ }
+
+ return true;
+ }
+
+ completeHighlightLine(prevProps) {
+ const {
+ pauseCommand,
+ selectedLocation,
+ selectedFrame,
+ selectedSourceTextContent,
+ } = this.props;
+ if (pauseCommand) {
+ this.isStepping = true;
+ }
+
+ startOperation();
+ if (prevProps) {
+ this.clearHighlightLine(
+ prevProps.selectedLocation,
+ prevProps.selectedSourceTextContent
+ );
+ }
+ this.setHighlightLine(
+ selectedLocation,
+ selectedFrame,
+ selectedSourceTextContent
+ );
+ endOperation();
+ }
+
+ setHighlightLine(selectedLocation, selectedFrame, selectedSourceTextContent) {
+ const { sourceId, line } = selectedLocation;
+ if (
+ !this.shouldSetHighlightLine(selectedLocation, selectedSourceTextContent)
+ ) {
+ return;
+ }
+
+ this.isStepping = false;
+ const editorLine = toEditorLine(sourceId, line);
+ this.previousEditorLine = editorLine;
+
+ if (!line || isDebugLine(selectedFrame, selectedLocation)) {
+ return;
+ }
+
+ const doc = getDocument(sourceId);
+ doc.addLineClass(editorLine, "wrap", "highlight-line");
+ this.resetHighlightLine(doc, editorLine);
+ }
+
+ resetHighlightLine(doc, editorLine) {
+ const editorWrapper = document.querySelector(".editor-wrapper");
+
+ if (editorWrapper === null) {
+ return;
+ }
+
+ const duration = parseInt(
+ getComputedStyle(editorWrapper).getPropertyValue(
+ "--highlight-line-duration"
+ ),
+ 10
+ );
+
+ setTimeout(
+ () => doc && doc.removeLineClass(editorLine, "wrap", "highlight-line"),
+ duration
+ );
+ }
+
+ clearHighlightLine(selectedLocation, selectedSourceTextContent) {
+ if (!isDocumentReady(selectedLocation, selectedSourceTextContent)) {
+ return;
+ }
+
+ const { line, sourceId } = selectedLocation;
+ const editorLine = toEditorLine(sourceId, line);
+ const doc = getDocument(sourceId);
+ doc.removeLineClass(editorLine, "wrap", "highlight-line");
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default connect(state => {
+ const selectedLocation = getSelectedLocation(state);
+
+ if (!selectedLocation) {
+ throw new Error("must have selected location");
+ }
+ return {
+ pauseCommand: getPauseCommand(state, getCurrentThread(state)),
+ selectedFrame: getVisibleSelectedFrame(state),
+ selectedLocation,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ };
+})(HighlightLine);
diff --git a/devtools/client/debugger/src/components/Editor/HighlightLines.js b/devtools/client/debugger/src/components/Editor/HighlightLines.js
new file mode 100644
index 0000000000..bffa209e7d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/HighlightLines.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { Component } from "react";
+import PropTypes from "prop-types";
+
+class HighlightLines extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ range: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.highlightLineRange();
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillUpdate() {
+ this.clearHighlightRange();
+ }
+
+ componentDidUpdate() {
+ this.highlightLineRange();
+ }
+
+ componentWillUnmount() {
+ this.clearHighlightRange();
+ }
+
+ clearHighlightRange() {
+ const { range, editor } = this.props;
+
+ const { codeMirror } = editor;
+
+ if (!range || !codeMirror) {
+ return;
+ }
+
+ const { start, end } = range;
+ codeMirror.operation(() => {
+ for (let line = start - 1; line < end; line++) {
+ codeMirror.removeLineClass(line, "wrap", "highlight-lines");
+ }
+ });
+ }
+
+ highlightLineRange = () => {
+ const { range, editor } = this.props;
+
+ const { codeMirror } = editor;
+
+ if (!range || !codeMirror) {
+ return;
+ }
+
+ const { start, end } = range;
+
+ codeMirror.operation(() => {
+ editor.alignLine(start);
+ for (let line = start - 1; line < end; line++) {
+ codeMirror.addLineClass(line, "wrap", "highlight-lines");
+ }
+ });
+ };
+
+ render() {
+ return null;
+ }
+}
+
+export default HighlightLines;
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.css b/devtools/client/debugger/src/components/Editor/InlinePreview.css
new file mode 100644
index 0000000000..13f1b5e23c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreview.css
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.inline-preview {
+ display: inline-block;
+ margin-inline-start: 8px;
+ user-select: none;
+}
+
+.inline-preview-outer {
+ background-color: var(--theme-inline-preview-background);
+ border: 1px solid var(--theme-inline-preview-border-color);
+ border-radius: 3px;
+ font-size: 10px;
+ margin-right: 5px;
+ white-space: nowrap;
+}
+
+.inline-preview-label {
+ padding: 0px 2px 0px 4px;
+ border-radius: 2px 0 0 2px;
+ color: var(--theme-inline-preview-label-color);
+ background-color: var(--theme-inline-preview-label-background);
+}
+
+.inline-preview-value {
+ padding: 2px 6px;
+}
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreview.js b/devtools/client/debugger/src/components/Editor/InlinePreview.js
new file mode 100644
index 0000000000..f978965134
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreview.js
@@ -0,0 +1,66 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import Reps from "devtools/client/shared/components/reps/index";
+
+const {
+ REPS: {
+ Rep,
+ ElementNode: { supportsObject: isElement },
+ },
+ MODE,
+} = Reps;
+
+// Renders single variable preview inside a codemirror line widget
+class InlinePreview extends PureComponent {
+ static get propTypes() {
+ return {
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ value: PropTypes.any,
+ variable: PropTypes.string.isRequired,
+ };
+ }
+
+ showInScopes(variable) {
+ // TODO: focus on variable value in the scopes sidepanel
+ // we will need more info from parent comp
+ }
+
+ render() {
+ const {
+ value,
+ variable,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const mode = isElement(value) ? MODE.TINY : MODE.SHORT;
+
+ return (
+ <span
+ className="inline-preview-outer"
+ onClick={() => this.showInScopes(variable)}
+ >
+ <span className="inline-preview-label">{variable}:</span>
+ <span className="inline-preview-value">
+ <Rep
+ object={value}
+ mode={mode}
+ onDOMNodeClick={grip => openElementInInspector(grip)}
+ onInspectIconClick={grip => openElementInInspector(grip)}
+ onDOMNodeMouseOver={grip => highlightDomElement(grip)}
+ onDOMNodeMouseOut={grip => unHighlightDomElement(grip)}
+ />
+ </span>
+ </span>
+ );
+ }
+}
+
+export default InlinePreview;
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js
new file mode 100644
index 0000000000..ad2631e01e
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreviewRow.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import ReactDOM from "react-dom";
+
+import actions from "../../actions";
+import assert from "../../utils/assert";
+import { connect } from "../../utils/connect";
+import InlinePreview from "./InlinePreview";
+
+import "./InlinePreview.css";
+
+// Handles rendering for each line ( row )
+// * Renders single widget for each line in codemirror
+// * Renders InlinePreview for each preview inside the widget
+class InlinePreviewRow extends PureComponent {
+ bookmark;
+ widgetNode;
+
+ componentDidMount() {
+ this.updatePreviewWidget(this.props, null);
+ }
+
+ componentDidUpdate(prevProps) {
+ this.updatePreviewWidget(this.props, prevProps);
+ }
+
+ componentWillUnmount() {
+ this.updatePreviewWidget(null, this.props);
+ }
+
+ updatePreviewWidget(props, prevProps) {
+ if (
+ this.bookmark &&
+ prevProps &&
+ (!props ||
+ prevProps.editor !== props.editor ||
+ prevProps.line !== props.line)
+ ) {
+ this.bookmark.clear();
+ this.bookmark = null;
+ this.widgetNode = null;
+ }
+
+ if (!props) {
+ assert(!this.bookmark, "Inline Preview widget shouldn't be present.");
+ return;
+ }
+
+ const {
+ editor,
+ line,
+ previews,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = props;
+
+ if (!this.bookmark) {
+ this.widgetNode = document.createElement("div");
+ this.widgetNode.classList.add("inline-preview");
+ }
+
+ ReactDOM.render(
+ <React.Fragment>
+ {previews.map(preview => (
+ <InlinePreview
+ line={line}
+ key={`${line}-${preview.name}`}
+ variable={preview.name}
+ value={preview.value}
+ openElementInInspector={openElementInInspector}
+ highlightDomElement={highlightDomElement}
+ unHighlightDomElement={unHighlightDomElement}
+ />
+ ))}
+ </React.Fragment>,
+ this.widgetNode
+ );
+
+ this.bookmark = editor.codeMirror.setBookmark(
+ {
+ line,
+ ch: Infinity,
+ },
+ this.widgetNode
+ );
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default connect(() => ({}), {
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+})(InlinePreviewRow);
diff --git a/devtools/client/debugger/src/components/Editor/InlinePreviews.js b/devtools/client/debugger/src/components/Editor/InlinePreviews.js
new file mode 100644
index 0000000000..8778cb373c
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/InlinePreviews.js
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import InlinePreviewRow from "./InlinePreviewRow";
+import { connect } from "../../utils/connect";
+import {
+ getSelectedFrame,
+ getCurrentThread,
+ getInlinePreviews,
+} from "../../selectors";
+
+function hasPreviews(previews) {
+ return !!previews && !!Object.keys(previews).length;
+}
+
+class InlinePreviews extends Component {
+ static get propTypes() {
+ return {
+ editor: PropTypes.object.isRequired,
+ previews: PropTypes.object,
+ selectedFrame: PropTypes.object.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ };
+ }
+
+ shouldComponentUpdate({ previews }) {
+ return hasPreviews(previews);
+ }
+
+ render() {
+ const { editor, selectedFrame, selectedSource, previews } = this.props;
+
+ // Render only if currently open file is the one where debugger is paused
+ if (
+ !selectedFrame ||
+ selectedFrame.location.sourceId !== selectedSource.id ||
+ !hasPreviews(previews)
+ ) {
+ return null;
+ }
+ const previewsObj = previews;
+
+ let inlinePreviewRows;
+ editor.codeMirror.operation(() => {
+ inlinePreviewRows = Object.keys(previewsObj).map(line => {
+ const lineNum = parseInt(line, 10);
+
+ return (
+ <InlinePreviewRow
+ editor={editor}
+ key={line}
+ line={lineNum}
+ previews={previewsObj[line]}
+ />
+ );
+ });
+ });
+
+ return <div>{inlinePreviewRows}</div>;
+ }
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ const selectedFrame = getSelectedFrame(state, thread);
+
+ if (!selectedFrame) {
+ return {
+ selectedFrame: null,
+ previews: null,
+ };
+ }
+
+ return {
+ selectedFrame,
+ previews: getInlinePreviews(state, thread, selectedFrame.id),
+ };
+};
+
+export default connect(mapStateToProps)(InlinePreviews);
diff --git a/devtools/client/debugger/src/components/Editor/Preview.css b/devtools/client/debugger/src/components/Editor/Preview.css
new file mode 100644
index 0000000000..35b874315e
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview.css
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.popover .preview {
+ background: var(--theme-body-background);
+ width: 350px;
+ border: 1px solid var(--theme-splitter-color);
+ padding: 10px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.theme-dark .popover .preview {
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.popover .preview .header {
+ width: 100%;
+ line-height: 20px;
+ border-bottom: 1px solid #cccccc;
+ display: flex;
+ flex-direction: column;
+}
+
+.popover .preview .header .link {
+ align-self: flex-end;
+ color: var(--theme-highlight-blue);
+ text-decoration: underline;
+}
+
+.selection,
+.debug-expression.selection {
+ background-color: var(--theme-highlight-yellow);
+}
+
+.theme-dark .selection,
+.theme-dark .debug-expression.selection {
+ background-color: #743884;
+}
+
+.theme-dark .cm-s-mozilla .selection,
+.theme-dark .cm-s-mozilla .debug-expression.selection {
+ color: #e7ebee;
+}
+
+.popover .preview .function-signature {
+ padding-top: 10px;
+}
+
+.theme-dark .popover .preview {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip {
+ position: fixed;
+ z-index: 100;
+}
+
+.tooltip .preview {
+ background: var(--theme-toolbar-background);
+ max-width: inherit;
+ border: 1px solid var(--theme-splitter-color);
+ box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt);
+ padding: 5px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+}
+
+.theme-dark .tooltip .preview {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip .gap {
+ height: 4px;
+ padding-top: 4px;
+}
+
+.add-to-expression-bar {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: none;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ font-size: 14px;
+ line-height: 30px;
+ background: var(--theme-toolbar-background);
+ color: var(--theme-text-color-inactive);
+ padding: 0 4px;
+}
+
+.add-to-expression-bar .prompt {
+ width: 1em;
+}
+
+.add-to-expression-bar .expression-to-save-label {
+ width: calc(100% - 4em);
+}
+
+.add-to-expression-bar .expression-to-save-button {
+ font-size: 14px;
+ color: var(--theme-comment);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js
new file mode 100644
index 0000000000..624a78fb8b
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/ExceptionPopup.js
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../../utils/connect";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { StringRep },
+} = Reps;
+
+import actions from "../../../actions";
+
+import { getThreadContext } from "../../../selectors";
+
+import AccessibleImage from "../../shared/AccessibleImage";
+
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
+const classnames = require("devtools/client/shared/classnames.js");
+
+const POPUP_SELECTOR = ".preview-popup.exception-popup";
+const ANONYMOUS_FN_NAME = "<anonymous>";
+
+// The exception popup works in two modes:
+// a. when the stacktrace is closed the exception popup
+// gets closed when the mouse leaves the popup.
+// b. when the stacktrace is opened the exception popup
+// gets closed only by clicking outside the popup.
+class ExceptionPopup extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isStacktraceExpanded: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ mouseout: PropTypes.func.isRequired,
+ selectSourceURL: PropTypes.func.isRequired,
+ exception: PropTypes.object.isRequired,
+ };
+ }
+
+ updateTopWindow() {
+ // The ChromeWindow is used when the stacktrace is expanded to capture all clicks
+ // outside the popup so the popup can be closed only by clicking outside of it.
+ if (this.topWindow) {
+ this.topWindow.removeEventListener(
+ "mousedown",
+ this.onTopWindowClick,
+ true
+ );
+ this.topWindow = null;
+ }
+ this.topWindow = DevToolsUtils.getTopWindow(window.parent);
+ this.topWindow.addEventListener("mousedown", this.onTopWindowClick, true);
+ }
+
+ onTopWindowClick = e => {
+ const { cx, clearPreview } = this.props;
+
+ // When the stactrace is expaned the exception popup gets closed
+ // only by clicking ouside the popup.
+ if (!e.target.closest(POPUP_SELECTOR)) {
+ clearPreview(cx);
+ }
+ };
+
+ onExceptionMessageClick() {
+ const isStacktraceExpanded = this.state.isStacktraceExpanded;
+
+ this.updateTopWindow();
+ this.setState({ isStacktraceExpanded: !isStacktraceExpanded });
+ }
+
+ buildStackFrame(frame) {
+ const { cx, selectSourceURL } = this.props;
+ const { filename, lineNumber } = frame;
+ const functionName = frame.functionName || ANONYMOUS_FN_NAME;
+
+ return (
+ <div
+ className="frame"
+ onClick={() => selectSourceURL(cx, filename, { line: lineNumber })}
+ >
+ <span className="title">{functionName}</span>
+ <span className="location">
+ <span className="filename">{filename}</span>:
+ <span className="line">{lineNumber}</span>
+ </span>
+ </div>
+ );
+ }
+
+ renderStacktrace(stacktrace) {
+ const isStacktraceExpanded = this.state.isStacktraceExpanded;
+
+ if (stacktrace.length && isStacktraceExpanded) {
+ return (
+ <div className="exception-stacktrace">
+ {stacktrace.map(frame => this.buildStackFrame(frame))}
+ </div>
+ );
+ }
+ return null;
+ }
+
+ renderArrowIcon(stacktrace) {
+ if (stacktrace.length) {
+ return (
+ <AccessibleImage
+ className={classnames("arrow", {
+ expanded: this.state.isStacktraceExpanded,
+ })}
+ />
+ );
+ }
+ return null;
+ }
+
+ render() {
+ const {
+ exception: { stacktrace, errorMessage },
+ mouseout,
+ } = this.props;
+
+ return (
+ <div
+ className="preview-popup exception-popup"
+ dir="ltr"
+ onMouseLeave={() => mouseout(true, this.state.isStacktraceExpanded)}
+ >
+ <div
+ className="exception-message"
+ onClick={() => this.onExceptionMessageClick()}
+ >
+ {this.renderArrowIcon(stacktrace)}
+ {StringRep.rep({
+ object: errorMessage,
+ useQuotes: false,
+ className: "exception-text",
+ })}
+ </div>
+ {this.renderStacktrace(stacktrace)}
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+});
+
+const mapDispatchToProps = {
+ selectSourceURL: actions.selectSourceURL,
+ clearPreview: actions.clearPreview,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ExceptionPopup);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.css b/devtools/client/debugger/src/components/Editor/Preview/Popup.css
new file mode 100644
index 0000000000..3e578becf1
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.css
@@ -0,0 +1,209 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.popover .preview-popup {
+ background: var(--theme-body-background);
+ width: 350px;
+ border: 1px solid var(--theme-splitter-color);
+ padding: 10px;
+ height: auto;
+ overflow: auto;
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.preview-popup .tree {
+ /* Setting a fixed line height to avoid issues in custom formatters changing
+ * the line height like the CLJS DevTools */
+ line-height: 15px;
+}
+
+.gap svg {
+ pointer-events: none;
+}
+
+.gap polygon {
+ pointer-events: auto;
+}
+
+.theme-dark .popover .preview-popup {
+ box-shadow: 1px 2px 3px var(--popup-shadow-color);
+}
+
+.popover .preview-popup .header-container {
+ width: 100%;
+ line-height: 15px;
+ display: flex;
+ flex-direction: row;
+ margin-bottom: 5px;
+}
+
+.popover .preview-popup .logo {
+ width: 20px;
+ margin-right: 5px;
+}
+
+.popover .preview-popup .header-container h3 {
+ margin: 0;
+ margin-bottom: 5px;
+ font-weight: normal;
+ font-size: 14px;
+ line-height: 20px;
+ margin-left: 4px;
+}
+
+.popover .preview-popup .header .link {
+ align-self: flex-end;
+ color: var(--theme-highlight-blue);
+ text-decoration: underline;
+}
+
+.popover .preview-popup .object-node {
+ padding-inline-start: 0px;
+}
+
+.preview-token:hover {
+ cursor: default;
+}
+
+.preview-token,
+.debug-expression.preview-token {
+ background-color: var(--theme-highlight-yellow);
+}
+
+.theme-dark .preview-token,
+.theme-dark .debug-expression.preview-token {
+ background-color: #743884;
+}
+
+.theme-dark .cm-s-mozilla .preview-token,
+.theme-dark .cm-s-mozilla .debug-expression.preview-token {
+ color: #e7ebee;
+}
+
+.popover .preview-popup .function-signature {
+ padding-top: 10px;
+}
+
+.theme-dark .popover .preview-popup {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip {
+ position: fixed;
+ z-index: 100;
+}
+
+.tooltip .preview-popup {
+ background: var(--theme-toolbar-background);
+ max-width: inherit;
+ border: 1px solid var(--theme-splitter-color);
+ box-shadow: 1px 2px 4px 1px var(--theme-toolbar-background-alt);
+ padding: 5px;
+ height: auto;
+ min-height: inherit;
+ max-height: 200px;
+ overflow: auto;
+}
+
+.theme-dark .tooltip .preview-popup {
+ border-color: var(--theme-body-color);
+}
+
+.tooltip .gap {
+ height: 4px;
+ padding-top: 0px;
+}
+
+.add-to-expression-bar {
+ border: 1px solid var(--theme-splitter-color);
+ border-top: none;
+ display: -webkit-box;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-align: center;
+ -ms-flex-align: center;
+ align-items: center;
+ font-size: 14px;
+ line-height: 30px;
+ background: var(--theme-toolbar-background);
+ color: var(--theme-text-color-inactive);
+ padding: 0 4px;
+}
+
+.add-to-expression-bar .prompt {
+ width: 1em;
+}
+
+.add-to-expression-bar .expression-to-save-label {
+ width: calc(100% - 4em);
+}
+
+.add-to-expression-bar .expression-to-save-button {
+ font-size: 14px;
+ color: var(--theme-comment);
+}
+
+/* Exception popup */
+.exception-popup .exception-text {
+ color: var(--red-70);
+}
+
+.theme-dark .exception-popup .exception-text {
+ color: var(--red-20);
+}
+
+.exception-popup .exception-message {
+ display: flex;
+ align-items: center;
+}
+
+.exception-message .arrow {
+ margin-inline-end: 4px;
+}
+
+.exception-popup .exception-stacktrace {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-column-gap: 8px;
+ padding-inline: 2px 3px;
+ line-height: var(--theme-code-line-height);
+}
+
+.exception-stacktrace .frame {
+ display: contents;
+ cursor: pointer;
+}
+
+.exception-stacktrace .title {
+ grid-column: 1/2;
+ color: var(--grey-90);
+}
+
+.theme-dark .exception-stacktrace .title {
+ color: white;
+}
+
+.exception-stacktrace .location {
+ grid-column: -1/-2;
+ color: var(--theme-highlight-purple);
+ direction: rtl;
+ text-align: end;
+ white-space: nowrap;
+ /* Force the location to be on one line and crop at start if wider then max-width */
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 350px;
+}
+
+.theme-dark .exception-stacktrace .location {
+ color: var(--blue-40);
+}
+
+.exception-stacktrace .line {
+ color: var(--theme-highlight-blue);
+}
+
+.theme-dark .exception-stacktrace .line {
+ color: hsl(210, 40%, 60%);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Preview/Popup.js b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
new file mode 100644
index 0000000000..3097d3c945
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/Popup.js
@@ -0,0 +1,382 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../../utils/connect";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { Rep },
+ MODE,
+ objectInspector,
+} = Reps;
+
+const { ObjectInspector, utils } = objectInspector;
+
+const {
+ node: { nodeIsPrimitive, nodeIsFunction, nodeIsObject },
+} = utils;
+
+import ExceptionPopup from "./ExceptionPopup";
+
+import actions from "../../../actions";
+import { getThreadContext } from "../../../selectors";
+import Popover from "../../shared/Popover";
+import PreviewFunction from "../../shared/PreviewFunction";
+
+import "./Popup.css";
+
+export class Popup extends Component {
+ constructor(props) {
+ super(props);
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ highlightDomElement: PropTypes.func.isRequired,
+ openElementInInspector: PropTypes.func.isRequired,
+ openLink: PropTypes.func.isRequired,
+ preview: PropTypes.object.isRequired,
+ selectSourceURL: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.addHighlightToToken();
+ }
+
+ componentWillUnmount() {
+ this.removeHighlightFromToken();
+ }
+
+ addHighlightToToken() {
+ const { target } = this.props.preview;
+ if (target) {
+ target.classList.add("preview-token");
+ addHighlightToTargetSiblings(target, this.props);
+ }
+ }
+
+ removeHighlightFromToken() {
+ const { target } = this.props.preview;
+ if (target) {
+ target.classList.remove("preview-token");
+ removeHighlightForTargetSiblings(target);
+ }
+ }
+
+ calculateMaxHeight = () => {
+ const { editorRef } = this.props;
+ if (!editorRef) {
+ return "auto";
+ }
+
+ const { height, top } = editorRef.getBoundingClientRect();
+ const maxHeight = height + top;
+ if (maxHeight < 250) {
+ return maxHeight;
+ }
+
+ return 250;
+ };
+
+ createElement(element) {
+ return document.createElement(element);
+ }
+
+ renderFunctionPreview() {
+ const {
+ cx,
+ selectSourceURL,
+ preview: { resultGrip },
+ } = this.props;
+
+ if (!resultGrip) {
+ return null;
+ }
+
+ const { location } = resultGrip;
+
+ return (
+ <div
+ className="preview-popup"
+ onClick={() =>
+ location &&
+ selectSourceURL(cx, location.url, {
+ line: location.line,
+ })
+ }
+ >
+ <PreviewFunction func={resultGrip} />
+ </div>
+ );
+ }
+
+ renderObjectPreview() {
+ const {
+ preview: { root, properties },
+ openLink,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const usesCustomFormatter =
+ root?.contents?.value?.useCustomFormatter ?? false;
+
+ if (!properties.length) {
+ return (
+ <div className="preview-popup">
+ <span className="label">{L10N.getStr("preview.noProperties")}</span>
+ </div>
+ );
+ }
+
+ const roots = usesCustomFormatter ? [root] : properties;
+
+ return (
+ <div
+ className="preview-popup"
+ style={{ maxHeight: this.calculateMaxHeight() }}
+ >
+ <ObjectInspector
+ roots={roots}
+ autoExpandDepth={0}
+ autoReleaseObjectActors={false}
+ mode={usesCustomFormatter ? MODE.LONG : null}
+ disableWrap={true}
+ focusable={false}
+ openLink={openLink}
+ createElement={this.createElement}
+ onDOMNodeClick={grip => openElementInInspector(grip)}
+ onInspectIconClick={grip => openElementInInspector(grip)}
+ onDOMNodeMouseOver={grip => highlightDomElement(grip)}
+ onDOMNodeMouseOut={grip => unHighlightDomElement(grip)}
+ mayUseCustomFormatter={true}
+ />
+ </div>
+ );
+ }
+
+ renderSimplePreview() {
+ const {
+ openLink,
+ preview: { resultGrip },
+ } = this.props;
+ return (
+ <div className="preview-popup">
+ {Rep({
+ object: resultGrip,
+ mode: MODE.LONG,
+ openLink,
+ })}
+ </div>
+ );
+ }
+
+ renderExceptionPreview(exception) {
+ return (
+ <ExceptionPopup
+ exception={exception}
+ mouseout={this.onMouseOutException}
+ />
+ );
+ }
+
+ renderPreview() {
+ // We don't have to check and
+ // return on `false`, `""`, `0`, `undefined` etc,
+ // these falsy simple typed value because we want to
+ // do `renderSimplePreview` on these values below.
+ const {
+ preview: { root, exception },
+ } = this.props;
+
+ if (nodeIsFunction(root)) {
+ return this.renderFunctionPreview();
+ }
+
+ if (nodeIsObject(root)) {
+ return <div>{this.renderObjectPreview()}</div>;
+ }
+
+ if (exception) {
+ return this.renderExceptionPreview(exception);
+ }
+
+ return this.renderSimplePreview();
+ }
+
+ getPreviewType() {
+ const {
+ preview: { root, properties, exception },
+ } = this.props;
+ if (
+ exception ||
+ nodeIsPrimitive(root) ||
+ nodeIsFunction(root) ||
+ !Array.isArray(properties) ||
+ properties.length === 0
+ ) {
+ return "tooltip";
+ }
+
+ return "popover";
+ }
+
+ onMouseOut = () => {
+ const { clearPreview, cx } = this.props;
+
+ clearPreview(cx);
+ };
+
+ onMouseOutException = (shouldClearOnMouseout, isExceptionStactraceOpen) => {
+ // onMouseOutException can be called:
+ // a. when the mouse leaves Popover element
+ // b. when the mouse leaves ExceptionPopup element
+ // We want to prevent closing the popup when the stacktrace
+ // is expanded and the mouse leaves either the Popover element
+ // or the ExceptionPopup element.
+ const { clearPreview, cx } = this.props;
+
+ if (shouldClearOnMouseout) {
+ this.isExceptionStactraceOpen = isExceptionStactraceOpen;
+ }
+
+ if (!this.isExceptionStactraceOpen) {
+ clearPreview(cx);
+ }
+ };
+
+ render() {
+ const {
+ preview: { cursorPos, resultGrip, exception },
+ editorRef,
+ } = this.props;
+
+ if (
+ !exception &&
+ (typeof resultGrip == "undefined" || resultGrip?.optimizedOut)
+ ) {
+ return null;
+ }
+
+ const type = this.getPreviewType();
+ return (
+ <Popover
+ targetPosition={cursorPos}
+ type={type}
+ editorRef={editorRef}
+ target={this.props.preview.target}
+ mouseout={exception ? this.onMouseOutException : this.onMouseOut}
+ >
+ {this.renderPreview()}
+ </Popover>
+ );
+ }
+}
+
+export function addHighlightToTargetSiblings(target, props) {
+ // This function searches for related tokens that should also be highlighted when previewed.
+ // Here is the process:
+ // It conducts a search on the target's next siblings and then another search for the previous siblings.
+ // If a sibling is not an element node (nodeType === 1), the highlight is not added and the search is short-circuited.
+ // If the element sibling is the same token type as the target, and is also found in the preview expression, the highlight class is added.
+
+ const tokenType = target.classList.item(0);
+ const previewExpression = props.preview.expression;
+
+ if (
+ tokenType &&
+ previewExpression &&
+ target.innerHTML !== previewExpression
+ ) {
+ let nextSibling = target.nextSibling;
+ let nextElementSibling = target.nextElementSibling;
+
+ // Note: Declaring previous/next ELEMENT siblings as well because
+ // properties like innerHTML can't be checked on nextSibling
+ // without creating a flow error even if the node is an element type.
+ while (
+ nextSibling &&
+ nextElementSibling &&
+ nextSibling.nodeType === 1 &&
+ nextElementSibling.className.includes(tokenType) &&
+ previewExpression.includes(nextElementSibling.innerHTML)
+ ) {
+ // All checks passed, add highlight and continue the search.
+ nextElementSibling.classList.add("preview-token");
+
+ nextSibling = nextSibling.nextSibling;
+ nextElementSibling = nextElementSibling.nextElementSibling;
+ }
+
+ let previousSibling = target.previousSibling;
+ let previousElementSibling = target.previousElementSibling;
+
+ while (
+ previousSibling &&
+ previousElementSibling &&
+ previousSibling.nodeType === 1 &&
+ previousElementSibling.className.includes(tokenType) &&
+ previewExpression.includes(previousElementSibling.innerHTML)
+ ) {
+ // All checks passed, add highlight and continue the search.
+ previousElementSibling.classList.add("preview-token");
+
+ previousSibling = previousSibling.previousSibling;
+ previousElementSibling = previousElementSibling.previousElementSibling;
+ }
+ }
+}
+
+export function removeHighlightForTargetSiblings(target) {
+ // Look at target's previous and next token siblings.
+ // If they also have the highlight class 'preview-token',
+ // remove that class.
+ let nextSibling = target.nextElementSibling;
+ while (nextSibling && nextSibling.className.includes("preview-token")) {
+ nextSibling.classList.remove("preview-token");
+ nextSibling = nextSibling.nextElementSibling;
+ }
+ let previousSibling = target.previousElementSibling;
+ while (
+ previousSibling &&
+ previousSibling.className.includes("preview-token")
+ ) {
+ previousSibling.classList.remove("preview-token");
+ previousSibling = previousSibling.previousElementSibling;
+ }
+}
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+});
+
+const {
+ addExpression,
+ selectSourceURL,
+ openLink,
+ openElementInInspectorCommand,
+ highlightDomElement,
+ unHighlightDomElement,
+ clearPreview,
+} = actions;
+
+const mapDispatchToProps = {
+ addExpression,
+ selectSourceURL,
+ openLink,
+ openElementInInspector: openElementInInspectorCommand,
+ highlightDomElement,
+ unHighlightDomElement,
+ clearPreview,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(Popup);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/index.js b/devtools/client/debugger/src/components/Editor/Preview/index.js
new file mode 100644
index 0000000000..0e2c70c557
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/index.js
@@ -0,0 +1,136 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import React, { PureComponent } from "react";
+import { connect } from "../../../utils/connect";
+
+import Popup from "./Popup";
+
+import {
+ getPreview,
+ getThreadContext,
+ getCurrentThread,
+ getHighlightedCalls,
+ getIsCurrentThreadPaused,
+} from "../../../selectors";
+import actions from "../../../actions";
+
+const EXCEPTION_MARKER = "mark-text-exception";
+
+class Preview extends PureComponent {
+ target = null;
+ constructor(props) {
+ super(props);
+ this.state = { selecting: false };
+ }
+
+ static get propTypes() {
+ return {
+ clearPreview: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object.isRequired,
+ editorRef: PropTypes.object.isRequired,
+ highlightedCalls: PropTypes.array,
+ isPaused: PropTypes.bool.isRequired,
+ preview: PropTypes.object,
+ setExceptionPreview: PropTypes.func.isRequired,
+ updatePreview: PropTypes.func.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ this.updateListeners();
+ }
+
+ componentWillUnmount() {
+ const { codeMirror } = this.props.editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirror.off("tokenenter", this.onTokenEnter);
+ codeMirror.off("scroll", this.onScroll);
+ codeMirrorWrapper.removeEventListener("mouseup", this.onMouseUp);
+ codeMirrorWrapper.removeEventListener("mousedown", this.onMouseDown);
+ }
+
+ updateListeners(prevProps) {
+ const { codeMirror } = this.props.editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+ codeMirror.on("tokenenter", this.onTokenEnter);
+ codeMirror.on("scroll", this.onScroll);
+ codeMirrorWrapper.addEventListener("mouseup", this.onMouseUp);
+ codeMirrorWrapper.addEventListener("mousedown", this.onMouseDown);
+ }
+
+ onTokenEnter = ({ target, tokenPos }) => {
+ const { cx, editor, updatePreview, highlightedCalls, setExceptionPreview } =
+ this.props;
+
+ const isTargetException = target.classList.contains(EXCEPTION_MARKER);
+
+ if (isTargetException) {
+ setExceptionPreview(cx, target, tokenPos, editor.codeMirror);
+ return;
+ }
+
+ if (
+ this.props.isPaused &&
+ !this.state.selecting &&
+ highlightedCalls === null &&
+ !isTargetException
+ ) {
+ updatePreview(cx, target, tokenPos, editor.codeMirror);
+ }
+ };
+
+ onMouseUp = () => {
+ if (this.props.isPaused) {
+ this.setState({ selecting: false });
+ }
+ };
+
+ onMouseDown = () => {
+ if (this.props.isPaused) {
+ this.setState({ selecting: true });
+ }
+ };
+
+ onScroll = () => {
+ if (this.props.isPaused) {
+ this.props.clearPreview(this.props.cx);
+ }
+ };
+
+ render() {
+ const { preview } = this.props;
+ if (!preview || this.state.selecting) {
+ return null;
+ }
+
+ return (
+ <Popup
+ preview={preview}
+ editor={this.props.editor}
+ editorRef={this.props.editorRef}
+ />
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const thread = getCurrentThread(state);
+ return {
+ highlightedCalls: getHighlightedCalls(state, thread),
+ cx: getThreadContext(state),
+ preview: getPreview(state),
+ isPaused: getIsCurrentThreadPaused(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ clearPreview: actions.clearPreview,
+ addExpression: actions.addExpression,
+ updatePreview: actions.updatePreview,
+ setExceptionPreview: actions.setExceptionPreview,
+})(Preview);
diff --git a/devtools/client/debugger/src/components/Editor/Preview/moz.build b/devtools/client/debugger/src/components/Editor/Preview/moz.build
new file mode 100644
index 0000000000..362faadc42
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "ExceptionPopup.js",
+ "index.js",
+ "Popup.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js
new file mode 100644
index 0000000000..8c58fe9c63
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Preview/tests/Popup.spec.js
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import {
+ addHighlightToTargetSiblings,
+ removeHighlightForTargetSiblings,
+} from "../Popup";
+
+describe("addHighlightToTargetSiblings", () => {
+ it("should add preview highlight class to related target siblings", async () => {
+ const div = document.createElement("div");
+ const divChildren = ["a", "divided", "token"];
+ divChildren.forEach(function (span) {
+ const child = document.createElement("span");
+ const text = document.createTextNode(span);
+ child.appendChild(text);
+ child.classList.add("cm-property");
+ div.appendChild(child);
+ });
+
+ const target = div.children[1];
+ const props = {
+ preview: {
+ expression: "adividedtoken",
+ },
+ };
+
+ addHighlightToTargetSiblings(target, props);
+
+ const previous = target.previousElementSibling;
+ if (previous && previous.className) {
+ expect(previous.className.includes("preview-token")).toEqual(true);
+ }
+
+ const next = target.nextElementSibling;
+ if (next && next.className) {
+ expect(next.className.includes("preview-token")).toEqual(true);
+ }
+ });
+
+ it("should not add preview highlight class to target's related siblings after non-element nodes", () => {
+ const div = document.createElement("div");
+
+ const elementBeforePeriod = document.createElement("span");
+ elementBeforePeriod.innerHTML = "object";
+ elementBeforePeriod.classList.add("cm-property");
+ div.appendChild(elementBeforePeriod);
+
+ const period = document.createTextNode(".");
+ div.appendChild(period);
+
+ const target = document.createElement("span");
+ target.innerHTML = "property";
+ target.classList.add("cm-property");
+ div.appendChild(target);
+
+ const anotherPeriod = document.createTextNode(".");
+ div.appendChild(anotherPeriod);
+
+ const elementAfterPeriod = document.createElement("span");
+ elementAfterPeriod.innerHTML = "anotherProperty";
+ elementAfterPeriod.classList.add("cm-property");
+ div.appendChild(elementAfterPeriod);
+
+ const props = {
+ preview: {
+ expression: "object.property.anotherproperty",
+ },
+ };
+ addHighlightToTargetSiblings(target, props);
+
+ expect(elementBeforePeriod.className.includes("preview-token")).toEqual(
+ false
+ );
+ expect(elementAfterPeriod.className.includes("preview-token")).toEqual(
+ false
+ );
+ });
+});
+
+describe("removeHighlightForTargetSiblings", () => {
+ it("should remove preview highlight class from target's related siblings", async () => {
+ const div = document.createElement("div");
+ const divChildren = ["a", "divided", "token"];
+ divChildren.forEach(function (span) {
+ const child = document.createElement("span");
+ const text = document.createTextNode(span);
+ child.appendChild(text);
+ child.classList.add("preview-token");
+ div.appendChild(child);
+ });
+ const target = div.children[1];
+
+ removeHighlightForTargetSiblings(target);
+
+ const previous = target.previousElementSibling;
+ if (previous && previous.className) {
+ expect(previous.className.includes("preview-token")).toEqual(false);
+ }
+
+ const next = target.nextElementSibling;
+ if (next && next.className) {
+ expect(next.className.includes("preview-token")).toEqual(false);
+ }
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.css b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css
new file mode 100644
index 0000000000..0f75783c00
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.css
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.search-bar {
+ position: relative;
+ display: flex;
+ border-top: 1px solid var(--theme-splitter-color);
+ height: var(--editor-searchbar-height);
+}
+
+/* display a fake outline above the search bar's top border, and above
+ the source footer's top border */
+.search-bar::before {
+ content: "";
+ position: absolute;
+ z-index: 10;
+ top: -1px;
+ left: 0;
+ right: 0;
+ bottom: -1px;
+ border: solid 1px var(--blue-50);
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 150ms ease-out;
+}
+
+.search-bar:focus-within::before {
+ opacity: 1;
+}
+
+.search-bar .search-outline {
+ flex-grow: 1;
+ border-width: 0;
+}
+
+.search-bar .result-list {
+ max-height: 230px;
+}
diff --git a/devtools/client/debugger/src/components/Editor/SearchInFileBar.js b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js
new file mode 100644
index 0000000000..80a6d28fb0
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/SearchInFileBar.js
@@ -0,0 +1,371 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import React, { Component } from "react";
+import { connect } from "../../utils/connect";
+import actions from "../../actions";
+import {
+ getActiveSearch,
+ getSelectedSource,
+ getContext,
+ getSelectedSourceTextContent,
+ getSearchOptions,
+} from "../../selectors";
+
+import { searchKeys } from "../../constants";
+import { scrollList } from "../../utils/result-list";
+
+import SearchInput from "../shared/SearchInput";
+import "./SearchInFileBar.css";
+
+const { PluralForm } = require("devtools/shared/plural-form");
+const { debounce } = require("devtools/shared/debounce");
+import { renderWasmText } from "../../utils/wasm";
+import {
+ clearSearch,
+ find,
+ findNext,
+ findPrev,
+ removeOverlay,
+} from "../../utils/editor";
+import { isFulfilled } from "../../utils/async-value";
+
+function getSearchShortcut() {
+ return L10N.getStr("sourceSearch.search.key2");
+}
+
+class SearchInFileBar extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ query: "",
+ selectedResultIndex: 0,
+ results: {
+ matches: [],
+ matchIndex: -1,
+ count: 0,
+ index: -1,
+ },
+ inputFocused: false,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ closeFileSearch: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ editor: PropTypes.object,
+ modifiers: PropTypes.object.isRequired,
+ searchInFileEnabled: PropTypes.bool.isRequired,
+ selectedSourceTextContent: PropTypes.bool.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ setActiveSearch: PropTypes.func.isRequired,
+ querySearchWorker: PropTypes.func.isRequired,
+ };
+ }
+
+ componentWillUnmount() {
+ const { shortcuts } = this.context;
+
+ shortcuts.off(getSearchShortcut(), this.toggleSearch);
+ shortcuts.off("Escape", this.onEscape);
+
+ this.doSearch.cancel();
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { query } = this.state;
+ // If a new source is selected update the file search results
+ if (
+ this.props.selectedSource &&
+ nextProps.selectedSource !== this.props.selectedSource &&
+ this.props.searchInFileEnabled &&
+ query
+ ) {
+ this.doSearch(query, false);
+ }
+ }
+
+ componentDidMount() {
+ // overwrite this.doSearch with debounced version to
+ // reduce frequency of queries
+ this.doSearch = debounce(this.doSearch, 100);
+ const { shortcuts } = this.context;
+
+ shortcuts.on(getSearchShortcut(), this.toggleSearch);
+ shortcuts.on("Escape", this.onEscape);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (this.refs.resultList && this.refs.resultList.refs) {
+ scrollList(this.refs.resultList.refs, this.state.selectedResultIndex);
+ }
+ }
+
+ onEscape = e => {
+ this.closeSearch(e);
+ };
+
+ clearSearch = () => {
+ const { editor: ed } = this.props;
+ if (ed) {
+ const ctx = { ed, cm: ed.codeMirror };
+ removeOverlay(ctx, this.state.query);
+ }
+ };
+
+ closeSearch = e => {
+ const { cx, closeFileSearch, editor, searchInFileEnabled } = this.props;
+ this.clearSearch();
+ if (editor && searchInFileEnabled) {
+ closeFileSearch(cx, editor);
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ this.setState({ inputFocused: false });
+ };
+
+ toggleSearch = e => {
+ e.stopPropagation();
+ e.preventDefault();
+ const { editor, searchInFileEnabled, setActiveSearch } = this.props;
+
+ // Set inputFocused to false, so that search query is highlighted whenever search shortcut is used, even if the input already has focus.
+ this.setState({ inputFocused: false });
+
+ if (!searchInFileEnabled) {
+ setActiveSearch("file");
+ }
+
+ if (searchInFileEnabled && editor) {
+ const query = editor.codeMirror.getSelection() || this.state.query;
+
+ if (query !== "") {
+ this.setState({ query, inputFocused: true });
+ this.doSearch(query);
+ } else {
+ this.setState({ query: "", inputFocused: true });
+ }
+ }
+ };
+
+ doSearch = async (query, focusFirstResult = true) => {
+ const { editor, modifiers, selectedSourceTextContent } = this.props;
+ if (
+ !editor ||
+ !selectedSourceTextContent ||
+ !isFulfilled(selectedSourceTextContent) ||
+ !modifiers
+ ) {
+ return;
+ }
+ const selectedContent = selectedSourceTextContent.value;
+
+ const ctx = { ed: editor, cm: editor.codeMirror };
+
+ if (!query) {
+ clearSearch(ctx.cm, query);
+ return;
+ }
+
+ let text;
+ if (selectedContent.type === "wasm") {
+ text = renderWasmText(this.props.selectedSource.id, selectedContent).join(
+ "\n"
+ );
+ } else {
+ text = selectedContent.value;
+ }
+
+ const matches = await this.props.querySearchWorker(query, text, modifiers);
+
+ const res = find(ctx, query, true, modifiers, focusFirstResult);
+ if (!res) {
+ return;
+ }
+
+ const { ch, line } = res;
+
+ const matchIndex = matches.findIndex(
+ elm => elm.line === line && elm.ch === ch
+ );
+ this.setState({
+ results: {
+ matches,
+ matchIndex,
+ count: matches.length,
+ index: ch,
+ },
+ });
+ };
+
+ traverseResults = (e, reverse = false) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const { editor } = this.props;
+
+ if (!editor) {
+ return;
+ }
+
+ const ctx = { ed: editor, cm: editor.codeMirror };
+
+ const { modifiers } = this.props;
+ const { query } = this.state;
+ const { matches } = this.state.results;
+
+ if (query === "" && !this.props.searchInFileEnabled) {
+ this.props.setActiveSearch("file");
+ }
+
+ if (modifiers) {
+ const findArgs = [ctx, query, true, modifiers];
+ const results = reverse ? findPrev(...findArgs) : findNext(...findArgs);
+
+ if (!results) {
+ return;
+ }
+ const { ch, line } = results;
+ const matchIndex = matches.findIndex(
+ elm => elm.line === line && elm.ch === ch
+ );
+ this.setState({
+ results: {
+ matches,
+ matchIndex,
+ count: matches.length,
+ index: ch,
+ },
+ });
+ }
+ };
+
+ // Handlers
+
+ onChange = e => {
+ this.setState({ query: e.target.value });
+
+ return this.doSearch(e.target.value);
+ };
+
+ onFocus = e => {
+ this.setState({ inputFocused: true });
+ };
+
+ onBlur = e => {
+ this.setState({ inputFocused: false });
+ };
+
+ onKeyDown = e => {
+ if (e.key !== "Enter" && e.key !== "F3") {
+ return;
+ }
+
+ this.traverseResults(e, e.shiftKey);
+ e.preventDefault();
+ this.doSearch(e.target.value);
+ };
+
+ onHistoryScroll = query => {
+ this.setState({ query });
+ this.doSearch(query);
+ };
+
+ // Renderers
+ buildSummaryMsg() {
+ const {
+ query,
+ results: { matchIndex, count, index },
+ } = this.state;
+
+ if (query.trim() == "") {
+ return "";
+ }
+
+ if (count == 0) {
+ return L10N.getStr("editor.noResultsFound");
+ }
+
+ if (index == -1) {
+ const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary1");
+ return PluralForm.get(count, resultsSummaryString).replace("#1", count);
+ }
+
+ const searchResultsString = L10N.getStr("editor.searchResults1");
+ return PluralForm.get(count, searchResultsString)
+ .replace("#1", count)
+ .replace("%d", matchIndex + 1);
+ }
+
+ shouldShowErrorEmoji() {
+ const {
+ query,
+ results: { count },
+ } = this.state;
+ return !!query && !count;
+ }
+
+ render() {
+ const { searchInFileEnabled } = this.props;
+ const {
+ results: { count },
+ } = this.state;
+
+ if (!searchInFileEnabled) {
+ return <div />;
+ }
+
+ return (
+ <div className="search-bar">
+ <SearchInput
+ query={this.state.query}
+ count={count}
+ placeholder={L10N.getStr("sourceSearch.search.placeholder2")}
+ summaryMsg={this.buildSummaryMsg()}
+ isLoading={false}
+ onChange={this.onChange}
+ onFocus={this.onFocus}
+ onBlur={this.onBlur}
+ showErrorEmoji={this.shouldShowErrorEmoji()}
+ onKeyDown={this.onKeyDown}
+ onHistoryScroll={this.onHistoryScroll}
+ handleNext={e => this.traverseResults(e, false)}
+ handlePrev={e => this.traverseResults(e, true)}
+ shouldFocus={this.state.inputFocused}
+ showClose={true}
+ showExcludePatterns={false}
+ handleClose={this.closeSearch}
+ showSearchModifiers={true}
+ searchKey={searchKeys.FILE_SEARCH}
+ onToggleSearchModifier={() => this.doSearch(this.state.query)}
+ />
+ </div>
+ );
+ }
+}
+
+SearchInFileBar.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = (state, p) => {
+ const selectedSource = getSelectedSource(state);
+
+ return {
+ cx: getContext(state),
+ searchInFileEnabled: getActiveSearch(state) === "file",
+ selectedSource,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ modifiers: getSearchOptions(state, "file-search"),
+ };
+};
+
+export default connect(mapStateToProps, {
+ setFileSearchQuery: actions.setFileSearchQuery,
+ setActiveSearch: actions.setActiveSearch,
+ closeFileSearch: actions.closeFileSearch,
+ querySearchWorker: actions.querySearchWorker,
+})(SearchInFileBar);
diff --git a/devtools/client/debugger/src/components/Editor/Tab.js b/devtools/client/debugger/src/components/Editor/Tab.js
new file mode 100644
index 0000000000..2f296f9346
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tab.js
@@ -0,0 +1,282 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+import { showMenu, buildMenu } from "../../context-menu/menu";
+
+import SourceIcon from "../shared/SourceIcon";
+import { CloseButton } from "../shared/Button";
+import { copyToTheClipboard } from "../../utils/clipboard";
+
+import actions from "../../actions";
+
+import {
+ getDisplayPath,
+ getFileURL,
+ getRawSourceURL,
+ getSourceQueryString,
+ getTruncatedFileName,
+ isPretty,
+ shouldBlackbox,
+} from "../../utils/source";
+import { getTabMenuItems } from "../../utils/tabs";
+import { createLocation } from "../../utils/location";
+
+import {
+ getSelectedLocation,
+ getActiveSearch,
+ getSourcesForTabs,
+ isSourceBlackBoxed,
+ getContext,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+class Tab extends PureComponent {
+ static get propTypes() {
+ return {
+ activeSearch: PropTypes.string,
+ closeTab: PropTypes.func.isRequired,
+ closeTabs: PropTypes.func.isRequired,
+ copyToClipboard: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ onDragEnd: PropTypes.func.isRequired,
+ onDragOver: PropTypes.func.isRequired,
+ onDragStart: PropTypes.func.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ selectedLocation: PropTypes.object,
+ showSource: PropTypes.func.isRequired,
+ source: PropTypes.object.isRequired,
+ sourceActor: PropTypes.object.isRequired,
+ tabSources: PropTypes.array.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ togglePrettyPrint: PropTypes.func.isRequired,
+ isBlackBoxed: PropTypes.bool.isRequired,
+ isSourceOnIgnoreList: PropTypes.bool.isRequired,
+ };
+ }
+
+ onTabContextMenu = (event, tab) => {
+ event.preventDefault();
+ this.showContextMenu(event, tab);
+ };
+
+ showContextMenu(e, tab) {
+ const {
+ cx,
+ closeTab,
+ closeTabs,
+ copyToClipboard,
+ tabSources,
+ showSource,
+ toggleBlackBox,
+ togglePrettyPrint,
+ selectedLocation,
+ source,
+ isBlackBoxed,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ const tabCount = tabSources.length;
+ const otherTabs = tabSources.filter(t => t.id !== tab);
+ const sourceTab = tabSources.find(t => t.id == tab);
+ const tabURLs = tabSources.map(t => t.url);
+ const otherTabURLs = otherTabs.map(t => t.url);
+
+ if (!sourceTab || !selectedLocation || !selectedLocation.sourceId) {
+ return;
+ }
+
+ const tabMenuItems = getTabMenuItems();
+ const items = [
+ {
+ item: {
+ ...tabMenuItems.closeTab,
+ click: () => closeTab(cx, sourceTab),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.closeOtherTabs,
+ click: () => closeTabs(cx, otherTabURLs),
+ disabled: otherTabURLs.length === 0,
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.closeTabsToEnd,
+ click: () => {
+ const tabIndex = tabSources.findIndex(t => t.id == tab);
+ closeTabs(
+ cx,
+ tabURLs.filter((t, i) => i > tabIndex)
+ );
+ },
+ disabled:
+ tabCount === 1 ||
+ tabSources.some((t, i) => t === tab && tabCount - 1 === i),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.closeAllTabs,
+ click: () => closeTabs(cx, tabURLs),
+ },
+ },
+ { item: { type: "separator" } },
+ {
+ item: {
+ ...tabMenuItems.copySource,
+ disabled: selectedLocation.sourceId !== tab,
+ click: () => copyToClipboard(sourceTab),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.copySourceUri2,
+ disabled: !selectedLocation.sourceUrl,
+ click: () => copyToTheClipboard(getRawSourceURL(sourceTab.url)),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.showSource,
+ disabled: !selectedLocation.sourceUrl,
+ click: () => showSource(cx, tab),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.toggleBlackBox,
+ label: isBlackBoxed
+ ? L10N.getStr("ignoreContextItem.unignore")
+ : L10N.getStr("ignoreContextItem.ignore"),
+ disabled: isSourceOnIgnoreList || !shouldBlackbox(source),
+ click: () => toggleBlackBox(cx, source),
+ },
+ },
+ {
+ item: {
+ ...tabMenuItems.prettyPrint,
+ click: () => togglePrettyPrint(cx, tab),
+ disabled: isPretty(sourceTab),
+ },
+ },
+ ];
+
+ showMenu(e, buildMenu(items));
+ }
+
+ isSourceSearchEnabled() {
+ return this.props.activeSearch === "source";
+ }
+
+ render() {
+ const {
+ cx,
+ selectedLocation,
+ selectSource,
+ closeTab,
+ source,
+ sourceActor,
+ tabSources,
+ onDragOver,
+ onDragStart,
+ onDragEnd,
+ } = this.props;
+ const sourceId = source.id;
+ const active =
+ selectedLocation &&
+ sourceId == selectedLocation.sourceId &&
+ !this.isSourceSearchEnabled();
+ const isPrettyCode = isPretty(source);
+
+ function onClickClose(e) {
+ e.stopPropagation();
+ closeTab(cx, source);
+ }
+
+ function handleTabClick(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ return selectSource(cx, source, sourceActor);
+ }
+
+ const className = classnames("source-tab", {
+ active,
+ pretty: isPrettyCode,
+ blackboxed: this.props.isBlackBoxed,
+ });
+
+ const path = getDisplayPath(source, tabSources);
+ const query = getSourceQueryString(source);
+
+ return (
+ <div
+ draggable
+ onDragOver={onDragOver}
+ onDragStart={onDragStart}
+ onDragEnd={onDragEnd}
+ className={className}
+ key={sourceId}
+ onClick={handleTabClick}
+ // Accommodate middle click to close tab
+ onMouseUp={e => e.button === 1 && closeTab(cx, source)}
+ onContextMenu={e => this.onTabContextMenu(e, sourceId)}
+ title={getFileURL(source, false)}
+ >
+ <SourceIcon
+ location={createLocation({ source, sourceActor })}
+ forTab={true}
+ modifier={icon =>
+ ["file", "javascript"].includes(icon) ? null : icon
+ }
+ />
+ <div className="filename">
+ {getTruncatedFileName(source, query)}
+ {path && <span>{`../${path}/..`}</span>}
+ </div>
+ <CloseButton
+ handleClick={onClickClose}
+ tooltip={L10N.getStr("sourceTabs.closeTabButtonTooltip")}
+ />
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = (state, { source }) => {
+ return {
+ cx: getContext(state),
+ tabSources: getSourcesForTabs(state),
+ selectedLocation: getSelectedLocation(state),
+ isBlackBoxed: isSourceBlackBoxed(state, source),
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, source),
+ activeSearch: getActiveSearch(state),
+ };
+};
+
+export default connect(
+ mapStateToProps,
+ {
+ selectSource: actions.selectSource,
+ copyToClipboard: actions.copyToClipboard,
+ closeTab: actions.closeTab,
+ closeTabs: actions.closeTabs,
+ togglePrettyPrint: actions.togglePrettyPrint,
+ showSource: actions.showSource,
+ toggleBlackBox: actions.toggleBlackBox,
+ },
+ null,
+ {
+ withRef: true,
+ }
+)(Tab);
diff --git a/devtools/client/debugger/src/components/Editor/Tabs.css b/devtools/client/debugger/src/components/Editor/Tabs.css
new file mode 100644
index 0000000000..565d8588f1
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tabs.css
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.source-header {
+ display: flex;
+ width: 100%;
+ height: var(--editor-header-height);
+ border-bottom: 1px solid var(--theme-splitter-color);
+ background-color: var(--theme-toolbar-background);
+}
+
+.source-header * {
+ user-select: none;
+}
+
+.source-header .command-bar {
+ flex: initial;
+ flex-shrink: 0;
+ border-bottom: 0;
+ border-inline-start: 1px solid var(--theme-splitter-color);
+}
+
+.source-tabs {
+ flex: auto;
+ align-self: flex-start;
+ align-items: flex-start;
+ /* Reserve space for the overflow button (even if not visible) */
+ padding-inline-end: 28px;
+}
+
+.source-tab {
+ display: inline-flex;
+ align-items: center;
+ position: relative;
+ min-width: 40px;
+ max-width: 100%;
+ overflow: hidden;
+ padding: 4px 10px;
+ cursor: default;
+ height: calc(var(--editor-header-height) - 1px);
+ font-size: 12px;
+ background-color: transparent;
+ vertical-align: bottom;
+}
+
+.source-tab::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background-color: var(--tab-line-color, transparent);
+ transition: transform 250ms var(--animation-curve),
+ opacity 250ms var(--animation-curve);
+ opacity: 0;
+ transform: scaleX(0);
+}
+
+.source-tab.active {
+ --tab-line-color: var(--tab-line-selected-color);
+ color: var(--theme-toolbar-selected-color);
+ border-bottom-color: transparent;
+}
+
+.source-tab:not(.active):hover {
+ --tab-line-color: var(--tab-line-hover-color);
+ background-color: var(--theme-toolbar-hover);
+}
+
+.source-tab:hover::before,
+.source-tab.active::before {
+ opacity: 1;
+ transform: scaleX(1);
+}
+
+.source-tab .img:is(.prettyPrint,.blackBox) {
+ mask-size: 14px;
+}
+
+.source-tab .img.prettyPrint {
+ background-color: currentColor;
+}
+
+.source-tab .img.source-icon.blackBox {
+ background-color: #806414;
+}
+
+.source-tab .filename {
+ display: block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-inline-end: 4px;
+}
+
+.source-tab .filename span {
+ opacity: 0.7;
+ padding-inline-start: 4px;
+}
+
+.source-tab .close-btn {
+ visibility: hidden;
+ margin-inline-end: -6px;
+}
+
+.source-tab.active .close-btn {
+ color: inherit;
+}
+
+.source-tab.active .close-btn,
+.source-tab:hover .close-btn {
+ visibility: visible;
+}
+
+.source-tab.active .source-icon {
+ background-color: currentColor;
+}
+
+.source-tab .close-btn:hover,
+.source-tab .close-btn:focus {
+ color: var(--theme-selection-color);
+ background-color: var(--theme-selection-background);
+}
diff --git a/devtools/client/debugger/src/components/Editor/Tabs.js b/devtools/client/debugger/src/components/Editor/Tabs.js
new file mode 100644
index 0000000000..3f38f216a0
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/Tabs.js
@@ -0,0 +1,332 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import ReactDOM from "react-dom";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+import {
+ getSourceTabs,
+ getSelectedSource,
+ getSourcesForTabs,
+ getIsPaused,
+ getCurrentThread,
+ getContext,
+ getBlackBoxRanges,
+} from "../../selectors";
+import { isVisible } from "../../utils/ui";
+
+import { getHiddenTabs } from "../../utils/tabs";
+import { getFilename, isPretty, getFileURL } from "../../utils/source";
+import actions from "../../actions";
+
+import "./Tabs.css";
+
+import Tab from "./Tab";
+import { PaneToggleButton } from "../shared/Button";
+import Dropdown from "../shared/Dropdown";
+import AccessibleImage from "../shared/AccessibleImage";
+import CommandBar from "../SecondaryPanes/CommandBar";
+
+const { debounce } = require("devtools/shared/debounce");
+
+function haveTabSourcesChanged(tabSources, prevTabSources) {
+ if (tabSources.length !== prevTabSources.length) {
+ return true;
+ }
+
+ for (let i = 0; i < tabSources.length; ++i) {
+ if (tabSources[i].id !== prevTabSources[i].id) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+class Tabs extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ dropdownShown: false,
+ hiddenTabs: [],
+ };
+
+ this.onResize = debounce(() => {
+ this.updateHiddenTabs();
+ });
+ }
+
+ static get propTypes() {
+ return {
+ cx: PropTypes.object.isRequired,
+ endPanelCollapsed: PropTypes.bool.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ moveTab: PropTypes.func.isRequired,
+ moveTabBySourceId: PropTypes.func.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object,
+ blackBoxRanges: PropTypes.object.isRequired,
+ startPanelCollapsed: PropTypes.bool.isRequired,
+ tabSources: PropTypes.array.isRequired,
+ tabs: PropTypes.array.isRequired,
+ togglePaneCollapse: PropTypes.func.isRequired,
+ };
+ }
+
+ get draggedSource() {
+ return this._draggedSource == null
+ ? { url: null, id: null }
+ : this._draggedSource;
+ }
+
+ set draggedSource(source) {
+ this._draggedSource = source;
+ }
+
+ get draggedSourceIndex() {
+ return this._draggedSourceIndex == null ? -1 : this._draggedSourceIndex;
+ }
+
+ set draggedSourceIndex(index) {
+ this._draggedSourceIndex = index;
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ this.props.selectedSource !== prevProps.selectedSource ||
+ haveTabSourcesChanged(this.props.tabSources, prevProps.tabSources)
+ ) {
+ this.updateHiddenTabs();
+ }
+ }
+
+ componentDidMount() {
+ window.requestIdleCallback(this.updateHiddenTabs);
+ window.addEventListener("resize", this.onResize);
+ window.document
+ .querySelector(".editor-pane")
+ .addEventListener("resizeend", this.onResize);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener("resize", this.onResize);
+ window.document
+ .querySelector(".editor-pane")
+ .removeEventListener("resizeend", this.onResize);
+ }
+
+ /*
+ * Updates the hiddenSourceTabs state, by
+ * finding the source tabs which are wrapped and are not on the top row.
+ */
+ updateHiddenTabs = () => {
+ if (!this.refs.sourceTabs) {
+ return;
+ }
+ const { selectedSource, tabSources, moveTab } = this.props;
+ const sourceTabEls = this.refs.sourceTabs.children;
+ const hiddenTabs = getHiddenTabs(tabSources, sourceTabEls);
+
+ if (
+ selectedSource &&
+ isVisible() &&
+ hiddenTabs.find(tab => tab.id == selectedSource.id)
+ ) {
+ moveTab(selectedSource.url, 0);
+ return;
+ }
+
+ this.setState({ hiddenTabs });
+ };
+
+ toggleSourcesDropdown() {
+ this.setState(prevState => ({
+ dropdownShown: !prevState.dropdownShown,
+ }));
+ }
+
+ getIconClass(source) {
+ if (isPretty(source)) {
+ return "prettyPrint";
+ }
+ if (this.props.blackBoxRanges[source.url]) {
+ return "blackBox";
+ }
+ return "file";
+ }
+
+ renderDropdownSource = source => {
+ const { cx, selectSource } = this.props;
+ const filename = getFilename(source);
+
+ const onClick = () => selectSource(cx, source);
+ return (
+ <li key={source.id} onClick={onClick} title={getFileURL(source, false)}>
+ <AccessibleImage
+ className={`dropdown-icon ${this.getIconClass(source)}`}
+ />
+ <span className="dropdown-label">{filename}</span>
+ </li>
+ );
+ };
+
+ onTabDragStart = (source, index) => {
+ this.draggedSource = source;
+ this.draggedSourceIndex = index;
+ };
+
+ onTabDragEnd = () => {
+ this.draggedSource = null;
+ this.draggedSourceIndex = null;
+ };
+
+ onTabDragOver = (e, source, hoveredTabIndex) => {
+ const { moveTabBySourceId } = this.props;
+ if (hoveredTabIndex === this.draggedSourceIndex) {
+ return;
+ }
+
+ const tabDOM = ReactDOM.findDOMNode(
+ this.refs[`tab_${source.id}`].getWrappedInstance()
+ );
+
+ const tabDOMRect = tabDOM.getBoundingClientRect();
+ const { pageX: mouseCursorX } = e;
+ if (
+ /* Case: the mouse cursor moves into the left half of any target tab */
+ mouseCursorX - tabDOMRect.left <
+ tabDOMRect.width / 2
+ ) {
+ // The current tab goes to the left of the target tab
+ const targetTab =
+ hoveredTabIndex > this.draggedSourceIndex
+ ? hoveredTabIndex - 1
+ : hoveredTabIndex;
+ moveTabBySourceId(this.draggedSource.id, targetTab);
+ this.draggedSourceIndex = targetTab;
+ } else if (
+ /* Case: the mouse cursor moves into the right half of any target tab */
+ mouseCursorX - tabDOMRect.left >=
+ tabDOMRect.width / 2
+ ) {
+ // The current tab goes to the right of the target tab
+ const targetTab =
+ hoveredTabIndex < this.draggedSourceIndex
+ ? hoveredTabIndex + 1
+ : hoveredTabIndex;
+ moveTabBySourceId(this.draggedSource.id, targetTab);
+ this.draggedSourceIndex = targetTab;
+ }
+ };
+
+ renderTabs() {
+ const { tabs } = this.props;
+ if (!tabs) {
+ return null;
+ }
+
+ return (
+ <div className="source-tabs" ref="sourceTabs">
+ {tabs.map(({ source, sourceActor }, index) => {
+ return (
+ <Tab
+ onDragStart={_ => this.onTabDragStart(source, index)}
+ onDragOver={e => {
+ this.onTabDragOver(e, source, index);
+ e.preventDefault();
+ }}
+ onDragEnd={this.onTabDragEnd}
+ key={index}
+ source={source}
+ sourceActor={sourceActor}
+ ref={`tab_${source.id}`}
+ />
+ );
+ })}
+ </div>
+ );
+ }
+
+ renderDropdown() {
+ const { hiddenTabs } = this.state;
+ if (!hiddenTabs || !hiddenTabs.length) {
+ return null;
+ }
+
+ const Panel = <ul>{hiddenTabs.map(this.renderDropdownSource)}</ul>;
+ const icon = <AccessibleImage className="more-tabs" />;
+
+ return <Dropdown panel={Panel} icon={icon} />;
+ }
+
+ renderCommandBar() {
+ const { horizontal, endPanelCollapsed, isPaused } = this.props;
+ if (!endPanelCollapsed || !isPaused) {
+ return null;
+ }
+
+ return <CommandBar horizontal={horizontal} />;
+ }
+
+ renderStartPanelToggleButton() {
+ return (
+ <PaneToggleButton
+ position="start"
+ collapsed={this.props.startPanelCollapsed}
+ handleClick={this.props.togglePaneCollapse}
+ />
+ );
+ }
+
+ renderEndPanelToggleButton() {
+ const { horizontal, endPanelCollapsed, togglePaneCollapse } = this.props;
+ if (!horizontal) {
+ return null;
+ }
+
+ return (
+ <PaneToggleButton
+ position="end"
+ collapsed={endPanelCollapsed}
+ handleClick={togglePaneCollapse}
+ horizontal={horizontal}
+ />
+ );
+ }
+
+ render() {
+ return (
+ <div className="source-header">
+ {this.renderStartPanelToggleButton()}
+ {this.renderTabs()}
+ {this.renderDropdown()}
+ {this.renderEndPanelToggleButton()}
+ {this.renderCommandBar()}
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ cx: getContext(state),
+ selectedSource: getSelectedSource(state),
+ tabSources: getSourcesForTabs(state),
+ tabs: getSourceTabs(state),
+ blackBoxRanges: getBlackBoxRanges(state),
+ isPaused: getIsPaused(state, getCurrentThread(state)),
+ };
+};
+
+export default connect(mapStateToProps, {
+ selectSource: actions.selectSource,
+ moveTab: actions.moveTab,
+ moveTabBySourceId: actions.moveTabBySourceId,
+ closeTab: actions.closeTab,
+ togglePaneCollapse: actions.togglePaneCollapse,
+ showSource: actions.showSource,
+})(Tabs);
diff --git a/devtools/client/debugger/src/components/Editor/index.js b/devtools/client/debugger/src/components/Editor/index.js
new file mode 100644
index 0000000000..fcaa129944
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/index.js
@@ -0,0 +1,808 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import PropTypes from "prop-types";
+import React, { PureComponent } from "react";
+import { bindActionCreators } from "redux";
+import ReactDOM from "react-dom";
+import { connect } from "../../utils/connect";
+
+import { getLineText, isLineBlackboxed } from "./../../utils/source";
+import { createLocation } from "./../../utils/location";
+import { features } from "../../utils/prefs";
+import { getIndentation } from "../../utils/indentation";
+
+import { showMenu } from "../../context-menu/menu";
+import {
+ createBreakpointItems,
+ breakpointItemActions,
+} from "./menus/breakpoints";
+
+import {
+ continueToHereItem,
+ editorItemActions,
+ blackBoxLineMenuItem,
+} from "./menus/editor";
+
+import {
+ getActiveSearch,
+ getSelectedLocation,
+ getSelectedSource,
+ getSelectedSourceTextContent,
+ getSelectedBreakableLines,
+ getConditionalPanelLocation,
+ getSymbols,
+ getIsCurrentThreadPaused,
+ getCurrentThread,
+ getThreadContext,
+ getSkipPausing,
+ getInlinePreview,
+ getEditorWrapping,
+ getHighlightedCalls,
+ getBlackBoxRanges,
+ isSourceBlackBoxed,
+ getHighlightedLineRangeForSelectedSource,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+
+// Redux actions
+import actions from "../../actions";
+
+import SearchInFileBar from "./SearchInFileBar";
+import HighlightLines from "./HighlightLines";
+import Preview from "./Preview";
+import Breakpoints from "./Breakpoints";
+import ColumnBreakpoints from "./ColumnBreakpoints";
+import DebugLine from "./DebugLine";
+import HighlightLine from "./HighlightLine";
+import EmptyLines from "./EmptyLines";
+import EditorMenu from "./EditorMenu";
+import ConditionalPanel from "./ConditionalPanel";
+import InlinePreviews from "./InlinePreviews";
+import HighlightCalls from "./HighlightCalls";
+import Exceptions from "./Exceptions";
+import BlackboxLines from "./BlackboxLines";
+
+import {
+ showSourceText,
+ showLoading,
+ showErrorMessage,
+ getEditor,
+ clearEditor,
+ getCursorLine,
+ getCursorColumn,
+ lineAtHeight,
+ toSourceLine,
+ getDocument,
+ scrollToColumn,
+ toEditorPosition,
+ getSourceLocationFromMouseEvent,
+ hasDocument,
+ onMouseOver,
+ startOperation,
+ endOperation,
+} from "../../utils/editor";
+
+import { resizeToggleButton, resizeBreakpointGutter } from "../../utils/ui";
+
+const { debounce } = require("devtools/shared/debounce");
+const classnames = require("devtools/client/shared/classnames.js");
+
+const { appinfo } = Services;
+const isMacOS = appinfo.OS === "Darwin";
+
+function isSecondary(ev) {
+ return isMacOS && ev.ctrlKey && ev.button === 0;
+}
+
+function isCmd(ev) {
+ return isMacOS ? ev.metaKey : ev.ctrlKey;
+}
+
+import "./Editor.css";
+import "./Breakpoints.css";
+import "./InlinePreview.css";
+
+const cssVars = {
+ searchbarHeight: "var(--editor-searchbar-height)",
+};
+
+class Editor extends PureComponent {
+ static get propTypes() {
+ return {
+ selectedSource: PropTypes.object,
+ selectedSourceTextContent: PropTypes.object,
+ selectedSourceIsBlackBoxed: PropTypes.bool,
+ cx: PropTypes.object.isRequired,
+ closeTab: PropTypes.func.isRequired,
+ toggleBreakpointAtLine: PropTypes.func.isRequired,
+ conditionalPanelLocation: PropTypes.object,
+ closeConditionalPanel: PropTypes.func.isRequired,
+ openConditionalPanel: PropTypes.func.isRequired,
+ updateViewport: PropTypes.func.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ highlightCalls: PropTypes.func.isRequired,
+ unhighlightCalls: PropTypes.func.isRequired,
+ breakpointActions: PropTypes.object.isRequired,
+ editorActions: PropTypes.object.isRequired,
+ addBreakpointAtLine: PropTypes.func.isRequired,
+ continueToHere: PropTypes.func.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ updateCursorPosition: PropTypes.func.isRequired,
+ jumpToMappedLocation: PropTypes.func.isRequired,
+ selectedLocation: PropTypes.object,
+ symbols: PropTypes.object,
+ startPanelSize: PropTypes.number.isRequired,
+ endPanelSize: PropTypes.number.isRequired,
+ searchInFileEnabled: PropTypes.bool.isRequired,
+ inlinePreviewEnabled: PropTypes.bool.isRequired,
+ editorWrappingEnabled: PropTypes.bool.isRequired,
+ skipPausing: PropTypes.bool.isRequired,
+ blackboxedRanges: PropTypes.object.isRequired,
+ breakableLines: PropTypes.object.isRequired,
+ highlightedLineRange: PropTypes.object,
+ isSourceOnIgnoreList: PropTypes.bool,
+ };
+ }
+
+ $editorWrapper;
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ editor: null,
+ contextMenu: null,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ let { editor } = this.state;
+
+ if (!editor && nextProps.selectedSource) {
+ editor = this.setupEditor();
+ }
+
+ const shouldUpdateText =
+ nextProps.selectedSource !== this.props.selectedSource ||
+ nextProps.selectedSourceTextContent !==
+ this.props.selectedSourceTextContent ||
+ nextProps.symbols !== this.props.symbols;
+
+ const shouldUpdateSize =
+ nextProps.startPanelSize !== this.props.startPanelSize ||
+ nextProps.endPanelSize !== this.props.endPanelSize;
+
+ const shouldScroll =
+ nextProps.selectedLocation &&
+ this.shouldScrollToLocation(nextProps, editor);
+
+ if (shouldUpdateText || shouldUpdateSize || shouldScroll) {
+ startOperation();
+ if (shouldUpdateText) {
+ this.setText(nextProps, editor);
+ }
+ if (shouldUpdateSize) {
+ editor.codeMirror.setSize();
+ }
+ if (shouldScroll) {
+ this.scrollToLocation(nextProps, editor);
+ }
+ endOperation();
+ }
+
+ if (this.props.selectedSource != nextProps.selectedSource) {
+ this.props.updateViewport();
+ resizeBreakpointGutter(editor.codeMirror);
+ resizeToggleButton(editor.codeMirror);
+ }
+ }
+
+ setupEditor() {
+ const editor = getEditor();
+
+ // disables the default search shortcuts
+ editor._initShortcuts = () => {};
+
+ const node = ReactDOM.findDOMNode(this);
+ if (node instanceof HTMLElement) {
+ editor.appendToLocalElement(node.querySelector(".editor-mount"));
+ }
+
+ const { codeMirror } = editor;
+ const codeMirrorWrapper = codeMirror.getWrapperElement();
+
+ codeMirror.on("gutterClick", this.onGutterClick);
+
+ if (features.commandClick) {
+ document.addEventListener("keydown", this.commandKeyDown);
+ document.addEventListener("keyup", this.commandKeyUp);
+ }
+
+ // Set code editor wrapper to be focusable
+ codeMirrorWrapper.tabIndex = 0;
+ codeMirrorWrapper.addEventListener("keydown", e => this.onKeyDown(e));
+ codeMirrorWrapper.addEventListener("click", e => this.onClick(e));
+ codeMirrorWrapper.addEventListener("mouseover", onMouseOver(codeMirror));
+
+ const toggleFoldMarkerVisibility = e => {
+ if (node instanceof HTMLElement) {
+ node
+ .querySelectorAll(".CodeMirror-guttermarker-subtle")
+ .forEach(elem => {
+ elem.classList.toggle("visible");
+ });
+ }
+ };
+
+ const codeMirrorGutter = codeMirror.getGutterElement();
+ codeMirrorGutter.addEventListener("mouseleave", toggleFoldMarkerVisibility);
+ codeMirrorGutter.addEventListener("mouseenter", toggleFoldMarkerVisibility);
+ codeMirrorWrapper.addEventListener("contextmenu", event =>
+ this.openMenu(event)
+ );
+
+ codeMirror.on("scroll", this.onEditorScroll);
+ this.onEditorScroll();
+ this.setState({ editor });
+ return editor;
+ }
+
+ componentDidMount() {
+ const { shortcuts } = this.context;
+
+ shortcuts.on(L10N.getStr("toggleBreakpoint.key"), this.onToggleBreakpoint);
+ shortcuts.on(
+ L10N.getStr("toggleCondPanel.breakpoint.key"),
+ this.onToggleConditionalPanel
+ );
+ shortcuts.on(
+ L10N.getStr("toggleCondPanel.logPoint.key"),
+ this.onToggleConditionalPanel
+ );
+ shortcuts.on(
+ L10N.getStr("sourceTabs.closeTab.key"),
+ this.onCloseShortcutPress
+ );
+ shortcuts.on("Esc", this.onEscape);
+ }
+
+ onCloseShortcutPress = e => {
+ const { cx, selectedSource } = this.props;
+ if (selectedSource) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.props.closeTab(cx, selectedSource, "shortcut");
+ }
+ };
+
+ componentWillUnmount() {
+ const { editor } = this.state;
+ if (editor) {
+ editor.destroy();
+ editor.codeMirror.off("scroll", this.onEditorScroll);
+ this.setState({ editor: null });
+ }
+
+ const { shortcuts } = this.context;
+ shortcuts.off(L10N.getStr("sourceTabs.closeTab.key"));
+ shortcuts.off(L10N.getStr("toggleBreakpoint.key"));
+ shortcuts.off(L10N.getStr("toggleCondPanel.breakpoint.key"));
+ shortcuts.off(L10N.getStr("toggleCondPanel.logPoint.key"));
+ }
+
+ getCurrentLine() {
+ const { codeMirror } = this.state.editor;
+ const { selectedSource } = this.props;
+ if (!selectedSource) {
+ return null;
+ }
+
+ const line = getCursorLine(codeMirror);
+ return toSourceLine(selectedSource.id, line);
+ }
+
+ onToggleBreakpoint = e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const line = this.getCurrentLine();
+ if (typeof line !== "number") {
+ return;
+ }
+
+ this.props.toggleBreakpointAtLine(this.props.cx, line);
+ };
+
+ onToggleConditionalPanel = e => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const {
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ openConditionalPanel,
+ selectedSource,
+ } = this.props;
+
+ const line = this.getCurrentLine();
+
+ const { codeMirror } = this.state.editor;
+ // add one to column for correct position in editor.
+ const column = getCursorColumn(codeMirror) + 1;
+
+ if (conditionalPanelLocation) {
+ return closeConditionalPanel();
+ }
+
+ if (!selectedSource || typeof line !== "number") {
+ return null;
+ }
+
+ return openConditionalPanel(
+ createLocation({
+ line,
+ column,
+ source: selectedSource,
+ }),
+ false
+ );
+ };
+
+ onEditorScroll = debounce(this.props.updateViewport, 75);
+
+ commandKeyDown = e => {
+ const { key } = e;
+ if (this.props.isPaused && key === "Meta") {
+ const { cx, highlightCalls } = this.props;
+ highlightCalls(cx);
+ }
+ };
+
+ commandKeyUp = e => {
+ const { key } = e;
+ if (key === "Meta") {
+ const { cx, unhighlightCalls } = this.props;
+ unhighlightCalls(cx);
+ }
+ };
+
+ onKeyDown(e) {
+ const { codeMirror } = this.state.editor;
+ const { key, target } = e;
+ const codeWrapper = codeMirror.getWrapperElement();
+ const textArea = codeWrapper.querySelector("textArea");
+
+ if (key === "Escape" && target == textArea) {
+ e.stopPropagation();
+ e.preventDefault();
+ codeWrapper.focus();
+ } else if (key === "Enter" && target == codeWrapper) {
+ e.preventDefault();
+ // Focus into editor's text area
+ textArea.focus();
+ }
+ }
+
+ /*
+ * The default Esc command is overridden in the CodeMirror keymap to allow
+ * the Esc keypress event to be catched by the toolbox and trigger the
+ * split console. Restore it here, but preventDefault if and only if there
+ * is a multiselection.
+ */
+ onEscape = e => {
+ if (!this.state.editor) {
+ return;
+ }
+
+ const { codeMirror } = this.state.editor;
+ if (codeMirror.listSelections().length > 1) {
+ codeMirror.execCommand("singleSelection");
+ e.preventDefault();
+ }
+ };
+
+ openMenu(event) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const {
+ cx,
+ selectedSource,
+ selectedSourceTextContent,
+ breakpointActions,
+ editorActions,
+ isPaused,
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ isSourceOnIgnoreList,
+ blackboxedRanges,
+ } = this.props;
+ const { editor } = this.state;
+ if (!selectedSource || !editor) {
+ return;
+ }
+
+ // only allow one conditionalPanel location.
+ if (conditionalPanelLocation) {
+ closeConditionalPanel();
+ }
+
+ const target = event.target;
+ const { id: sourceId } = selectedSource;
+ const line = lineAtHeight(editor, sourceId, event);
+
+ if (typeof line != "number") {
+ return;
+ }
+
+ const location = createLocation({
+ line,
+ column: undefined,
+ source: selectedSource,
+ });
+
+ if (target.classList.contains("CodeMirror-linenumber")) {
+ const lineText = getLineText(
+ sourceId,
+ selectedSourceTextContent,
+ line
+ ).trim();
+
+ showMenu(event, [
+ ...createBreakpointItems(cx, location, breakpointActions, lineText),
+ { type: "separator" },
+ continueToHereItem(cx, location, isPaused, editorActions),
+ { type: "separator" },
+ blackBoxLineMenuItem(
+ cx,
+ selectedSource,
+ editorActions,
+ editor,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ line
+ ),
+ ]);
+ return;
+ }
+
+ if (target.getAttribute("id") === "columnmarker") {
+ return;
+ }
+
+ this.setState({ contextMenu: event });
+ }
+
+ clearContextMenu = () => {
+ this.setState({ contextMenu: null });
+ };
+
+ onGutterClick = (cm, line, gutter, ev) => {
+ const {
+ cx,
+ selectedSource,
+ conditionalPanelLocation,
+ closeConditionalPanel,
+ addBreakpointAtLine,
+ continueToHere,
+ breakableLines,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ } = this.props;
+
+ // ignore right clicks in the gutter
+ if (isSecondary(ev) || ev.button === 2 || !selectedSource) {
+ return;
+ }
+
+ if (conditionalPanelLocation) {
+ closeConditionalPanel();
+ return;
+ }
+
+ if (gutter === "CodeMirror-foldgutter") {
+ return;
+ }
+
+ const sourceLine = toSourceLine(selectedSource.id, line);
+ if (typeof sourceLine !== "number") {
+ return;
+ }
+
+ // ignore clicks on a non-breakable line
+ if (!breakableLines.has(sourceLine)) {
+ return;
+ }
+
+ if (isCmd(ev)) {
+ continueToHere(
+ cx,
+ createLocation({
+ line: sourceLine,
+ column: undefined,
+ source: selectedSource,
+ })
+ );
+ return;
+ }
+
+ addBreakpointAtLine(
+ cx,
+ sourceLine,
+ ev.altKey,
+ ev.shiftKey ||
+ isLineBlackboxed(
+ blackboxedRanges[selectedSource.url],
+ sourceLine,
+ isSourceOnIgnoreList
+ )
+ );
+ };
+
+ onGutterContextMenu = event => {
+ this.openMenu(event);
+ };
+
+ onClick(e) {
+ const { cx, selectedSource, updateCursorPosition, jumpToMappedLocation } =
+ this.props;
+
+ if (selectedSource) {
+ const sourceLocation = getSourceLocationFromMouseEvent(
+ this.state.editor,
+ selectedSource,
+ e
+ );
+
+ if (e.metaKey && e.altKey) {
+ jumpToMappedLocation(cx, sourceLocation);
+ }
+
+ updateCursorPosition(sourceLocation);
+ }
+ }
+
+ shouldScrollToLocation(nextProps, editor) {
+ const { selectedLocation, selectedSource, selectedSourceTextContent } =
+ this.props;
+ if (
+ !editor ||
+ !nextProps.selectedSource ||
+ !nextProps.selectedLocation ||
+ !nextProps.selectedLocation.line ||
+ !nextProps.selectedSourceTextContent
+ ) {
+ return false;
+ }
+
+ const isFirstLoad =
+ (!selectedSource || !selectedSourceTextContent) &&
+ nextProps.selectedSourceTextContent;
+ const locationChanged = selectedLocation !== nextProps.selectedLocation;
+ const symbolsChanged = nextProps.symbols != this.props.symbols;
+
+ return isFirstLoad || locationChanged || symbolsChanged;
+ }
+
+ scrollToLocation(nextProps, editor) {
+ const { selectedLocation, selectedSource } = nextProps;
+
+ let { line, column } = toEditorPosition(selectedLocation);
+
+ if (selectedSource && hasDocument(selectedSource.id)) {
+ const doc = getDocument(selectedSource.id);
+ const lineText = doc.getLine(line);
+ column = Math.max(column, getIndentation(lineText));
+ }
+
+ scrollToColumn(editor.codeMirror, line, column);
+ }
+
+ setText(props, editor) {
+ const { selectedSource, selectedSourceTextContent, symbols } = props;
+
+ if (!editor) {
+ return;
+ }
+
+ // check if we previously had a selected source
+ if (!selectedSource) {
+ this.clearEditor();
+ return;
+ }
+
+ if (!selectedSourceTextContent?.value) {
+ showLoading(editor);
+ return;
+ }
+
+ if (selectedSourceTextContent.state === "rejected") {
+ let { value } = selectedSourceTextContent;
+ if (typeof value !== "string") {
+ value = "Unexpected source error";
+ }
+
+ this.showErrorMessage(value);
+ return;
+ }
+
+ showSourceText(editor, selectedSource, selectedSourceTextContent, symbols);
+ }
+
+ clearEditor() {
+ const { editor } = this.state;
+ if (!editor) {
+ return;
+ }
+
+ clearEditor(editor);
+ }
+
+ showErrorMessage(msg) {
+ const { editor } = this.state;
+ if (!editor) {
+ return;
+ }
+
+ showErrorMessage(editor, msg);
+ }
+
+ getInlineEditorStyles() {
+ const { searchInFileEnabled } = this.props;
+
+ if (searchInFileEnabled) {
+ return {
+ height: `calc(100% - ${cssVars.searchbarHeight})`,
+ };
+ }
+
+ return {
+ height: "100%",
+ };
+ }
+
+ renderItems() {
+ const {
+ cx,
+ selectedSource,
+ conditionalPanelLocation,
+ isPaused,
+ inlinePreviewEnabled,
+ editorWrappingEnabled,
+ highlightedLineRange,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ selectedSourceIsBlackBoxed,
+ } = this.props;
+ const { editor, contextMenu } = this.state;
+
+ if (!selectedSource || !editor || !getDocument(selectedSource.id)) {
+ return null;
+ }
+
+ return (
+ <div>
+ <HighlightCalls editor={editor} selectedSource={selectedSource} />
+ <DebugLine />
+ <HighlightLine />
+ <EmptyLines editor={editor} />
+ <Breakpoints editor={editor} cx={cx} />
+ <Preview editor={editor} editorRef={this.$editorWrapper} />
+ {highlightedLineRange ? (
+ <HighlightLines editor={editor} range={highlightedLineRange} />
+ ) : null}
+ {isSourceOnIgnoreList || selectedSourceIsBlackBoxed ? (
+ <BlackboxLines
+ editor={editor}
+ selectedSource={selectedSource}
+ isSourceOnIgnoreList={isSourceOnIgnoreList}
+ blackboxedRangesForSelectedSource={
+ blackboxedRanges[selectedSource.url]
+ }
+ />
+ ) : null}
+ <Exceptions />
+ <EditorMenu
+ editor={editor}
+ contextMenu={contextMenu}
+ clearContextMenu={this.clearContextMenu}
+ selectedSource={selectedSource}
+ editorWrappingEnabled={editorWrappingEnabled}
+ />
+ {conditionalPanelLocation ? <ConditionalPanel editor={editor} /> : null}
+ <ColumnBreakpoints editor={editor} />
+ {isPaused && inlinePreviewEnabled ? (
+ <InlinePreviews editor={editor} selectedSource={selectedSource} />
+ ) : null}
+ </div>
+ );
+ }
+
+ renderSearchInFileBar() {
+ if (!this.props.selectedSource) {
+ return null;
+ }
+
+ return <SearchInFileBar editor={this.state.editor} />;
+ }
+
+ render() {
+ const { selectedSourceIsBlackBoxed, skipPausing } = this.props;
+ return (
+ <div
+ className={classnames("editor-wrapper", {
+ blackboxed: selectedSourceIsBlackBoxed,
+ "skip-pausing": skipPausing,
+ })}
+ ref={c => (this.$editorWrapper = c)}
+ >
+ <div
+ className="editor-mount devtools-monospace"
+ style={this.getInlineEditorStyles()}
+ />
+ {this.renderSearchInFileBar()}
+ {this.renderItems()}
+ </div>
+ );
+ }
+}
+
+Editor.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ const selectedLocation = getSelectedLocation(state);
+
+ return {
+ cx: getThreadContext(state),
+ selectedLocation,
+ selectedSource,
+ selectedSourceTextContent: getSelectedSourceTextContent(state),
+ selectedSourceIsBlackBoxed: selectedSource
+ ? isSourceBlackBoxed(state, selectedSource)
+ : null,
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, selectedSource),
+ searchInFileEnabled: getActiveSearch(state) === "file",
+ conditionalPanelLocation: getConditionalPanelLocation(state),
+ symbols: getSymbols(state, selectedLocation),
+ isPaused: getIsCurrentThreadPaused(state),
+ skipPausing: getSkipPausing(state),
+ inlinePreviewEnabled: getInlinePreview(state),
+ editorWrappingEnabled: getEditorWrapping(state),
+ highlightedCalls: getHighlightedCalls(state, getCurrentThread(state)),
+ blackboxedRanges: getBlackBoxRanges(state),
+ breakableLines: getSelectedBreakableLines(state),
+ highlightedLineRange: getHighlightedLineRangeForSelectedSource(state),
+ };
+};
+
+const mapDispatchToProps = dispatch => ({
+ ...bindActionCreators(
+ {
+ openConditionalPanel: actions.openConditionalPanel,
+ closeConditionalPanel: actions.closeConditionalPanel,
+ continueToHere: actions.continueToHere,
+ toggleBreakpointAtLine: actions.toggleBreakpointAtLine,
+ addBreakpointAtLine: actions.addBreakpointAtLine,
+ jumpToMappedLocation: actions.jumpToMappedLocation,
+ updateViewport: actions.updateViewport,
+ updateCursorPosition: actions.updateCursorPosition,
+ closeTab: actions.closeTab,
+ toggleBlackBox: actions.toggleBlackBox,
+ highlightCalls: actions.highlightCalls,
+ unhighlightCalls: actions.unhighlightCalls,
+ },
+ dispatch
+ ),
+ breakpointActions: breakpointItemActions(dispatch),
+ editorActions: editorItemActions(dispatch),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Editor);
diff --git a/devtools/client/debugger/src/components/Editor/menus/breakpoints.js b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js
new file mode 100644
index 0000000000..b130d8a9b7
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/menus/breakpoints.js
@@ -0,0 +1,293 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import actions from "../../../actions";
+import { bindActionCreators } from "redux";
+import { features } from "../../../utils/prefs";
+import { formatKeyShortcut } from "../../../utils/text";
+import { isLineBlackboxed } from "../../../utils/source";
+
+export const addBreakpointItem = (cx, location, breakpointActions) => ({
+ id: "node-menu-add-breakpoint",
+ label: L10N.getStr("editor.addBreakpoint"),
+ accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.addBreakpoint(cx, location),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")),
+});
+
+export const removeBreakpointItem = (cx, breakpoint, breakpointActions) => ({
+ id: "node-menu-remove-breakpoint",
+ label: L10N.getStr("editor.removeBreakpoint"),
+ accesskey: L10N.getStr("shortcuts.toggleBreakpoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.removeBreakpoint(cx, breakpoint),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleBreakpoint.key")),
+});
+
+export const addConditionalBreakpointItem = (location, breakpointActions) => ({
+ id: "node-menu-add-conditional-breakpoint",
+ label: L10N.getStr("editor.addConditionBreakpoint"),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")),
+ accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.openConditionalPanel(location),
+});
+
+export const editConditionalBreakpointItem = (location, breakpointActions) => ({
+ id: "node-menu-edit-conditional-breakpoint",
+ label: L10N.getStr("editor.editConditionBreakpoint"),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.breakpoint.key")),
+ accesskey: L10N.getStr("editor.addConditionBreakpoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.openConditionalPanel(location),
+});
+
+export const conditionalBreakpointItem = (
+ breakpoint,
+ location,
+ breakpointActions
+) => {
+ const {
+ options: { condition },
+ } = breakpoint;
+ return condition
+ ? editConditionalBreakpointItem(location, breakpointActions)
+ : addConditionalBreakpointItem(location, breakpointActions);
+};
+
+export const addLogPointItem = (location, breakpointActions) => ({
+ id: "node-menu-add-log-point",
+ label: L10N.getStr("editor.addLogPoint"),
+ accesskey: L10N.getStr("editor.addLogPoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.openConditionalPanel(location, true),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")),
+});
+
+export const editLogPointItem = (location, breakpointActions) => ({
+ id: "node-menu-edit-log-point",
+ label: L10N.getStr("editor.editLogPoint"),
+ accesskey: L10N.getStr("editor.editLogPoint.accesskey"),
+ disabled: false,
+ click: () => breakpointActions.openConditionalPanel(location, true),
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")),
+});
+
+export const logPointItem = (breakpoint, location, breakpointActions) => {
+ const {
+ options: { logValue },
+ } = breakpoint;
+ return logValue
+ ? editLogPointItem(location, breakpointActions)
+ : addLogPointItem(location, breakpointActions);
+};
+
+export const toggleDisabledBreakpointItem = (
+ cx,
+ breakpoint,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+) => {
+ return {
+ accesskey: L10N.getStr("editor.disableBreakpoint.accesskey"),
+ disabled: isLineBlackboxed(
+ blackboxedRangesForSelectedSource,
+ breakpoint.location.line,
+ isSelectedSourceOnIgnoreList
+ ),
+ click: () => breakpointActions.toggleDisabledBreakpoint(cx, breakpoint),
+ ...(breakpoint.disabled
+ ? {
+ id: "node-menu-enable-breakpoint",
+ label: L10N.getStr("editor.enableBreakpoint"),
+ }
+ : {
+ id: "node-menu-disable-breakpoint",
+ label: L10N.getStr("editor.disableBreakpoint"),
+ }),
+ };
+};
+
+export const toggleDbgStatementItem = (
+ cx,
+ location,
+ breakpointActions,
+ breakpoint
+) => {
+ if (breakpoint && breakpoint.options.condition === "false") {
+ return {
+ disabled: false,
+ id: "node-menu-enable-dbgStatement",
+ label: L10N.getStr("breakpointMenuItem.enabledbg.label"),
+ click: () =>
+ breakpointActions.setBreakpointOptions(cx, location, {
+ ...breakpoint.options,
+ condition: null,
+ }),
+ };
+ }
+
+ return {
+ disabled: false,
+ id: "node-menu-disable-dbgStatement",
+ label: L10N.getStr("breakpointMenuItem.disabledbg.label"),
+ click: () =>
+ breakpointActions.setBreakpointOptions(cx, location, {
+ condition: "false",
+ }),
+ };
+};
+
+export function breakpointItems(
+ cx,
+ breakpoint,
+ selectedLocation,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+) {
+ const items = [
+ removeBreakpointItem(cx, breakpoint, breakpointActions),
+ toggleDisabledBreakpointItem(
+ cx,
+ breakpoint,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+ ),
+ ];
+
+ if (breakpoint.originalText.startsWith("debugger")) {
+ items.push(
+ { type: "separator" },
+ toggleDbgStatementItem(
+ cx,
+ selectedLocation,
+ breakpointActions,
+ breakpoint
+ )
+ );
+ }
+
+ items.push(
+ { type: "separator" },
+ removeBreakpointsOnLineItem(cx, selectedLocation, breakpointActions),
+ breakpoint.disabled
+ ? enableBreakpointsOnLineItem(
+ cx,
+ selectedLocation,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+ )
+ : disableBreakpointsOnLineItem(cx, selectedLocation, breakpointActions),
+ { type: "separator" }
+ );
+
+ items.push(
+ conditionalBreakpointItem(breakpoint, selectedLocation, breakpointActions)
+ );
+ items.push(logPointItem(breakpoint, selectedLocation, breakpointActions));
+
+ return items;
+}
+
+export function createBreakpointItems(
+ cx,
+ location,
+ breakpointActions,
+ lineText
+) {
+ const items = [
+ addBreakpointItem(cx, location, breakpointActions),
+ addConditionalBreakpointItem(location, breakpointActions),
+ ];
+
+ if (features.logPoints) {
+ items.push(addLogPointItem(location, breakpointActions));
+ }
+
+ if (lineText && lineText.startsWith("debugger")) {
+ items.push(toggleDbgStatementItem(cx, location, breakpointActions));
+ }
+ return items;
+}
+
+// ToDo: Only enable if there are more than one breakpoints on a line?
+export const removeBreakpointsOnLineItem = (
+ cx,
+ location,
+ breakpointActions
+) => ({
+ id: "node-menu-remove-breakpoints-on-line",
+ label: L10N.getStr("breakpointMenuItem.removeAllAtLine.label"),
+ accesskey: L10N.getStr("breakpointMenuItem.removeAllAtLine.accesskey"),
+ disabled: false,
+ click: () =>
+ breakpointActions.removeBreakpointsAtLine(
+ cx,
+ location.sourceId,
+ location.line
+ ),
+});
+
+export const enableBreakpointsOnLineItem = (
+ cx,
+ location,
+ breakpointActions,
+ blackboxedRangesForSelectedSource,
+ isSelectedSourceOnIgnoreList
+) => ({
+ id: "node-menu-remove-breakpoints-on-line",
+ label: L10N.getStr("breakpointMenuItem.enableAllAtLine.label"),
+ accesskey: L10N.getStr("breakpointMenuItem.enableAllAtLine.accesskey"),
+ disabled: isLineBlackboxed(
+ blackboxedRangesForSelectedSource,
+ location.line,
+ isSelectedSourceOnIgnoreList
+ ),
+ click: () =>
+ breakpointActions.enableBreakpointsAtLine(
+ cx,
+ location.sourceId,
+ location.line
+ ),
+});
+
+export const disableBreakpointsOnLineItem = (
+ cx,
+ location,
+ breakpointActions
+) => ({
+ id: "node-menu-remove-breakpoints-on-line",
+ label: L10N.getStr("breakpointMenuItem.disableAllAtLine.label"),
+ accesskey: L10N.getStr("breakpointMenuItem.disableAllAtLine.accesskey"),
+ disabled: false,
+ click: () =>
+ breakpointActions.disableBreakpointsAtLine(
+ cx,
+ location.sourceId,
+ location.line
+ ),
+});
+
+export function breakpointItemActions(dispatch) {
+ return bindActionCreators(
+ {
+ addBreakpoint: actions.addBreakpoint,
+ removeBreakpoint: actions.removeBreakpoint,
+ removeBreakpointsAtLine: actions.removeBreakpointsAtLine,
+ enableBreakpointsAtLine: actions.enableBreakpointsAtLine,
+ disableBreakpointsAtLine: actions.disableBreakpointsAtLine,
+ disableBreakpoint: actions.disableBreakpoint,
+ toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint,
+ toggleBreakpointsAtLine: actions.toggleBreakpointsAtLine,
+ setBreakpointOptions: actions.setBreakpointOptions,
+ openConditionalPanel: actions.openConditionalPanel,
+ },
+ dispatch
+ );
+}
diff --git a/devtools/client/debugger/src/components/Editor/menus/editor.js b/devtools/client/debugger/src/components/Editor/menus/editor.js
new file mode 100644
index 0000000000..5ed3c96f6f
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/menus/editor.js
@@ -0,0 +1,403 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import { bindActionCreators } from "redux";
+
+import { copyToTheClipboard } from "../../../utils/clipboard";
+import {
+ getRawSourceURL,
+ getFilename,
+ shouldBlackbox,
+ findBlackBoxRange,
+} from "../../../utils/source";
+import { toSourceLine } from "../../../utils/editor";
+import { downloadFile } from "../../../utils/utils";
+import { features } from "../../../utils/prefs";
+
+import { isFulfilled } from "../../../utils/async-value";
+import actions from "../../../actions";
+
+// Menu Items
+export const continueToHereItem = (cx, location, isPaused, editorActions) => ({
+ accesskey: L10N.getStr("editor.continueToHere.accesskey"),
+ disabled: !isPaused,
+ click: () => editorActions.continueToHere(cx, location),
+ id: "node-menu-continue-to-here",
+ label: L10N.getStr("editor.continueToHere.label"),
+});
+
+const copyToClipboardItem = (selectionText, editorActions) => ({
+ id: "node-menu-copy-to-clipboard",
+ label: L10N.getStr("copyToClipboard.label"),
+ accesskey: L10N.getStr("copyToClipboard.accesskey"),
+ disabled: selectionText.length === 0,
+ click: () => copyToTheClipboard(selectionText),
+});
+
+const copySourceItem = (selectedContent, editorActions) => ({
+ id: "node-menu-copy-source",
+ label: L10N.getStr("copySource.label"),
+ accesskey: L10N.getStr("copySource.accesskey"),
+ disabled: false,
+ click: () =>
+ selectedContent.type === "text" &&
+ copyToTheClipboard(selectedContent.value),
+});
+
+const copySourceUri2Item = (selectedSource, editorActions) => ({
+ id: "node-menu-copy-source-url",
+ label: L10N.getStr("copySourceUri2"),
+ accesskey: L10N.getStr("copySourceUri2.accesskey"),
+ disabled: !selectedSource.url,
+ click: () => copyToTheClipboard(getRawSourceURL(selectedSource.url)),
+});
+
+const jumpToMappedLocationItem = (
+ cx,
+ selectedSource,
+ location,
+ hasMappedLocation,
+ editorActions
+) => ({
+ id: "node-menu-jump",
+ label: L10N.getFormatStr(
+ "editor.jumpToMappedLocation1",
+ selectedSource.isOriginal
+ ? L10N.getStr("generated")
+ : L10N.getStr("original")
+ ),
+ accesskey: L10N.getStr("editor.jumpToMappedLocation1.accesskey"),
+ disabled: !hasMappedLocation,
+ click: () => editorActions.jumpToMappedLocation(cx, location),
+});
+
+const showSourceMenuItem = (cx, selectedSource, editorActions) => ({
+ id: "node-menu-show-source",
+ label: L10N.getStr("sourceTabs.revealInTree"),
+ accesskey: L10N.getStr("sourceTabs.revealInTree.accesskey"),
+ disabled: !selectedSource.url,
+ click: () => editorActions.showSource(cx, selectedSource.id),
+});
+
+const blackBoxMenuItem = (
+ cx,
+ selectedSource,
+ blackboxedRanges,
+ editorActions,
+ isSourceOnIgnoreList
+) => {
+ const isBlackBoxed = !!blackboxedRanges[selectedSource.url];
+ return {
+ id: "node-menu-blackbox",
+ label: isBlackBoxed
+ ? L10N.getStr("ignoreContextItem.unignore")
+ : L10N.getStr("ignoreContextItem.ignore"),
+ accesskey: isBlackBoxed
+ ? L10N.getStr("ignoreContextItem.unignore.accesskey")
+ : L10N.getStr("ignoreContextItem.ignore.accesskey"),
+ disabled: isSourceOnIgnoreList || !shouldBlackbox(selectedSource),
+ click: () => editorActions.toggleBlackBox(cx, selectedSource),
+ };
+};
+
+export const blackBoxLineMenuItem = (
+ cx,
+ selectedSource,
+ editorActions,
+ editor,
+ blackboxedRanges,
+ isSourceOnIgnoreList,
+ // the clickedLine is passed when the context menu
+ // is opened from the gutter, it is not available when the
+ // the context menu is opened from the editor.
+ clickedLine = null
+) => {
+ const { codeMirror } = editor;
+ const from = codeMirror.getCursor("from");
+ const to = codeMirror.getCursor("to");
+
+ const startLine = clickedLine ?? toSourceLine(selectedSource.id, from.line);
+ const endLine = clickedLine ?? toSourceLine(selectedSource.id, to.line);
+
+ const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, {
+ start: startLine,
+ end: endLine,
+ });
+
+ const selectedLineIsBlackBoxed = !!blackboxRange;
+
+ const isSingleLine = selectedLineIsBlackBoxed
+ ? blackboxRange.start.line == blackboxRange.end.line
+ : startLine == endLine;
+
+ const isSourceFullyBlackboxed =
+ blackboxedRanges[selectedSource.url] &&
+ !blackboxedRanges[selectedSource.url].length;
+
+ // The ignore/unignore line context menu item should be disabled when
+ // 1) The source is on the sourcemap ignore list
+ // 2) The whole source is blackboxed or
+ // 3) Multiple lines are blackboxed or
+ // 4) Multiple lines are selected in the editor
+ const shouldDisable =
+ isSourceOnIgnoreList || isSourceFullyBlackboxed || !isSingleLine;
+
+ return {
+ id: "node-menu-blackbox-line",
+ label: !selectedLineIsBlackBoxed
+ ? L10N.getStr("ignoreContextItem.ignoreLine")
+ : L10N.getStr("ignoreContextItem.unignoreLine"),
+ accesskey: !selectedLineIsBlackBoxed
+ ? L10N.getStr("ignoreContextItem.ignoreLine.accesskey")
+ : L10N.getStr("ignoreContextItem.unignoreLine.accesskey"),
+ disabled: shouldDisable,
+ click: () => {
+ const selectionRange = {
+ start: {
+ line: startLine,
+ column: clickedLine == null ? from.ch : 0,
+ },
+ end: {
+ line: endLine,
+ column: clickedLine == null ? to.ch : 0,
+ },
+ };
+
+ editorActions.toggleBlackBox(
+ cx,
+ selectedSource,
+ !selectedLineIsBlackBoxed,
+ selectedLineIsBlackBoxed ? [blackboxRange] : [selectionRange]
+ );
+ },
+ };
+};
+
+const blackBoxLinesMenuItem = (
+ cx,
+ selectedSource,
+ editorActions,
+ editor,
+ blackboxedRanges,
+ isSourceOnIgnoreList
+) => {
+ const { codeMirror } = editor;
+ const from = codeMirror.getCursor("from");
+ const to = codeMirror.getCursor("to");
+
+ const startLine = toSourceLine(selectedSource.id, from.line);
+ const endLine = toSourceLine(selectedSource.id, to.line);
+
+ const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, {
+ start: startLine,
+ end: endLine,
+ });
+
+ const selectedLinesAreBlackBoxed = !!blackboxRange;
+
+ return {
+ id: "node-menu-blackbox-lines",
+ label: !selectedLinesAreBlackBoxed
+ ? L10N.getStr("ignoreContextItem.ignoreLines")
+ : L10N.getStr("ignoreContextItem.unignoreLines"),
+ accesskey: !selectedLinesAreBlackBoxed
+ ? L10N.getStr("ignoreContextItem.ignoreLines.accesskey")
+ : L10N.getStr("ignoreContextItem.unignoreLines.accesskey"),
+ disabled: isSourceOnIgnoreList,
+ click: () => {
+ const selectionRange = {
+ start: {
+ line: startLine,
+ column: from.ch,
+ },
+ end: {
+ line: endLine,
+ column: to.ch,
+ },
+ };
+
+ editorActions.toggleBlackBox(
+ cx,
+ selectedSource,
+ !selectedLinesAreBlackBoxed,
+ selectedLinesAreBlackBoxed ? [blackboxRange] : [selectionRange]
+ );
+ },
+ };
+};
+
+const watchExpressionItem = (
+ cx,
+ selectedSource,
+ selectionText,
+ editorActions
+) => ({
+ id: "node-menu-add-watch-expression",
+ label: L10N.getStr("expressions.label"),
+ accesskey: L10N.getStr("expressions.accesskey"),
+ click: () => editorActions.addExpression(cx, selectionText),
+});
+
+const evaluateInConsoleItem = (
+ selectedSource,
+ selectionText,
+ editorActions
+) => ({
+ id: "node-menu-evaluate-in-console",
+ label: L10N.getStr("evaluateInConsole.label"),
+ click: () => editorActions.evaluateInConsole(selectionText),
+});
+
+const downloadFileItem = (selectedSource, selectedContent, editorActions) => ({
+ id: "node-menu-download-file",
+ label: L10N.getStr("downloadFile.label"),
+ accesskey: L10N.getStr("downloadFile.accesskey"),
+ click: () => downloadFile(selectedContent, getFilename(selectedSource)),
+});
+
+const inlinePreviewItem = editorActions => ({
+ id: "node-menu-inline-preview",
+ label: features.inlinePreview
+ ? L10N.getStr("inlinePreview.hide.label")
+ : L10N.getStr("inlinePreview.show.label"),
+ click: () => editorActions.toggleInlinePreview(!features.inlinePreview),
+});
+
+const editorWrappingItem = (editorActions, editorWrappingEnabled) => ({
+ id: "node-menu-editor-wrapping",
+ label: editorWrappingEnabled
+ ? L10N.getStr("editorWrapping.hide.label")
+ : L10N.getStr("editorWrapping.show.label"),
+ click: () => editorActions.toggleEditorWrapping(!editorWrappingEnabled),
+});
+
+export function editorMenuItems({
+ cx,
+ editorActions,
+ selectedSource,
+ blackboxedRanges,
+ location,
+ selectionText,
+ hasMappedLocation,
+ isTextSelected,
+ isPaused,
+ editorWrappingEnabled,
+ editor,
+ isSourceOnIgnoreList,
+}) {
+ const items = [];
+
+ const content =
+ selectedSource.content && isFulfilled(selectedSource.content)
+ ? selectedSource.content.value
+ : null;
+
+ items.push(
+ jumpToMappedLocationItem(
+ cx,
+ selectedSource,
+ location,
+ hasMappedLocation,
+ editorActions
+ ),
+ continueToHereItem(cx, location, isPaused, editorActions),
+ { type: "separator" },
+ copyToClipboardItem(selectionText, editorActions),
+ ...(!selectedSource.isWasm
+ ? [
+ ...(content ? [copySourceItem(content, editorActions)] : []),
+ copySourceUri2Item(selectedSource, editorActions),
+ ]
+ : []),
+ ...(content
+ ? [downloadFileItem(selectedSource, content, editorActions)]
+ : []),
+ { type: "separator" },
+ showSourceMenuItem(cx, selectedSource, editorActions),
+ { type: "separator" },
+ blackBoxMenuItem(
+ cx,
+ selectedSource,
+ blackboxedRanges,
+ editorActions,
+ isSourceOnIgnoreList
+ )
+ );
+
+ const startLine = toSourceLine(
+ selectedSource.id,
+ editor.codeMirror.getCursor("from").line
+ );
+ const endLine = toSourceLine(
+ selectedSource.id,
+ editor.codeMirror.getCursor("to").line
+ );
+
+ // Find any blackbox ranges that exist for the selected lines
+ const blackboxRange = findBlackBoxRange(selectedSource, blackboxedRanges, {
+ start: startLine,
+ end: endLine,
+ });
+
+ const isMultiLineSelection = blackboxRange
+ ? blackboxRange.start.line !== blackboxRange.end.line
+ : startLine !== endLine;
+
+ // When the range is defined and is an empty array,
+ // the whole source is blackboxed
+ const theWholeSourceIsBlackBoxed =
+ blackboxedRanges[selectedSource.url] &&
+ !blackboxedRanges[selectedSource.url].length;
+
+ if (!theWholeSourceIsBlackBoxed) {
+ const blackBoxSourceLinesMenuItem = isMultiLineSelection
+ ? blackBoxLinesMenuItem
+ : blackBoxLineMenuItem;
+
+ items.push(
+ blackBoxSourceLinesMenuItem(
+ cx,
+ selectedSource,
+ editorActions,
+ editor,
+ blackboxedRanges,
+ isSourceOnIgnoreList
+ )
+ );
+ }
+
+ if (isTextSelected) {
+ items.push(
+ { type: "separator" },
+ watchExpressionItem(cx, selectedSource, selectionText, editorActions),
+ evaluateInConsoleItem(selectedSource, selectionText, editorActions)
+ );
+ }
+
+ items.push(
+ { type: "separator" },
+ inlinePreviewItem(editorActions),
+ editorWrappingItem(editorActions, editorWrappingEnabled)
+ );
+
+ return items;
+}
+
+export function editorItemActions(dispatch) {
+ return bindActionCreators(
+ {
+ addExpression: actions.addExpression,
+ continueToHere: actions.continueToHere,
+ evaluateInConsole: actions.evaluateInConsole,
+ flashLineRange: actions.flashLineRange,
+ jumpToMappedLocation: actions.jumpToMappedLocation,
+ showSource: actions.showSource,
+ toggleBlackBox: actions.toggleBlackBox,
+ toggleBlackBoxLines: actions.toggleBlackBoxLines,
+ toggleInlinePreview: actions.toggleInlinePreview,
+ toggleEditorWrapping: actions.toggleEditorWrapping,
+ },
+ dispatch
+ );
+}
diff --git a/devtools/client/debugger/src/components/Editor/menus/moz.build b/devtools/client/debugger/src/components/Editor/menus/moz.build
new file mode 100644
index 0000000000..18009aa2db
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/menus/moz.build
@@ -0,0 +1,12 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += []
+
+CompiledModules(
+ "breakpoints.js",
+ "editor.js",
+ "source.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/menus/source.js b/devtools/client/debugger/src/components/Editor/menus/source.js
new file mode 100644
index 0000000000..0ba8834e6f
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/menus/source.js
@@ -0,0 +1,3 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
diff --git a/devtools/client/debugger/src/components/Editor/moz.build b/devtools/client/debugger/src/components/Editor/moz.build
new file mode 100644
index 0000000000..b31918f2e0
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/moz.build
@@ -0,0 +1,34 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "menus",
+ "Preview",
+]
+
+CompiledModules(
+ "BlackboxLines.js",
+ "Breakpoint.js",
+ "Breakpoints.js",
+ "ColumnBreakpoint.js",
+ "ColumnBreakpoints.js",
+ "ConditionalPanel.js",
+ "DebugLine.js",
+ "EditorMenu.js",
+ "EmptyLines.js",
+ "Exception.js",
+ "Exceptions.js",
+ "Footer.js",
+ "HighlightCalls.js",
+ "HighlightLine.js",
+ "HighlightLines.js",
+ "index.js",
+ "InlinePreview.js",
+ "InlinePreviewRow.js",
+ "InlinePreviews.js",
+ "SearchInFileBar.js",
+ "Tab.js",
+ "Tabs.js",
+)
diff --git a/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js
new file mode 100644
index 0000000000..915b812dff
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/Breakpoints.spec.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import Breakpoints from "../Breakpoints";
+
+const BreakpointsComponent = Breakpoints.WrappedComponent;
+
+function generateDefaults(overrides) {
+ const sourceId = "server1.conn1.child1/source1";
+ const matchingBreakpoints = [{ location: { source: { id: sourceId } } }];
+
+ return {
+ selectedSource: { sourceId, get: () => false },
+ editor: {
+ codeMirror: {
+ setGutterMarker: jest.fn(),
+ },
+ },
+ blackboxedRanges: {},
+ cx: {},
+ breakpointActions: {},
+ editorActions: {},
+ breakpoints: matchingBreakpoints,
+ ...overrides,
+ };
+}
+
+function render(overrides = {}) {
+ const props = generateDefaults(overrides);
+ const component = shallow(<BreakpointsComponent {...props} />);
+ return { component, props };
+}
+
+describe("Breakpoints Component", () => {
+ it("should render breakpoints without columns", async () => {
+ const sourceId = "server1.conn1.child1/source1";
+ const breakpoints = [{ location: { source: { id: sourceId } } }];
+
+ const { component, props } = render({ breakpoints });
+ expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length);
+ });
+
+ it("should render breakpoints with columns", async () => {
+ const sourceId = "server1.conn1.child1/source1";
+ const breakpoints = [{ location: { column: 2, source: { id: sourceId } } }];
+
+ const { component, props } = render({ breakpoints });
+ expect(component.find("Breakpoint")).toHaveLength(props.breakpoints.length);
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js
new file mode 100644
index 0000000000..05e4dcb727
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/ConditionalPanel.spec.js
@@ -0,0 +1,77 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { mount } from "enzyme";
+import { ConditionalPanel } from "../ConditionalPanel";
+import * as mocks from "../../../utils/test-mockup";
+
+const source = mocks.makeMockSource();
+
+function generateDefaults(overrides, log, line, column, condition, logValue) {
+ const breakpoint = mocks.makeMockBreakpoint(source, line, column);
+ breakpoint.options.condition = condition;
+ breakpoint.options.logValue = logValue;
+
+ return {
+ editor: {
+ CodeMirror: {
+ fromTextArea: jest.fn(() => {
+ return {
+ on: jest.fn(),
+ getWrapperElement: jest.fn(() => {
+ return {
+ addEventListener: jest.fn(),
+ };
+ }),
+ focus: jest.fn(),
+ setCursor: jest.fn(),
+ lineCount: jest.fn(),
+ };
+ }),
+ },
+ codeMirror: {
+ addLineWidget: jest.fn(),
+ },
+ },
+ location: breakpoint.location,
+ source,
+ breakpoint,
+ log,
+ getDefaultValue: jest.fn(),
+ openConditionalPanel: jest.fn(),
+ closeConditionalPanel: jest.fn(),
+ ...overrides,
+ };
+}
+
+function render(log, line, column, condition, logValue, overrides = {}) {
+ const defaults = generateDefaults(
+ overrides,
+ log,
+ line,
+ column,
+ condition,
+ logValue
+ );
+ const props = { ...defaults, ...overrides };
+ const wrapper = mount(<ConditionalPanel {...props} />);
+ return { wrapper, props };
+}
+
+describe("ConditionalPanel", () => {
+ it("it should render at location of selected breakpoint", () => {
+ const { wrapper } = render(false, 2, 2);
+ expect(wrapper).toMatchSnapshot();
+ });
+ it("it should render with condition at selected breakpoint location", () => {
+ const { wrapper } = render(false, 3, 3, "I'm a condition", "not a log");
+ expect(wrapper).toMatchSnapshot();
+ });
+ it("it should render with logpoint at selected breakpoint location", () => {
+ const { wrapper } = render(true, 4, 4, "not a condition", "I'm a log");
+ expect(wrapper).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js
new file mode 100644
index 0000000000..a7fcb53a2d
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/DebugLine.spec.js
@@ -0,0 +1,85 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import DebugLine from "../DebugLine";
+
+import { setDocument } from "../../../utils/editor";
+
+function createMockDocument(clear) {
+ const doc = {
+ addLineClass: jest.fn(),
+ removeLineClass: jest.fn(),
+ markText: jest.fn(() => ({ clear })),
+ getLine: line => "",
+ };
+
+ return doc;
+}
+
+function generateDefaults(editor, overrides) {
+ return {
+ editor,
+ pauseInfo: {
+ why: { type: "breakpoint" },
+ },
+ frame: null,
+ sourceTextContent: null,
+ ...overrides,
+ };
+}
+
+function createLocation(line) {
+ return {
+ source: {
+ id: "foo",
+ },
+ sourceId: "foo",
+ line,
+ column: 2,
+ };
+}
+
+function render(overrides = {}) {
+ const clear = jest.fn();
+ const editor = { codeMirror: {} };
+ const props = generateDefaults(editor, overrides);
+
+ const doc = createMockDocument(clear);
+ setDocument("foo", doc);
+
+ const component = shallow(<DebugLine.WrappedComponent {...props} />, {
+ lifecycleExperimental: true,
+ });
+ return { component, props, clear, editor, doc };
+}
+
+describe("DebugLine Component", () => {
+ describe("pausing at the first location", () => {
+ describe("when there is no selected frame", () => {
+ it("should not set the debug line", () => {
+ const { component, props, doc } = render({ frame: null });
+ const line = 2;
+ const location = createLocation(line);
+
+ component.setProps({ ...props, location });
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("when there is a different source", () => {
+ it("should not set the debug line", async () => {
+ const { component, doc } = render();
+ const newSelectedFrame = { location: { sourceId: "bar" } };
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+
+ component.setProps({ frame: newSelectedFrame });
+ expect(doc.removeLineClass).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js
new file mode 100644
index 0000000000..b58ba45cb3
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/Footer.spec.js
@@ -0,0 +1,67 @@
+/* eslint max-nested-callbacks: ["error", 7] */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import SourceFooter from "../Footer";
+import { createSourceObject } from "../../../utils/test-head";
+import { setDocument } from "../../../utils/editor";
+
+function createMockDocument(clear, position) {
+ const doc = {
+ getCursor: jest.fn(() => position),
+ };
+ return doc;
+}
+
+function generateDefaults(overrides) {
+ return {
+ editor: {
+ codeMirror: {
+ doc: {},
+ cursorActivity: jest.fn(),
+ on: jest.fn(),
+ },
+ },
+ endPanelCollapsed: false,
+ selectedSource: {
+ ...createSourceObject("foo"),
+ content: null,
+ },
+ ...overrides,
+ };
+}
+
+function render(overrides = {}, position = { line: 0, column: 0 }) {
+ const clear = jest.fn();
+ const props = generateDefaults(overrides);
+
+ const doc = createMockDocument(clear, position);
+ setDocument(props.selectedSource.id, doc);
+
+ const component = shallow(<SourceFooter.WrappedComponent {...props} />, {
+ lifecycleExperimental: true,
+ });
+ return { component, props, clear, doc };
+}
+
+describe("SourceFooter Component", () => {
+ describe("default case", () => {
+ it("should render", () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+ });
+
+ describe("move cursor", () => {
+ it("should render new cursor position", () => {
+ const { component } = render();
+ component.setState({ cursorPosition: { line: 5, column: 10 } });
+
+ expect(component).toMatchSnapshot();
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap
new file mode 100644
index 0000000000..48cda915a4
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Breakpoints.spec.js.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Breakpoints Component should render breakpoints with columns 1`] = `
+<div>
+ <Breakpoint
+ breakpoint={
+ Object {
+ "location": Object {
+ "column": 2,
+ "source": Object {
+ "id": "server1.conn1.child1/source1",
+ },
+ },
+ }
+ }
+ breakpointActions={Object {}}
+ cx={Object {}}
+ editor={
+ Object {
+ "codeMirror": Object {
+ "setGutterMarker": [MockFunction],
+ },
+ }
+ }
+ editorActions={Object {}}
+ key="undefined:undefined:2"
+ selectedSource={
+ Object {
+ "get": [Function],
+ "sourceId": "server1.conn1.child1/source1",
+ }
+ }
+ />
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap
new file mode 100644
index 0000000000..d2f52bb6e3
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/ConditionalPanel.spec.js.snap
@@ -0,0 +1,630 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ConditionalPanel it should render at location of selected breakpoint 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "options": Object {
+ "condition": undefined,
+ "logValue": undefined,
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea />,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Breakpoint condition, e.g. items.length > 0",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 1,
+ <div>
+ <div
+ class="conditional-breakpoint-panel"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea />
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 2,
+ "line": 2,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ }
+ }
+ log={false}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
+
+exports[`ConditionalPanel it should render with condition at selected breakpoint location 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "options": Object {
+ "condition": "I'm a condition",
+ "logValue": "not a log",
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea>
+ I'm a condition
+ </textarea>,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Breakpoint condition, e.g. items.length > 0",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 2,
+ <div>
+ <div
+ class="conditional-breakpoint-panel"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea>
+ I'm a condition
+ </textarea>
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 3,
+ "line": 3,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ }
+ }
+ log={false}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
+
+exports[`ConditionalPanel it should render with logpoint at selected breakpoint location 1`] = `
+<ConditionalPanel
+ breakpoint={
+ Object {
+ "disabled": false,
+ "generatedLocation": Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "id": "breakpoint",
+ "location": Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ },
+ "options": Object {
+ "condition": "not a condition",
+ "logValue": "I'm a log",
+ },
+ "originalText": "text",
+ "text": "text",
+ }
+ }
+ closeConditionalPanel={[MockFunction]}
+ editor={
+ Object {
+ "CodeMirror": Object {
+ "fromTextArea": [MockFunction] {
+ "calls": Array [
+ Array [
+ <textarea>
+ I'm a log
+ </textarea>,
+ Object {
+ "cursorBlinkRate": 530,
+ "mode": "javascript",
+ "placeholder": "Log message, e.g. displayName",
+ "theme": "mozilla",
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "focus": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "getWrapperElement": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Object {
+ "addEventListener": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ "lineCount": [MockFunction] {
+ "calls": Array [
+ Array [],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "on": [MockFunction] {
+ "calls": Array [
+ Array [
+ "keydown",
+ [Function],
+ ],
+ Array [
+ "blur",
+ [Function],
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ "setCursor": [MockFunction] {
+ "calls": Array [
+ Array [
+ undefined,
+ 0,
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ },
+ "codeMirror": Object {
+ "addLineWidget": [MockFunction] {
+ "calls": Array [
+ Array [
+ 3,
+ <div>
+ <div
+ class="conditional-breakpoint-panel log-point"
+ >
+ <div
+ class="prompt"
+ >
+ »
+ </div>
+ <textarea>
+ I'm a log
+ </textarea>
+ </div>
+ </div>,
+ Object {
+ "coverGutter": true,
+ "noHScroll": true,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
+ },
+ }
+ }
+ getDefaultValue={[MockFunction]}
+ location={
+ Object {
+ "column": 4,
+ "line": 4,
+ "source": Object {
+ "id": "source",
+ },
+ "sourceId": "source",
+ }
+ }
+ log={true}
+ openConditionalPanel={[MockFunction]}
+ source={
+ Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "url",
+ "group": "",
+ "path": "url",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "source",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "url",
+ }
+ }
+/>
+`;
diff --git a/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap
new file mode 100644
index 0000000000..d6123d4c67
--- /dev/null
+++ b/devtools/client/debugger/src/components/Editor/tests/__snapshots__/Footer.spec.js.snap
@@ -0,0 +1,105 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SourceFooter Component default case should render 1`] = `
+<div
+ className="source-footer"
+>
+ <div
+ className="source-footer-start"
+ >
+ <div
+ className="commands"
+ >
+ <button
+ aria-label="Ignore source"
+ className="action black-box"
+ key="black-box"
+ onClick={[Function]}
+ title="Ignore source"
+ >
+ <AccessibleImage
+ className="blackBox"
+ />
+ </button>
+ <button
+ className="action prettyPrint"
+ disabled={true}
+ key="prettyPrint"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="prettyPrint"
+ />
+ </button>
+ </div>
+ </div>
+ <div
+ className="source-footer-end"
+ >
+ <div
+ className="cursor-position"
+ title="(Line 1, column 1)"
+ >
+ (1, 1)
+ </div>
+ <PaneToggleButton
+ collapsed={false}
+ horizontal={false}
+ key="toggle"
+ position="end"
+ />
+ </div>
+</div>
+`;
+
+exports[`SourceFooter Component move cursor should render new cursor position 1`] = `
+<div
+ className="source-footer"
+>
+ <div
+ className="source-footer-start"
+ >
+ <div
+ className="commands"
+ >
+ <button
+ aria-label="Ignore source"
+ className="action black-box"
+ key="black-box"
+ onClick={[Function]}
+ title="Ignore source"
+ >
+ <AccessibleImage
+ className="blackBox"
+ />
+ </button>
+ <button
+ className="action prettyPrint"
+ disabled={true}
+ key="prettyPrint"
+ onClick={[Function]}
+ >
+ <AccessibleImage
+ className="prettyPrint"
+ />
+ </button>
+ </div>
+ </div>
+ <div
+ className="source-footer-end"
+ >
+ <div
+ className="cursor-position"
+ title="(Line 6, column 11)"
+ >
+ (6, 11)
+ </div>
+ <PaneToggleButton
+ collapsed={false}
+ horizontal={false}
+ key="toggle"
+ position="end"
+ />
+ </div>
+</div>
+`;
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..cbad0bddc3
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.css
@@ -0,0 +1,205 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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-tabs {
+ font-size: 12px;
+ width: 100%;
+ background: var(--theme-body-background);
+ display: flex;
+ user-select: none;
+ box-sizing: border-box;
+ height: var(--editor-header-height);
+ margin: 0;
+ padding: 0;
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.source-outline-tabs .tab {
+ align-items: center;
+ background-color: var(--theme-toolbar-background);
+ color: var(--theme-toolbar-color);
+ cursor: default;
+ display: inline-flex;
+ flex: 1;
+ justify-content: center;
+ overflow: hidden;
+ padding: 4px 8px;
+ position: relative;
+}
+
+.source-outline-tabs .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-outline-tabs .tab.active {
+ --tab-line-color: var(--tab-line-selected-color);
+ color: var(--theme-toolbar-selected-color);
+ border-bottom-color: transparent;
+}
+
+.source-outline-tabs .tab:not(.active):hover {
+ --tab-line-color: var(--tab-line-hover-color);
+ background-color: var(--theme-toolbar-hover);
+}
+
+.source-outline-tabs .tab:hover::before,
+.source-outline-tabs .tab.active::before {
+ opacity: 1;
+ transform: scaleX(1);
+}
+
+.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);
+}
+
+.outline-footer button.active {
+ background: var(--theme-selection-background);
+ color: var(--theme-selection-color);
+}
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..8e0aa17ca4
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Outline.js
@@ -0,0 +1,372 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { showMenu } from "../../context-menu/menu";
+import { connect } from "../../utils/connect";
+import { score as fuzzaldrinScore } from "fuzzaldrin-plus";
+
+import { containsPosition, positionAfter } from "../../utils/ast";
+import { copyToTheClipboard } from "../../utils/clipboard";
+import { findFunctionText } from "../../utils/function";
+import { createLocation } from "../../utils/location";
+
+import actions from "../../actions";
+import {
+ getSelectedLocation,
+ getSelectedSource,
+ getSelectedSourceTextContent,
+ getSymbols,
+ getCursorPosition,
+ getContext,
+} from "../../selectors";
+
+import OutlineFilter from "./OutlineFilter";
+import "./Outline.css";
+import PreviewFunction from "../shared/PreviewFunction";
+
+const classnames = require("devtools/client/shared/classnames.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 };
+ }
+
+ static get propTypes() {
+ return {
+ alphabetizeOutline: PropTypes.bool.isRequired,
+ cursorPosition: PropTypes.object,
+ cx: PropTypes.object.isRequired,
+ flashLineRange: PropTypes.func.isRequired,
+ getFunctionText: PropTypes.func.isRequired,
+ onAlphabetizeClick: PropTypes.func.isRequired,
+ selectLocation: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object.isRequired,
+ symbols: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const { cursorPosition, symbols } = this.props;
+ if (
+ cursorPosition &&
+ symbols &&
+ cursorPosition !== prevProps.cursorPosition
+ ) {
+ this.setFocus(cursorPosition);
+ }
+
+ if (
+ this.focusedElRef &&
+ !isVisible(this.focusedElRef, this.refs.outlineList)
+ ) {
+ this.focusedElRef.scrollIntoView({ block: "center" });
+ }
+ }
+
+ setFocus(cursorPosition) {
+ const { symbols } = this.props;
+ let classes = [];
+ let functions = [];
+
+ if (symbols) {
+ ({ classes, functions } = symbols);
+ }
+
+ // Find items that enclose the selected location
+ const enclosedItems = [...classes, ...functions].filter(
+ ({ name, location }) =>
+ name != "anonymous" && 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 { cx, selectedSource, selectLocation } = this.props;
+ if (!selectedSource || !selectedItem) {
+ return;
+ }
+
+ selectLocation(
+ cx,
+ createLocation({
+ source: selectedSource,
+ line: selectedItem.location.start.line,
+ column: selectedItem.location.start.column,
+ })
+ );
+
+ this.setState({ focusedItem: selectedItem });
+ }
+
+ onContextMenu(event, func) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const { selectedSource, flashLineRange, getFunctionText } = this.props;
+
+ if (!selectedSource) {
+ return;
+ }
+
+ const sourceLine = func.location.start.line;
+ const functionText = getFunctionText(sourceLine);
+
+ const copyFunctionItem = {
+ id: "node-menu-copy-function",
+ label: L10N.getStr("copyFunction.label"),
+ accesskey: L10N.getStr("copyFunction.accesskey"),
+ disabled: !functionText,
+ click: () => {
+ flashLineRange({
+ start: sourceLine,
+ end: func.location.end.line,
+ sourceId: selectedSource.id,
+ });
+ return copyToTheClipboard(functionText);
+ },
+ };
+ const menuOptions = [copyFunctionItem];
+ showMenu(event, menuOptions);
+ }
+
+ updateFilter = filter => {
+ this.setState({ filter: filter.trim() });
+ };
+
+ renderPlaceholder() {
+ const placeholderMessage = this.props.selectedSource
+ ? L10N.getStr("outline.noFunctions")
+ : L10N.getStr("outline.noFileSelected");
+
+ return <div className="outline-pane-info">{placeholderMessage}</div>;
+ }
+
+ renderLoading() {
+ return (
+ <div className="outline-pane-info">{L10N.getStr("loadingText")}</div>
+ );
+ }
+
+ 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">λ</span>
+ <PreviewFunction func={{ name, parameterNames }} />
+ </li>
+ );
+ }
+
+ renderClassHeader(klass) {
+ return (
+ <div>
+ <span className="keyword">class</span> {klass}
+ </div>
+ );
+ }
+
+ renderClassFunctions(klass, functions) {
+ const { symbols } = this.props;
+
+ 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)}
+ </h2>
+ <ul className="outline-list__class-list">
+ {classFunctions.map(func => this.renderFunction(func))}
+ </ul>
+ </li>
+ );
+ }
+
+ 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))}
+ </ul>
+ );
+ }
+
+ renderFooter() {
+ return (
+ <div className="outline-footer">
+ <button
+ onClick={this.props.onAlphabetizeClick}
+ className={this.props.alphabetizeOutline ? "active" : ""}
+ >
+ {L10N.getStr("outline.sortLabel")}
+ </button>
+ </div>
+ );
+ }
+
+ render() {
+ const { symbols, selectedSource } = this.props;
+ const { filter } = this.state;
+
+ if (!selectedSource) {
+ return this.renderPlaceholder();
+ }
+
+ if (!symbols) {
+ return this.renderLoading();
+ }
+
+ const symbolsToDisplay = symbols.functions.filter(
+ ({ name }) => name != "anonymous"
+ );
+
+ if (symbolsToDisplay.length === 0) {
+ return this.renderPlaceholder();
+ }
+
+ return (
+ <div className="outline">
+ <div>
+ <OutlineFilter filter={filter} updateFilter={this.updateFilter} />
+ {this.renderFunctions(symbolsToDisplay)}
+ {this.renderFooter()}
+ </div>
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ const selectedSource = getSelectedSource(state);
+ const symbols = getSymbols(state, getSelectedLocation(state));
+
+ return {
+ cx: getContext(state),
+ symbols,
+ selectedSource,
+ cursorPosition: getCursorPosition(state),
+ getFunctionText: line => {
+ if (selectedSource) {
+ const selectedSourceTextContent = getSelectedSourceTextContent(state);
+ return findFunctionText(
+ line,
+ selectedSource,
+ selectedSourceTextContent,
+ symbols
+ );
+ }
+
+ return null;
+ },
+ };
+};
+
+export default connect(mapStateToProps, {
+ selectLocation: actions.selectLocation,
+ flashLineRange: actions.flashLineRange,
+})(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..354093fc31
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.css
@@ -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/. */
+
+.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-filter-input.focused {
+ border: 1px solid var(--theme-highlight-blue);
+}
+
+.outline-filter-input::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
+
+.theme-dark .outline-filter-input.focused {
+ border: 1px solid var(--blue-50);
+}
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..1d3daed0d9
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/OutlineFilter.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./OutlineFilter.css";
+
+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>
+ <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}
+ />
+ </form>
+ </div>
+ );
+ }
+}
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..f6d5e132ea
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.css
@@ -0,0 +1,165 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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);
+}
+
+.project-text-search .result .line-value {
+ grid-column: 2;
+ padding-block: 1px;
+ padding-inline-end: 4px;
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+}
+
+.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-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..922e266c40
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/ProjectSearch.js
@@ -0,0 +1,327 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import actions from "../../actions";
+
+import { getEditor } from "../../utils/editor";
+import { searchKeys } from "../../constants";
+
+import { statusType } from "../../reducers/project-text-search";
+import { getRelativePath } from "../../utils/sources-tree/utils";
+import { getFormattedSourceId } from "../../utils/source";
+import {
+ getProjectSearchResults,
+ getProjectSearchStatus,
+ getProjectSearchQuery,
+ getContext,
+} from "../../selectors";
+
+import SearchInput from "../shared/SearchInput";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const { PluralForm } = require("devtools/shared/plural-form");
+const classnames = require("devtools/client/shared/classnames.js");
+const Tree = require("devtools/client/shared/components/Tree");
+
+import "./ProjectSearch.css";
+
+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 = {
+ inputValue: this.props.query || "",
+ inputFocused: false,
+ focusedItem: null,
+ expanded: new Set(),
+ };
+ }
+
+ static get propTypes() {
+ return {
+ clearSearch: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ doSearchForHighlight: PropTypes.func.isRequired,
+ query: PropTypes.string.isRequired,
+ results: PropTypes.array.isRequired,
+ searchSources: PropTypes.func.isRequired,
+ selectSpecificLocation: PropTypes.func.isRequired,
+ setActiveSearch: PropTypes.func.isRequired,
+ status: PropTypes.oneOf([
+ "INITIAL",
+ "FETCHING",
+ "CANCELED",
+ "DONE",
+ "ERROR",
+ ]).isRequired,
+ modifiers: PropTypes.object,
+ toggleProjectSearchModifier: PropTypes.func,
+ };
+ }
+
+ componentDidMount() {
+ const { shortcuts } = this.context;
+ shortcuts.on("Enter", this.onEnterPress);
+ }
+
+ componentWillUnmount() {
+ const { shortcuts } = this.context;
+ shortcuts.off("Enter", this.onEnterPress);
+ }
+
+ componentDidUpdate(prevProps) {
+ // If the query changes in redux, also change it in the UI
+ if (prevProps.query !== this.props.query) {
+ this.setState({ inputValue: this.props.query });
+ }
+ }
+
+ doSearch(searchTerm) {
+ if (searchTerm) {
+ this.props.searchSources(this.props.cx, searchTerm);
+ }
+ }
+
+ selectMatchItem = matchItem => {
+ this.props.selectSpecificLocation(this.props.cx, matchItem.location);
+ this.props.doSearchForHighlight(
+ this.state.inputValue,
+ 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>
+ <span className="query-match" key={1}>
+ {value.substr(matchIndex, len)}
+ </span>
+ <span className="line-match" key={2}>
+ {value.slice(matchIndex + len, value.length)}
+ </span>
+ </span>
+ );
+ };
+
+ getResultCount = () =>
+ this.props.results.reduce((count, file) => count + file.matches.length, 0);
+
+ onKeyDown = e => {
+ if (e.key === "Escape") {
+ return;
+ }
+
+ e.stopPropagation();
+
+ this.setState({ focusedItem: null });
+ this.doSearch(this.state.inputValue);
+ };
+
+ onHistoryScroll = query => {
+ this.setState({ inputValue: query });
+ };
+
+ onEnterPress = () => {
+ // This is to select a match from the search result.
+ if (!this.state.focusedItem || this.state.inputFocused) {
+ return;
+ }
+ if (this.state.focusedItem.type === "MATCH") {
+ this.selectMatchItem(this.state.focusedItem);
+ }
+ };
+
+ onFocus = item => {
+ if (this.state.focusedItem !== item) {
+ this.setState({ focusedItem: item });
+ }
+ };
+
+ inputOnChange = e => {
+ const inputValue = e.target.value;
+ const { cx, clearSearch } = this.props;
+ this.setState({ inputValue });
+ if (inputValue === "") {
+ clearSearch(cx);
+ }
+ };
+
+ 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}
+ >
+ <AccessibleImage className={classnames("arrow", { expanded })} />
+ <AccessibleImage className="file" />
+ <span className="file-path">
+ {file.location.source.url
+ ? getRelativePath(file.location.source.url)
+ : getFormattedSourceId(file.location.source.id)}
+ </span>
+ <span className="matches-summary">{matches}</span>
+ </div>
+ );
+ };
+
+ renderMatch = (match, focused) => {
+ return (
+ <div
+ className={classnames("result", { focused })}
+ onClick={() => setTimeout(() => this.selectMatchItem(match), 50)}
+ >
+ <span className="line-number" key={match.location.line}>
+ {match.location.line}
+ </span>
+ {this.highlightMatches(match)}
+ </div>
+ );
+ };
+
+ renderItem = (item, depth, focused, _, expanded) => {
+ if (item.type === "RESULT") {
+ return this.renderFile(item, focused, expanded);
+ }
+ return this.renderMatch(item, focused);
+ };
+
+ renderResults = () => {
+ const { status, results } = this.props;
+ if (!this.props.query) {
+ return null;
+ }
+ if (results.length) {
+ return (
+ <Tree
+ getRoots={() => results}
+ getChildren={file => file.matches || []}
+ itemHeight={24}
+ autoExpandAll={true}
+ autoExpandDepth={1}
+ autoExpandNodeChildrenLimit={100}
+ getParent={item => null}
+ getPath={getFilePath}
+ renderItem={this.renderItem}
+ focused={this.state.focusedItem}
+ onFocus={this.onFocus}
+ 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}</div>;
+ };
+
+ renderSummary = () => {
+ if (this.props.query !== "") {
+ const resultsSummaryString = L10N.getStr("sourceSearch.resultsSummary2");
+ const count = this.getResultCount();
+ return PluralForm.get(count, resultsSummaryString).replace("#1", count);
+ }
+ return "";
+ };
+
+ shouldShowErrorEmoji() {
+ return !this.getResultCount() && this.props.status === statusType.done;
+ }
+
+ renderInput() {
+ const { status } = this.props;
+
+ return (
+ <SearchInput
+ query={this.state.inputValue}
+ count={this.getResultCount()}
+ placeholder={L10N.getStr("projectTextSearch.placeholder")}
+ size="small"
+ showErrorEmoji={this.shouldShowErrorEmoji()}
+ summaryMsg={this.renderSummary()}
+ 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(this.state.inputValue)}
+ />
+ );
+ }
+
+ render() {
+ return (
+ <div className="search-container">
+ <div className="project-text-search">
+ <div className="header">{this.renderInput()}</div>
+ {this.renderResults()}
+ </div>
+ </div>
+ );
+ }
+}
+
+ProjectSearch.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+const mapStateToProps = state => ({
+ cx: getContext(state),
+ results: getProjectSearchResults(state),
+ query: getProjectSearchQuery(state),
+ status: getProjectSearchStatus(state),
+});
+
+export default connect(mapStateToProps, {
+ searchSources: actions.searchSources,
+ clearSearch: actions.clearSearch,
+ selectSpecificLocation: actions.selectSpecificLocation,
+ setActiveSearch: actions.setActiveSearch,
+ 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..e0e251cb47
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/Sources.css
@@ -0,0 +1,219 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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;
+}
+
+.sources-panel * {
+ user-select: none;
+}
+
+/***********************/
+/* 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);
+ 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..c570bdd5a0
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTree.js
@@ -0,0 +1,510 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+// Selectors
+import {
+ getSelectedLocation,
+ getMainThreadHost,
+ getExpandedState,
+ getProjectDirectoryRoot,
+ getProjectDirectoryRootName,
+ getSourcesTreeSources,
+ getFocusedSourceItem,
+ getContext,
+ getGeneratedSourceByURL,
+ getBlackBoxRanges,
+ getHideIgnoredSources,
+} from "../../selectors";
+
+// Actions
+import actions from "../../actions";
+
+// Components
+import SourcesTreeItem from "./SourcesTreeItem";
+import AccessibleImage from "../shared/AccessibleImage";
+
+// Utils
+import { getRawSourceURL } from "../../utils/source";
+import { createLocation } from "../../utils/location";
+
+const classnames = require("devtools/client/shared/classnames.js");
+const Tree = require("devtools/client/shared/components/Tree");
+
+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;
+}
+
+/**
+ * Get the SourceItem displayed in the SourceTree for a given "tree location".
+ *
+ * @param {Object} treeLocation
+ * An object containing the Source coming from the sources.js reducer and the source actor
+ * See getTreeLocation().
+ * @param {object} rootItems
+ * Result of getSourcesTreeSources selector, containing all sources sorted in a tree structure.
+ * items to be displayed in the source tree.
+ * @return {SourceItem}
+ * The directory source item where the given source is displayed.
+ */
+function getSourceItemForTreeLocation(treeLocation, rootItems) {
+ // Sources without URLs are not visible in the SourceTree
+ const { source, sourceActor } = treeLocation;
+
+ if (!source.url) {
+ return null;
+ }
+ const { displayURL } = source;
+ function findSourceInItem(item, path) {
+ if (item.type == "source") {
+ if (item.source.url == source.url) {
+ return item;
+ }
+ return null;
+ }
+ // Bail out if we the current item doesn't match the source
+ if (item.type == "thread" && item.threadActorID != sourceActor?.thread) {
+ return null;
+ }
+ if (item.type == "group" && displayURL.group != item.groupName) {
+ return null;
+ }
+ if (item.type == "directory" && !path.startsWith(item.path)) {
+ return null;
+ }
+ // Otherwise, walk down the tree if this ancestor item seems to match
+ for (const child of item.children) {
+ const match = findSourceInItem(child, path);
+ if (match) {
+ return match;
+ }
+ }
+
+ return null;
+ }
+ for (const rootItem of rootItems) {
+ // Note that when we are setting a project root, rootItem
+ // may no longer be only Thread Item, but also be Group, Directory or Source Items.
+ const item = findSourceInItem(rootItem, displayURL.path);
+ if (item) {
+ return item;
+ }
+ }
+ return null;
+}
+
+class SourcesTree extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ }
+
+ static get propTypes() {
+ return {
+ cx: PropTypes.object.isRequired,
+ mainThreadHost: PropTypes.string.isRequired,
+ expanded: PropTypes.object.isRequired,
+ focusItem: PropTypes.func.isRequired,
+ focused: PropTypes.object,
+ projectRoot: PropTypes.string.isRequired,
+ selectSource: PropTypes.func.isRequired,
+ selectedTreeLocation: PropTypes.object,
+ setExpandedState: PropTypes.func.isRequired,
+ blackBoxRanges: PropTypes.object.isRequired,
+ rootItems: PropTypes.object.isRequired,
+ clearProjectDirectoryRoot: PropTypes.func.isRequired,
+ projectRootName: PropTypes.string.isRequired,
+ setHideOrShowIgnoredSources: PropTypes.func.isRequired,
+ hideIgnoredSources: PropTypes.bool.isRequired,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { selectedTreeLocation } = this.props;
+
+ // We might fail to find the source if its thread is registered late,
+ // so that we should re-search the selected source if state.focused is null.
+ if (
+ nextProps.selectedTreeLocation?.source &&
+ (nextProps.selectedTreeLocation.source != selectedTreeLocation?.source ||
+ (nextProps.selectedTreeLocation.source ===
+ selectedTreeLocation?.source &&
+ nextProps.selectedTreeLocation.sourceActor !=
+ selectedTreeLocation?.sourceActor) ||
+ !this.props.focused)
+ ) {
+ const sourceItem = getSourceItemForTreeLocation(
+ nextProps.selectedTreeLocation,
+ this.props.rootItems
+ );
+ if (sourceItem) {
+ // Walk up the tree to expand all ancestor items up to the root of the tree.
+ const expanded = new Set(this.props.expanded);
+ let parentDirectory = sourceItem;
+ while (parentDirectory) {
+ expanded.add(this.getKey(parentDirectory));
+ parentDirectory = this.getParent(parentDirectory);
+ }
+ this.props.setExpandedState(expanded);
+ this.onFocus(sourceItem);
+ }
+ }
+ }
+
+ selectSourceItem = item => {
+ this.props.selectSource(this.props.cx, 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) => {
+ const { expanded } = this.props;
+ 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}
+ </div>
+ );
+ }
+
+ 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);
+ };
+
+ /**
+ * Computes 4 lists:
+ * - `sourcesInside`: the list of all Source Items that are
+ * children of the current item (can be thread/group/directory).
+ * This include any nested level of children.
+ * - `sourcesOutside`: all other Source Items.
+ * i.e. all sources that are in any other folder of any group/thread.
+ * - `allInsideBlackBoxed`, all sources of `sourcesInside` which are currently
+ * blackboxed.
+ * - `allOutsideBlackBoxed`, all sources of `sourcesOutside` which are currently
+ * blackboxed.
+ */
+ getBlackBoxSourcesGroups = item => {
+ const allSources = [];
+ function collectAllSources(list, _item) {
+ if (_item.children) {
+ _item.children.forEach(i => collectAllSources(list, i));
+ }
+ if (_item.type == "source") {
+ list.push(_item.source);
+ }
+ }
+ for (const rootItem of this.props.rootItems) {
+ collectAllSources(allSources, rootItem);
+ }
+
+ const sourcesInside = [];
+ collectAllSources(sourcesInside, item);
+
+ const sourcesOutside = allSources.filter(
+ source => !sourcesInside.includes(source)
+ );
+ const allInsideBlackBoxed = sourcesInside.every(
+ source => this.props.blackBoxRanges[source.url]
+ );
+ const allOutsideBlackBoxed = sourcesOutside.every(
+ source => this.props.blackBoxRanges[source.url]
+ );
+
+ return {
+ sourcesInside,
+ sourcesOutside,
+ allInsideBlackBoxed,
+ allOutsideBlackBoxed,
+ };
+ };
+
+ renderProjectRootHeader() {
+ const { cx, 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(cx)}
+ title={L10N.getStr("removeDirectoryRoot.label")}
+ >
+ <AccessibleImage className="home" />
+ <AccessibleImage className="breadcrumb" />
+ <span className="sources-clear-root-label">{projectRootName}</span>
+ </button>
+ </div>
+ );
+ }
+
+ renderItem = (item, depth, focused, _, expanded) => {
+ const { mainThreadHost, projectRoot } = this.props;
+ return (
+ <SourcesTreeItem
+ item={item}
+ depth={depth}
+ focused={focused}
+ autoExpand={shouldAutoExpand(item, mainThreadHost)}
+ expanded={expanded}
+ focusItem={this.onFocus}
+ selectSourceItem={this.selectSourceItem}
+ projectRoot={projectRoot}
+ setExpanded={this.setExpanded}
+ getBlackBoxSourcesGroups={this.getBlackBoxSourcesGroups}
+ 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,
+ itemHeight: 21,
+ key: this.isEmpty() ? "empty" : "full",
+ 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 <Tree {...treeProps} />;
+ }
+
+ renderPane(child) {
+ const { projectRoot } = this.props;
+
+ return (
+ <div
+ key="pane"
+ className={classnames("sources-pane", {
+ "sources-list-custom-root": !!projectRoot,
+ })}
+ >
+ {child}
+ </div>
+ );
+ }
+
+ 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")}
+ </button>
+ </footer>
+ );
+ }
+ 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"))
+ ) : (
+ <>
+ {this.renderProjectRootHeader()}
+ {this.renderTree()}
+ {this.renderFooter()}
+ </>
+ )}
+ </div>
+ );
+ }
+}
+
+function getTreeLocation(state, location) {
+ // In the SourceTree, we never show the pretty printed sources and only
+ // the minified version, so if we are selecting a pretty file, fake selecting
+ // the minified version.
+ if (location?.source.isPrettyPrinted) {
+ const source = getGeneratedSourceByURL(
+ state,
+ getRawSourceURL(location.source.url)
+ );
+ if (source) {
+ return createLocation({
+ source,
+ // A source actor is required by getSourceItemForTreeLocation
+ // in order to know in which thread this source relates to.
+ sourceActor: location.sourceActor,
+ });
+ }
+ }
+ return location;
+}
+
+const mapStateToProps = state => {
+ const rootItems = getSourcesTreeSources(state);
+
+ return {
+ cx: getContext(state),
+ selectedTreeLocation: getTreeLocation(state, getSelectedLocation(state)),
+ mainThreadHost: getMainThreadHost(state),
+ expanded: getExpandedState(state),
+ focused: getFocusedSourceItem(state),
+ projectRoot: getProjectDirectoryRoot(state),
+ rootItems,
+ blackBoxRanges: getBlackBoxRanges(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..874df4c77c
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/SourcesTreeItem.js
@@ -0,0 +1,457 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import { showMenu } from "../../context-menu/menu";
+
+import SourceIcon from "../shared/SourceIcon";
+import AccessibleImage from "../shared/AccessibleImage";
+
+import {
+ getGeneratedSourceByURL,
+ getContext,
+ getFirstSourceActorForGeneratedSource,
+ isSourceOverridden,
+ getHideIgnoredSources,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../selectors";
+import actions from "../../actions";
+
+import { shouldBlackbox, sourceTypes } from "../../utils/source";
+import { copyToTheClipboard } from "../../utils/clipboard";
+import { saveAsLocalFile } from "../../utils/utils";
+import { createLocation } from "../../utils/location";
+import { safeDecodeItemName } from "../../utils/sources-tree/utils";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+class SourceTreeItem extends Component {
+ static get propTypes() {
+ return {
+ autoExpand: PropTypes.bool.isRequired,
+ blackBoxSources: PropTypes.func.isRequired,
+ clearProjectDirectoryRoot: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ depth: PropTypes.number.isRequired,
+ expanded: PropTypes.bool.isRequired,
+ focusItem: PropTypes.func.isRequired,
+ focused: PropTypes.bool.isRequired,
+ getBlackBoxSourcesGroups: PropTypes.func.isRequired,
+ hasMatchingGeneratedSource: PropTypes.bool.isRequired,
+ item: PropTypes.object.isRequired,
+ loadSourceText: PropTypes.func.isRequired,
+ getFirstSourceActorForGeneratedSource: PropTypes.func.isRequired,
+ projectRoot: PropTypes.string.isRequired,
+ selectSourceItem: PropTypes.func.isRequired,
+ setExpanded: PropTypes.func.isRequired,
+ setProjectDirectoryRoot: PropTypes.func.isRequired,
+ toggleBlackBox: PropTypes.func.isRequired,
+ getParent: PropTypes.func.isRequired,
+ setOverrideSource: PropTypes.func.isRequired,
+ removeOverrideSource: PropTypes.func.isRequired,
+ isOverridden: PropTypes.bool,
+ hideIgnoredSources: PropTypes.bool,
+ isSourceOnIgnoreList: 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 => {
+ const copySourceUri2Label = L10N.getStr("copySourceUri2");
+ const copySourceUri2Key = L10N.getStr("copySourceUri2.accesskey");
+ const setDirectoryRootLabel = L10N.getStr("setDirectoryRoot.label");
+ const setDirectoryRootKey = L10N.getStr("setDirectoryRoot.accesskey");
+ const removeDirectoryRootLabel = L10N.getStr("removeDirectoryRoot.label");
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ const menuOptions = [];
+
+ const { item, isOverridden, cx, isSourceOnIgnoreList } = this.props;
+ if (item.type == "source") {
+ const { source } = item;
+ const copySourceUri2 = {
+ id: "node-menu-copy-source",
+ label: copySourceUri2Label,
+ accesskey: copySourceUri2Key,
+ disabled: false,
+ click: () => copyToTheClipboard(source.url),
+ };
+
+ const ignoreStr = item.isBlackBoxed ? "unignore" : "ignore";
+ const blackBoxMenuItem = {
+ id: "node-menu-blackbox",
+ label: L10N.getStr(`ignoreContextItem.${ignoreStr}`),
+ accesskey: L10N.getStr(`ignoreContextItem.${ignoreStr}.accesskey`),
+ disabled: isSourceOnIgnoreList || !shouldBlackbox(source),
+ click: () => this.props.toggleBlackBox(cx, source),
+ };
+ const downloadFileItem = {
+ id: "node-menu-download-file",
+ label: L10N.getStr("downloadFile.label"),
+ accesskey: L10N.getStr("downloadFile.accesskey"),
+ disabled: false,
+ click: () => this.saveLocalFile(cx, source),
+ };
+
+ const overrideStr = !isOverridden ? "override" : "removeOverride";
+ const overridesItem = {
+ id: "node-menu-overrides",
+ label: L10N.getStr(`overridesContextItem.${overrideStr}`),
+ accesskey: L10N.getStr(`overridesContextItem.${overrideStr}.accesskey`),
+ disabled: !!source.isHTML,
+ click: () => this.handleLocalOverride(cx, source, isOverridden),
+ };
+
+ menuOptions.push(
+ copySourceUri2,
+ blackBoxMenuItem,
+ downloadFileItem,
+ overridesItem
+ );
+ }
+
+ // All other types other than source are folder-like
+ if (item.type != "source") {
+ this.addCollapseExpandAllOptions(menuOptions, item);
+
+ const { depth, projectRoot } = this.props;
+
+ if (projectRoot == item.uniquePath) {
+ menuOptions.push({
+ id: "node-remove-directory-root",
+ label: removeDirectoryRootLabel,
+ disabled: false,
+ click: () => this.props.clearProjectDirectoryRoot(cx),
+ });
+ } else {
+ menuOptions.push({
+ id: "node-set-directory-root",
+ label: setDirectoryRootLabel,
+ accesskey: setDirectoryRootKey,
+ disabled: false,
+ click: () =>
+ this.props.setProjectDirectoryRoot(
+ cx,
+ item.uniquePath,
+ this.renderItemName(depth)
+ ),
+ });
+ }
+
+ this.addBlackboxAllOption(menuOptions, item);
+ }
+
+ showMenu(event, menuOptions);
+ };
+
+ saveLocalFile = async (cx, source) => {
+ if (!source) {
+ return null;
+ }
+
+ const data = await this.props.loadSourceText(cx, source);
+ if (!data) {
+ return null;
+ }
+ return saveAsLocalFile(data.value, source.displayURL.filename);
+ };
+
+ handleLocalOverride = async (cx, source, isOverridden) => {
+ if (!isOverridden) {
+ const localPath = await this.saveLocalFile(cx, source);
+ if (localPath) {
+ this.props.setOverrideSource(cx, source, localPath);
+ }
+ } else {
+ this.props.removeOverrideSource(cx, source);
+ }
+ };
+
+ addBlackboxAllOption = (menuOptions, item) => {
+ const { cx, depth, projectRoot } = this.props;
+ const {
+ sourcesInside,
+ sourcesOutside,
+ allInsideBlackBoxed,
+ allOutsideBlackBoxed,
+ } = this.props.getBlackBoxSourcesGroups(item);
+
+ let blackBoxInsideMenuItemLabel;
+ let blackBoxOutsideMenuItemLabel;
+ if (depth === 0 || (depth === 1 && projectRoot === "")) {
+ blackBoxInsideMenuItemLabel = allInsideBlackBoxed
+ ? L10N.getStr("unignoreAllInGroup.label")
+ : L10N.getStr("ignoreAllInGroup.label");
+ if (sourcesOutside.length) {
+ blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed
+ ? L10N.getStr("unignoreAllOutsideGroup.label")
+ : L10N.getStr("ignoreAllOutsideGroup.label");
+ }
+ } else {
+ blackBoxInsideMenuItemLabel = allInsideBlackBoxed
+ ? L10N.getStr("unignoreAllInDir.label")
+ : L10N.getStr("ignoreAllInDir.label");
+ if (sourcesOutside.length) {
+ blackBoxOutsideMenuItemLabel = allOutsideBlackBoxed
+ ? L10N.getStr("unignoreAllOutsideDir.label")
+ : L10N.getStr("ignoreAllOutsideDir.label");
+ }
+ }
+
+ const blackBoxInsideMenuItem = {
+ id: allInsideBlackBoxed
+ ? "node-unblackbox-all-inside"
+ : "node-blackbox-all-inside",
+ label: blackBoxInsideMenuItemLabel,
+ disabled: false,
+ click: () =>
+ this.props.blackBoxSources(cx, sourcesInside, !allInsideBlackBoxed),
+ };
+
+ if (sourcesOutside.length) {
+ menuOptions.push({
+ id: "node-blackbox-all",
+ label: L10N.getStr("ignoreAll.label"),
+ submenu: [
+ blackBoxInsideMenuItem,
+ {
+ id: allOutsideBlackBoxed
+ ? "node-unblackbox-all-outside"
+ : "node-blackbox-all-outside",
+ label: blackBoxOutsideMenuItemLabel,
+ disabled: false,
+ click: () =>
+ this.props.blackBoxSources(
+ cx,
+ sourcesOutside,
+ !allOutsideBlackBoxed
+ ),
+ },
+ ],
+ });
+ } else {
+ menuOptions.push(blackBoxInsideMenuItem);
+ }
+ };
+
+ addCollapseExpandAllOptions = (menuOptions, item) => {
+ const { setExpanded } = this.props;
+
+ menuOptions.push({
+ id: "node-menu-collapse-all",
+ label: L10N.getStr("collapseAll.label"),
+ disabled: false,
+ click: () => setExpanded(item, false, true),
+ });
+
+ menuOptions.push({
+ id: "node-menu-expand-all",
+ label: L10N.getStr("expandAll.label"),
+ disabled: false,
+ click: () => setExpanded(item, true, true),
+ });
+ };
+
+ renderItemArrow() {
+ const { item, expanded } = this.props;
+ return item.type != "source" ? (
+ <AccessibleImage className={classnames("arrow", { expanded })} />
+ ) : (
+ <span className="img no-arrow" />
+ );
+ }
+
+ renderIcon(item, depth) {
+ if (item.type == "thread") {
+ const icon = item.thread.targetType.includes("worker")
+ ? "worker"
+ : "window";
+ return <AccessibleImage className={classnames(icon)} />;
+ }
+ if (item.type == "group") {
+ if (item.groupName === "Webpack") {
+ return <AccessibleImage className="webpack" />;
+ } else if (item.groupName === "Angular") {
+ return <AccessibleImage className="angular" />;
+ }
+ // Check if the group relates to an extension.
+ // This happens when a webextension injects a content script.
+ if (item.isForExtensionSource) {
+ return <AccessibleImage className="extension" />;
+ }
+
+ return <AccessibleImage className="globe-small" />;
+ }
+ if (item.type == "directory") {
+ return <AccessibleImage className="folder" />;
+ }
+ if (item.type == "source") {
+ const { source, sourceActor } = item;
+ return (
+ <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(depth) {
+ 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,
+ depth,
+ focused,
+ hasMatchingGeneratedSource,
+ hideIgnoredSources,
+ } = this.props;
+
+ if (hideIgnoredSources && item.isBlackBoxed) {
+ return null;
+ }
+ const suffix = hasMatchingGeneratedSource ? (
+ <span className="suffix">{L10N.getStr("sourceFooter.mappedSuffix")}</span>
+ ) : 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, depth)}
+ <span className="label">
+ {this.renderItemName(depth)}
+ {suffix}
+ </span>
+ </div>
+ );
+ }
+}
+
+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 {
+ cx: getContext(state),
+ hasMatchingGeneratedSource: getHasMatchingGeneratedSource(state, source),
+ getFirstSourceActorForGeneratedSource: (sourceId, threadId) =>
+ getFirstSourceActorForGeneratedSource(state, sourceId, threadId),
+ isOverridden: isSourceOverridden(state, source),
+ hideIgnoredSources: getHideIgnoredSources(state),
+ isSourceOnIgnoreList:
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, source),
+ };
+ }
+ return {
+ cx: getContext(state),
+ getFirstSourceActorForGeneratedSource: (sourceId, threadId) =>
+ getFirstSourceActorForGeneratedSource(state, sourceId, threadId),
+ };
+};
+
+export default connect(mapStateToProps, {
+ setProjectDirectoryRoot: actions.setProjectDirectoryRoot,
+ clearProjectDirectoryRoot: actions.clearProjectDirectoryRoot,
+ toggleBlackBox: actions.toggleBlackBox,
+ loadSourceText: actions.loadSourceText,
+ blackBoxSources: actions.blackBoxSources,
+ setBlackBoxAllOutside: actions.setBlackBoxAllOutside,
+ setOverrideSource: actions.setOverrideSource,
+ removeOverrideSource: actions.removeOverrideSource,
+})(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..c0ab3075bd
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/index.js
@@ -0,0 +1,132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { Tab, Tabs, TabList, TabPanels } from "react-aria-components/src/tabs";
+
+import actions from "../../actions";
+import { getSelectedPrimaryPaneTab, getContext } from "../../selectors";
+import { prefs } from "../../utils/prefs";
+import { connect } from "../../utils/connect";
+import { primaryPaneTabs } from "../../constants";
+import { formatKeyShortcut } from "../../utils/text";
+
+import Outline from "./Outline";
+import SourcesTree from "./SourcesTree";
+import ProjectSearch from "./ProjectSearch";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./Sources.css";
+
+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 {
+ cx: PropTypes.object.isRequired,
+ 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();
+ }
+ };
+
+ renderTabList() {
+ return [
+ <Tab
+ className={classnames("tab sources-tab", {
+ active: this.props.selectedTab === primaryPaneTabs.SOURCES,
+ })}
+ key="sources-tab"
+ >
+ {formatKeyShortcut(L10N.getStr("sources.header"))}
+ </Tab>,
+ <Tab
+ className={classnames("tab outline-tab", {
+ active: this.props.selectedTab === primaryPaneTabs.OUTLINE,
+ })}
+ key="outline-tab"
+ >
+ {formatKeyShortcut(L10N.getStr("outline.header"))}
+ </Tab>,
+ <Tab
+ className={classnames("tab search-tab", {
+ active: this.props.selectedTab === primaryPaneTabs.PROJECT_SEARCH,
+ })}
+ key="search-tab"
+ >
+ {formatKeyShortcut(L10N.getStr("search.header"))}
+ </Tab>,
+ ];
+ }
+
+ render() {
+ const { selectedTab } = this.props;
+ return (
+ <Tabs
+ activeIndex={tabs.indexOf(selectedTab)}
+ className="sources-panel"
+ onActivateTab={this.onActivateTab}
+ >
+ <TabList className="source-outline-tabs">
+ {this.renderTabList()}
+ </TabList>
+ <TabPanels className="source-outline-panel" hasFocusableContent>
+ <SourcesTree />
+ <Outline
+ alphabetizeOutline={this.state.alphabetizeOutline}
+ onAlphabetizeClick={this.onAlphabetizeClick}
+ />
+ <ProjectSearch />
+ </TabPanels>
+ </Tabs>
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ cx: getContext(state),
+ 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/PrimaryPanes/tests/ProjectSearch.spec.js b/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js
new file mode 100644
index 0000000000..10f9f197fe
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/tests/ProjectSearch.spec.js
@@ -0,0 +1,326 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { Provider } from "react-redux";
+import configureStore from "redux-mock-store";
+import PropTypes from "prop-types";
+
+import { mount, shallow } from "enzyme";
+import { ProjectSearch } from "../ProjectSearch";
+import { statusType } from "../../../reducers/project-text-search";
+import { mockcx } from "../../../utils/test-mockup";
+import { searchKeys } from "../../../constants";
+
+const hooks = { on: [], off: [] };
+const shortcuts = {
+ dispatch(eventName) {
+ hooks.on.forEach(hook => {
+ if (hook.event === eventName) {
+ hook.cb();
+ }
+ });
+ hooks.off.forEach(hook => {
+ if (hook.event === eventName) {
+ hook.cb();
+ }
+ });
+ },
+ on: jest.fn((event, cb) => hooks.on.push({ event, cb })),
+ off: jest.fn((event, cb) => hooks.off.push({ event, cb })),
+};
+
+const context = { shortcuts };
+
+const testResults = [
+ {
+ location: {
+ source: {
+ url: "testFilePath1",
+ },
+ },
+ type: "RESULT",
+ matches: [
+ {
+ match: "match1",
+ value: "some thing match1",
+ location: {
+ source: {},
+ column: 30,
+ },
+ type: "MATCH",
+ },
+ {
+ match: "match2",
+ value: "some thing match2",
+ location: {
+ source: {},
+ column: 60,
+ },
+ type: "MATCH",
+ },
+ {
+ match: "match3",
+ value: "some thing match3",
+ location: {
+ source: {},
+ column: 90,
+ },
+ type: "MATCH",
+ },
+ ],
+ },
+ {
+ location: {
+ source: {
+ url: "testFilePath2",
+ },
+ },
+ type: "RESULT",
+ matches: [
+ {
+ match: "match4",
+ value: "some thing match4",
+ location: {
+ source: {},
+ column: 80,
+ },
+ type: "MATCH",
+ },
+ {
+ match: "match5",
+ value: "some thing match5",
+ location: {
+ source: {},
+ column: 40,
+ },
+ type: "MATCH",
+ },
+ ],
+ },
+];
+
+const testMatch = {
+ type: "MATCH",
+ match: "match1",
+ value: "some thing match1",
+ sourceId: "some-target/source42",
+ location: {
+ source: {
+ id: "some-target/source42",
+ },
+ line: 3,
+ column: 30,
+ },
+};
+
+function render(overrides = {}, mounted = false) {
+ const mockStore = configureStore([]);
+ const store = mockStore({
+ ui: {
+ mutableSearchOptions: {
+ [searchKeys.PROJECT_SEARCH]: {
+ regexMatch: false,
+ wholeWord: false,
+ caseSensitive: false,
+ excludePatterns: "",
+ },
+ },
+ },
+ });
+ const props = {
+ cx: mockcx,
+ status: "DONE",
+ sources: {},
+ results: [],
+ query: "foo",
+ activeSearch: "project",
+ closeProjectSearch: jest.fn(),
+ searchSources: jest.fn(),
+ clearSearch: jest.fn(),
+ updateSearchStatus: jest.fn(),
+ selectSpecificLocation: jest.fn(),
+ doSearchForHighlight: jest.fn(),
+ setActiveSearch: jest.fn(),
+ ...overrides,
+ };
+
+ if (mounted) {
+ return mount(
+ <Provider store={store}>
+ <ProjectSearch {...props} />
+ </Provider>,
+ { context, childContextTypes: { shortcuts: PropTypes.object } }
+ ).childAt(0);
+ }
+
+ return shallow(
+ <Provider store={store}>
+ <ProjectSearch {...props} />
+ </Provider>,
+ { context }
+ ).dive();
+}
+
+describe("ProjectSearch", () => {
+ beforeEach(() => {
+ context.shortcuts.on.mockClear();
+ context.shortcuts.off.mockClear();
+ });
+
+ it("renders nothing when disabled", () => {
+ const component = render({ activeSearch: "" });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("where <Enter> has not been pressed", () => {
+ const component = render({ query: "" });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("found no search results", () => {
+ const component = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("should display loading message while search is in progress", () => {
+ const component = render({
+ query: "match",
+ status: statusType.fetching,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("found search results", () => {
+ const component = render(
+ {
+ query: "match",
+ results: testResults,
+ },
+ true
+ );
+ expect(component).toMatchSnapshot();
+ });
+
+ it("turns off shortcuts on unmount", () => {
+ const component = render({
+ query: "",
+ });
+ expect(component).toMatchSnapshot();
+ component.unmount();
+ expect(context.shortcuts.off).toHaveBeenCalled();
+ });
+
+ it("calls inputOnChange", () => {
+ const component = render(
+ {
+ results: testResults,
+ },
+ true
+ );
+ component
+ .find("SearchInput .search-field input")
+ .simulate("change", { target: { value: "bar" } });
+ expect(component.state().inputValue).toEqual("bar");
+ });
+
+ it("onKeyDown Escape/Other", () => {
+ const searchSources = jest.fn();
+ const component = render(
+ {
+ results: testResults,
+ searchSources,
+ },
+ true
+ );
+ component
+ .find("SearchInput .search-field input")
+ .simulate("keydown", { key: "Escape" });
+ expect(searchSources).not.toHaveBeenCalled();
+ searchSources.mockClear();
+ component
+ .find("SearchInput .search-field input")
+ .simulate("keydown", { key: "Other", stopPropagation: jest.fn() });
+ expect(searchSources).not.toHaveBeenCalled();
+ });
+
+ it("onKeyDown Enter", () => {
+ const searchSources = jest.fn();
+ const component = render(
+ {
+ results: testResults,
+ searchSources,
+ },
+ true
+ );
+ component
+ .find("SearchInput .search-field input")
+ .simulate("keydown", { key: "Enter", stopPropagation: jest.fn() });
+ expect(searchSources).toHaveBeenCalledWith(mockcx, "foo");
+ });
+
+ it("onEnterPress shortcut no match or setExpanded", () => {
+ const selectSpecificLocation = jest.fn();
+ const component = render(
+ {
+ results: testResults,
+ selectSpecificLocation,
+ },
+ true
+ );
+ component.instance().state.focusedItem = null;
+ shortcuts.dispatch("Enter");
+ expect(selectSpecificLocation).not.toHaveBeenCalled();
+ });
+
+ it("onEnterPress shortcut match", () => {
+ const selectSpecificLocation = jest.fn();
+ const component = render(
+ {
+ results: testResults,
+ selectSpecificLocation,
+ },
+ true
+ );
+ component.instance().state.focusedItem = { ...testMatch };
+ shortcuts.dispatch("Enter");
+ expect(selectSpecificLocation).toHaveBeenCalledWith(mockcx, {
+ source: {
+ id: "some-target/source42",
+ },
+ line: 3,
+ column: 30,
+ });
+ });
+
+ it("state.inputValue responds to prop.query changes", () => {
+ const component = render({ query: "foo" });
+ expect(component.state().inputValue).toEqual("foo");
+ component.setProps({ query: "" });
+ expect(component.state().inputValue).toEqual("");
+ });
+
+ describe("showErrorEmoji", () => {
+ it("false if not done & results", () => {
+ const component = render({
+ status: statusType.fetching,
+ results: testResults,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("false if not done & no results", () => {
+ const component = render({
+ status: statusType.fetching,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ // "false if done & has results"
+ // is the same test as "found search results"
+
+ // "true if done & has no results"
+ // is the same test as "found no search results"
+ });
+});
diff --git a/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap b/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap
new file mode 100644
index 0000000000..4be18c4753
--- /dev/null
+++ b/devtools/client/debugger/src/components/PrimaryPanes/tests/__snapshots__/ProjectSearch.spec.js.snap
@@ -0,0 +1,1111 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProjectSearch found no search results 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="foo"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="0 results"
+ />
+ </div>
+ <div
+ className="no-result-msg absolute-center"
+ >
+ No results found
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch found search results 1`] = `
+<ProjectSearch
+ activeSearch="project"
+ clearSearch={[MockFunction]}
+ closeProjectSearch={[MockFunction]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ doSearchForHighlight={[MockFunction]}
+ query="match"
+ results={
+ Array [
+ Object {
+ "location": Object {
+ "source": Object {
+ "url": "testFilePath1",
+ },
+ },
+ "matches": Array [
+ Object {
+ "location": Object {
+ "column": 30,
+ "source": Object {},
+ },
+ "match": "match1",
+ "type": "MATCH",
+ "value": "some thing match1",
+ },
+ Object {
+ "location": Object {
+ "column": 60,
+ "source": Object {},
+ },
+ "match": "match2",
+ "type": "MATCH",
+ "value": "some thing match2",
+ },
+ Object {
+ "location": Object {
+ "column": 90,
+ "source": Object {},
+ },
+ "match": "match3",
+ "type": "MATCH",
+ "value": "some thing match3",
+ },
+ ],
+ "type": "RESULT",
+ },
+ Object {
+ "location": Object {
+ "source": Object {
+ "url": "testFilePath2",
+ },
+ },
+ "matches": Array [
+ Object {
+ "location": Object {
+ "column": 80,
+ "source": Object {},
+ },
+ "match": "match4",
+ "type": "MATCH",
+ "value": "some thing match4",
+ },
+ Object {
+ "location": Object {
+ "column": 40,
+ "source": Object {},
+ },
+ "match": "match5",
+ "type": "MATCH",
+ "value": "some thing match5",
+ },
+ ],
+ "type": "RESULT",
+ },
+ ]
+ }
+ searchSources={[MockFunction]}
+ selectSpecificLocation={[MockFunction]}
+ setActiveSearch={[MockFunction]}
+ sources={Object {}}
+ status="DONE"
+ updateSearchStatus={[MockFunction]}
+>
+ <div
+ className="search-container"
+ >
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={5}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="match"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="5 results"
+ >
+ <SearchInput
+ count={5}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ expanded={false}
+ hasPrefix={false}
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="match"
+ searchKey="project-search"
+ searchOptions={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ selectedItemId=""
+ setSearchOptions={[Function]}
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="5 results"
+ >
+ <div
+ className="search-outline"
+ >
+ <div
+ aria-expanded={false}
+ aria-haspopup="listbox"
+ aria-owns="result-list"
+ className="search-field small"
+ 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="Find in files…"
+ spellCheck={false}
+ value="match"
+ />
+ <div
+ className="search-field-summary"
+ >
+ 5 results
+ </div>
+ <div
+ className="search-buttons-bar"
+ >
+ <SearchModifiers
+ modifiers={
+ Object {
+ "caseSensitive": false,
+ "excludePatterns": "",
+ "regexMatch": false,
+ "wholeWord": false,
+ }
+ }
+ onToggleSearchModifier={[Function]}
+ >
+ <div
+ className="search-modifiers"
+ >
+ <span
+ className="pipe-divider"
+ />
+ <button
+ className="regex-match-btn "
+ onKeyDown={[Function]}
+ onMouseDown={[Function]}
+ title="Use Regular Expression"
+ >
+ <span
+ className="regex-match"
+ />
+ </button>
+ <button
+ className="case-sensitive-btn "
+ onKeyDown={[Function]}
+ onMouseDown={[Function]}
+ title="Match Case"
+ >
+ <span
+ className="case-match"
+ />
+ </button>
+ <button
+ className="whole-word-btn "
+ onKeyDown={[Function]}
+ onMouseDown={[Function]}
+ title="Match Whole Word"
+ >
+ <span
+ className="whole-word-match"
+ />
+ </button>
+ </div>
+ </SearchModifiers>
+ </div>
+ </div>
+ <div
+ className="exclude-patterns-field small"
+ >
+ <label>
+ files to exclude
+ </label>
+ <input
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="e.g. **/node_modules/**,app.js"
+ value=""
+ />
+ </div>
+ </div>
+ </SearchInput>
+ </Connect(SearchInput)>
+ </div>
+ <Tree
+ autoExpandAll={true}
+ autoExpandDepth={1}
+ autoExpandNodeChildrenLimit={100}
+ focused={null}
+ getChildren={[Function]}
+ getKey={[Function]}
+ getParent={[Function]}
+ getPath={[Function]}
+ getRoots={[Function]}
+ isExpanded={[Function]}
+ itemHeight={24}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ onFocus={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-activedescendant={null}
+ className="tree "
+ onBlur={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ onKeyPress={[Function]}
+ onKeyUp={[Function]}
+ role="tree"
+ style={Object {}}
+ tabIndex="0"
+ >
+ <TreeNode
+ active={false}
+ depth={0}
+ expanded={true}
+ focused={false}
+ id="undefined-$"
+ index={0}
+ isExpandable={true}
+ item={
+ Object {
+ "location": Object {
+ "source": Object {
+ "url": "testFilePath1",
+ },
+ },
+ "matches": Array [
+ Object {
+ "location": Object {
+ "column": 30,
+ "source": Object {},
+ },
+ "match": "match1",
+ "type": "MATCH",
+ "value": "some thing match1",
+ },
+ Object {
+ "location": Object {
+ "column": 60,
+ "source": Object {},
+ },
+ "match": "match2",
+ "type": "MATCH",
+ "value": "some thing match2",
+ },
+ Object {
+ "location": Object {
+ "column": 90,
+ "source": Object {},
+ },
+ "match": "match3",
+ "type": "MATCH",
+ "value": "some thing match3",
+ },
+ ],
+ "type": "RESULT",
+ }
+ }
+ key="undefined-$-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-expanded={true}
+ aria-level={1}
+ className="tree-node"
+ data-expandable={true}
+ id="undefined-$"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <div
+ className="file-result"
+ >
+ <AccessibleImage
+ className="arrow expanded"
+ >
+ <span
+ className="img arrow expanded"
+ />
+ </AccessibleImage>
+ <AccessibleImage
+ className="file"
+ >
+ <span
+ className="img file"
+ />
+ </AccessibleImage>
+ <span
+ className="file-path"
+ />
+ <span
+ className="matches-summary"
+ >
+ (3 matches)
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-30-1"
+ index={1}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 30,
+ "source": Object {},
+ },
+ "match": "match1",
+ "type": "MATCH",
+ "value": "some thing match1",
+ }
+ }
+ key="undefined-undefined-30-1-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-30-1"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match1
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match1
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-60-2"
+ index={2}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 60,
+ "source": Object {},
+ },
+ "match": "match2",
+ "type": "MATCH",
+ "value": "some thing match2",
+ }
+ }
+ key="undefined-undefined-60-2-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-60-2"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match2
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match2
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-90-3"
+ index={3}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 90,
+ "source": Object {},
+ },
+ "match": "match3",
+ "type": "MATCH",
+ "value": "some thing match3",
+ }
+ }
+ key="undefined-undefined-90-3-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-90-3"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match3
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match3
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={0}
+ expanded={true}
+ focused={false}
+ id="undefined-4"
+ index={4}
+ isExpandable={true}
+ item={
+ Object {
+ "location": Object {
+ "source": Object {
+ "url": "testFilePath2",
+ },
+ },
+ "matches": Array [
+ Object {
+ "location": Object {
+ "column": 80,
+ "source": Object {},
+ },
+ "match": "match4",
+ "type": "MATCH",
+ "value": "some thing match4",
+ },
+ Object {
+ "location": Object {
+ "column": 40,
+ "source": Object {},
+ },
+ "match": "match5",
+ "type": "MATCH",
+ "value": "some thing match5",
+ },
+ ],
+ "type": "RESULT",
+ }
+ }
+ key="undefined-4-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-expanded={true}
+ aria-level={1}
+ className="tree-node"
+ data-expandable={true}
+ id="undefined-4"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <div
+ className="file-result"
+ >
+ <AccessibleImage
+ className="arrow expanded"
+ >
+ <span
+ className="img arrow expanded"
+ />
+ </AccessibleImage>
+ <AccessibleImage
+ className="file"
+ >
+ <span
+ className="img file"
+ />
+ </AccessibleImage>
+ <span
+ className="file-path"
+ />
+ <span
+ className="matches-summary"
+ >
+ (2 matches)
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-80-5"
+ index={5}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 80,
+ "source": Object {},
+ },
+ "match": "match4",
+ "type": "MATCH",
+ "value": "some thing match4",
+ }
+ }
+ key="undefined-undefined-80-5-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-80-5"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match4
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match4
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ <TreeNode
+ active={false}
+ depth={1}
+ expanded={false}
+ focused={false}
+ id="undefined-undefined-40-6"
+ index={6}
+ isExpandable={false}
+ item={
+ Object {
+ "location": Object {
+ "column": 40,
+ "source": Object {},
+ },
+ "match": "match5",
+ "type": "MATCH",
+ "value": "some thing match5",
+ }
+ }
+ key="undefined-undefined-40-6-inactive"
+ onClick={[Function]}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ renderItem={[Function]}
+ >
+ <div
+ aria-level={2}
+ className="tree-node"
+ data-expandable={false}
+ id="undefined-undefined-40-6"
+ onClick={[Function]}
+ onKeyDownCapture={null}
+ role="treeitem"
+ >
+ <span
+ className="tree-indent tree-last-indent"
+ >
+ ​
+ </span>
+ <div
+ className="result"
+ onClick={[Function]}
+ >
+ <span
+ className="line-number"
+ />
+ <span
+ className="line-value"
+ >
+ <span
+ className="line-match"
+ key="0"
+ >
+ some thing match5
+ </span>
+ <span
+ className="query-match"
+ key="1"
+ >
+ some t
+ </span>
+ <span
+ className="line-match"
+ key="2"
+ >
+ some thing match5
+ </span>
+ </span>
+ </div>
+ </div>
+ </TreeNode>
+ </div>
+ </Tree>
+ </div>
+ </div>
+</ProjectSearch>
+`;
+
+exports[`ProjectSearch renders nothing when disabled 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="foo"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="0 results"
+ />
+ </div>
+ <div
+ className="no-result-msg absolute-center"
+ >
+ No results found
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch should display loading message while search is in progress 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={true}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="match"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="0 results"
+ />
+ </div>
+ <div
+ className="no-result-msg absolute-center"
+ >
+ Loading…
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch showErrorEmoji false if not done & no results 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={true}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="foo"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="0 results"
+ />
+ </div>
+ <div
+ className="no-result-msg absolute-center"
+ >
+ Loading…
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch showErrorEmoji false if not done & results 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={5}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={true}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query="foo"
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={false}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg="5 results"
+ />
+ </div>
+ <Tree
+ autoExpandAll={true}
+ autoExpandDepth={1}
+ autoExpandNodeChildrenLimit={100}
+ focused={null}
+ getChildren={[Function]}
+ getKey={[Function]}
+ getParent={[Function]}
+ getPath={[Function]}
+ getRoots={[Function]}
+ isExpanded={[Function]}
+ itemHeight={24}
+ onCollapse={[Function]}
+ onExpand={[Function]}
+ onFocus={[Function]}
+ renderItem={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch turns off shortcuts on unmount 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query=""
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg=""
+ />
+ </div>
+ </div>
+</div>
+`;
+
+exports[`ProjectSearch where <Enter> has not been pressed 1`] = `
+<div
+ className="search-container"
+>
+ <div
+ className="project-text-search"
+ >
+ <div
+ className="header"
+ >
+ <Connect(SearchInput)
+ count={0}
+ excludePatternsLabel="files to exclude"
+ excludePatternsPlaceholder="e.g. **/node_modules/**,app.js"
+ isLoading={false}
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onHistoryScroll={[Function]}
+ onKeyDown={[Function]}
+ onToggleSearchModifier={[Function]}
+ placeholder="Find in files…"
+ query=""
+ searchKey="project-search"
+ showClose={false}
+ showErrorEmoji={true}
+ showExcludePatterns={true}
+ showSearchModifiers={true}
+ size="small"
+ summaryMsg=""
+ />
+ </div>
+ </div>
+</div>
+`;
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..f993b0f6c1
--- /dev/null
+++ b/devtools/client/debugger/src/components/QuickOpenModal.js
@@ -0,0 +1,524 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../utils/connect";
+import fuzzyAldrin from "fuzzaldrin-plus";
+import { basename } from "../utils/path";
+import { createLocation } from "../utils/location";
+
+const { throttle } = require("devtools/shared/throttle");
+
+import actions from "../actions";
+import {
+ getDisplayedSourcesList,
+ getQuickOpenEnabled,
+ getQuickOpenQuery,
+ getQuickOpenType,
+ getSelectedSource,
+ getSelectedLocation,
+ getSettledSourceTextContent,
+ getSymbols,
+ getTabs,
+ getContext,
+ getBlackBoxRanges,
+ getProjectDirectoryRoot,
+} from "../selectors";
+import { memoizeLast } from "../utils/memoizeLast";
+import { scrollList } from "../utils/result-list";
+import { searchKeys } from "../constants";
+import {
+ formatSymbols,
+ parseLineColumn,
+ formatShortcutResults,
+ formatSourceForList,
+} from "../utils/quick-open";
+import Modal from "./shared/Modal";
+import SearchInput from "./shared/SearchInput";
+import ResultList from "./shared/ResultList";
+
+import "./QuickOpenModal.css";
+
+const maxResults = 100;
+
+const SIZE_BIG = { size: "big" };
+const SIZE_DEFAULT = {};
+
+function filter(values, query) {
+ const preparedQuery = fuzzyAldrin.prepareQuery(query);
+
+ return fuzzyAldrin.filter(values, query, {
+ key: "value",
+ 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,
+ cx: PropTypes.object.isRequired,
+ displayedSources: PropTypes.array.isRequired,
+ blackBoxRanges: PropTypes.object.isRequired,
+ enabled: PropTypes.bool.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,
+ selectedSource: PropTypes.object,
+ setQuickOpenQuery: PropTypes.func.isRequired,
+ shortcutsModalEnabled: PropTypes.bool.isRequired,
+ symbols: PropTypes.object.isRequired,
+ symbolsLoading: PropTypes.bool.isRequired,
+ tabUrls: PropTypes.array.isRequired,
+ toggleShortcutsModal: PropTypes.func.isRequired,
+ projectDirectoryRoot: PropTypes.string,
+ };
+ }
+
+ 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 nowEnabled = !prevProps.enabled && this.props.enabled;
+ const queryChanged = prevProps.query !== this.props.query;
+
+ if (this.refs.resultList && this.refs.resultList.refs) {
+ scrollList(this.refs.resultList.refs, this.state.selectedIndex);
+ }
+
+ if (nowEnabled || 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, tabUrls, 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 = tabUrls.includes(source.url);
+ return formatSourceForList(
+ source,
+ hasTabOpened,
+ isBlackBoxed,
+ projectDirectoryRoot
+ );
+ });
+ }
+ );
+
+ searchSources = query => {
+ const { displayedSources, tabUrls, blackBoxRanges, projectDirectoryRoot } =
+ this.props;
+
+ const sources = this.formatSources(
+ displayedSources,
+ tabUrls,
+ blackBoxRanges,
+ projectDirectoryRoot
+ );
+ const results =
+ query == "" ? sources : filter(sources, this.dropGoto(query));
+ return this.setResults(results);
+ };
+
+ searchSymbols = query => {
+ const {
+ symbols: { functions },
+ } = this.props;
+
+ let results = functions;
+ results = results.filter(result => result.title !== "anonymous");
+
+ if (query === "@" || query === "#") {
+ return this.setResults(results);
+ }
+ results = filter(results, query.slice(1));
+ 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 { tabUrls, 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 (tabUrls.length) {
+ displayedSources = displayedSources.filter(
+ source => !!source.url && tabUrls.includes(source.url)
+ );
+ }
+
+ this.setResults(
+ this.formatSources(
+ displayedSources,
+ tabUrls,
+ 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 { selectedSource, highlightLineRange, clearHighlightLineRange } =
+ this.props;
+ if (
+ selectedSource == null ||
+ !this.isSymbolSearch() ||
+ !this.isFunctionQuery()
+ ) {
+ return;
+ }
+
+ if (item.location) {
+ highlightLineRange({
+ start: item.location.start.line,
+ end: item.location.end.line,
+ sourceId: selectedSource.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 { cx, selectSpecificLocation, selectedSource } = this.props;
+
+ if (location != null) {
+ selectSpecificLocation(
+ cx,
+ createLocation({
+ source: location.source || selectedSource,
+ line: location.line,
+ column: location.column,
+ })
+ );
+ this.closeModal();
+ }
+ };
+
+ onChange = e => {
+ const { selectedSource, selectedContentLoaded, setQuickOpenQuery } =
+ this.props;
+ setQuickOpenQuery(e.target.value);
+ const noSource = !selectedSource || !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 { enabled, query } = this.props;
+ const { results, selectedIndex } = this.state;
+ const isGoToQuery = this.isGotoQuery();
+
+ if ((!enabled || !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.props.symbolsLoading) {
+ summaryMsg = L10N.getStr("loadingText");
+ }
+ return summaryMsg;
+ }
+
+ render() {
+ const { enabled, query } = this.props;
+ const { selectedIndex, results } = this.state;
+
+ if (!enabled) {
+ return null;
+ }
+ const items = this.highlightMatching(query, results || []);
+ const expanded = !!items && !!items.length;
+
+ return (
+ <Modal in={enabled} handleClose={this.closeModal}>
+ <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 && (
+ <ResultList
+ key="results"
+ items={items}
+ selected={selectedIndex}
+ selectItem={this.selectResultItem}
+ ref="resultList"
+ expanded={expanded}
+ {...(this.isSourceSearch() ? SIZE_BIG : SIZE_DEFAULT)}
+ />
+ )}
+ </Modal>
+ );
+ }
+}
+
+/* istanbul ignore next: ignoring testing of redux connection stuff */
+function mapStateToProps(state) {
+ const selectedSource = getSelectedSource(state);
+ const location = getSelectedLocation(state);
+ const displayedSources = getDisplayedSourcesList(state);
+ const tabs = getTabs(state);
+ const tabUrls = [...new Set(tabs.map(tab => tab.url))];
+ const symbols = getSymbols(state, location);
+
+ return {
+ cx: getContext(state),
+ enabled: getQuickOpenEnabled(state),
+ displayedSources,
+ blackBoxRanges: getBlackBoxRanges(state),
+ projectDirectoryRoot: getProjectDirectoryRoot(state),
+ selectedSource,
+ selectedContentLoaded: location
+ ? !!getSettledSourceTextContent(state, location)
+ : undefined,
+ symbols: formatSymbols(symbols, maxResults),
+ symbolsLoading: !symbols,
+ query: getQuickOpenQuery(state),
+ searchType: getQuickOpenType(state),
+ tabUrls,
+ };
+}
+
+export default connect(mapStateToProps, {
+ selectSpecificLocation: actions.selectSpecificLocation,
+ setQuickOpenQuery: actions.setQuickOpenQuery,
+ highlightLineRange: actions.highlightLineRange,
+ clearHighlightLineRange: actions.clearHighlightLineRange,
+ closeQuickOpen: actions.closeQuickOpen,
+})(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..368170bed7
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoint.js
@@ -0,0 +1,219 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../../utils/connect";
+import { createSelector } from "reselect";
+import actions from "../../../actions";
+
+import showContextMenu from "./BreakpointsContextMenu";
+import { CloseButton } from "../../shared/Button";
+
+import { getSelectedText, makeBreakpointId } from "../../../utils/breakpoint";
+import { getSelectedLocation } from "../../../utils/selected-location";
+import { isLineBlackboxed } from "../../../utils/source";
+
+import {
+ getBreakpointsList,
+ getSelectedFrame,
+ getSelectedSource,
+ getCurrentThread,
+ getContext,
+ isSourceMapIgnoreListEnabled,
+ isSourceOnSourceMapIgnoreList,
+} from "../../../selectors";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+class Breakpoint extends PureComponent {
+ static get propTypes() {
+ return {
+ breakpoint: PropTypes.object.isRequired,
+ cx: 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,
+ };
+ }
+
+ onContextMenu = e => {
+ showContextMenu({ ...this.props, contextMenuEvent: e });
+ };
+
+ get selectedLocation() {
+ const { breakpoint, selectedSource } = this.props;
+ return getSelectedLocation(breakpoint, selectedSource);
+ }
+
+ 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 { cx, selectSpecificLocation } = this.props;
+ selectSpecificLocation(cx, this.selectedLocation);
+ };
+
+ removeBreakpoint = event => {
+ const { cx, removeBreakpoint, breakpoint } = this.props;
+ event.stopPropagation();
+ removeBreakpoint(cx, breakpoint);
+ };
+
+ handleBreakpointCheckbox = () => {
+ const { cx, breakpoint, enableBreakpoint, disableBreakpoint } = this.props;
+ if (breakpoint.disabled) {
+ enableBreakpoint(cx, breakpoint);
+ } else {
+ disableBreakpoint(cx, 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;
+ const columnVal = column ? `:${column}` : "";
+ 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,
+ blackboxedRangesForSource,
+ checkSourceOnIgnoreList,
+ } = 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={isLineBlackboxed(
+ blackboxedRangesForSource,
+ breakpoint.location.line,
+ checkSourceOnIgnoreList(breakpoint.location.source)
+ )}
+ onChange={this.handleBreakpointCheckbox}
+ onClick={ev => ev.stopPropagation()}
+ 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)} />
+ </span>
+ <div className="breakpoint-line-close">
+ <div className="breakpoint-line devtools-monospace">
+ {this.getBreakpointLocation()}
+ </div>
+ <CloseButton
+ handleClick={e => this.removeBreakpoint(e)}
+ tooltip={L10N.getStr("breakpoints.removeBreakpointTooltip")}
+ />
+ </div>
+ </div>
+ );
+ }
+}
+
+const getFormattedFrame = createSelector(
+ getSelectedSource,
+ getSelectedFrame,
+ (selectedSource, frame) => {
+ if (!frame) {
+ return null;
+ }
+
+ return {
+ ...frame,
+ selectedLocation: getSelectedLocation(frame, selectedSource),
+ };
+ }
+);
+
+const mapStateToProps = (state, p) => ({
+ cx: getContext(state),
+ breakpoints: getBreakpointsList(state),
+ frame: getFormattedFrame(state, getCurrentThread(state)),
+ checkSourceOnIgnoreList: source =>
+ isSourceMapIgnoreListEnabled(state) &&
+ isSourceOnSourceMapIgnoreList(state, source),
+});
+
+export default connect(mapStateToProps, {
+ enableBreakpoint: actions.enableBreakpoint,
+ removeBreakpoint: actions.removeBreakpoint,
+ removeBreakpoints: actions.removeBreakpoints,
+ removeAllBreakpoints: actions.removeAllBreakpoints,
+ disableBreakpoint: actions.disableBreakpoint,
+ selectSpecificLocation: actions.selectSpecificLocation,
+ setBreakpointOptions: actions.setBreakpointOptions,
+ toggleAllBreakpoints: actions.toggleAllBreakpoints,
+ toggleBreakpoints: actions.toggleBreakpoints,
+ toggleDisabledBreakpoint: actions.toggleDisabledBreakpoint,
+ openConditionalPanel: actions.openConditionalPanel,
+})(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..c2c29cc258
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeading.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+
+import { connect } from "../../../utils/connect";
+import actions from "../../../actions";
+
+import {
+ getTruncatedFileName,
+ getDisplayPath,
+ getSourceQueryString,
+ getFileURL,
+} from "../../../utils/source";
+import { createLocation } from "../../../utils/location";
+import {
+ getBreakpointsForSource,
+ getContext,
+ getFirstSourceActorForGeneratedSource,
+} from "../../../selectors";
+
+import SourceIcon from "../../shared/SourceIcon";
+
+import showContextMenu from "./BreakpointHeadingsContextMenu";
+
+class BreakpointHeading extends PureComponent {
+ static get propTypes() {
+ return {
+ cx: PropTypes.object.isRequired,
+ sources: PropTypes.array.isRequired,
+ source: PropTypes.object.isRequired,
+ firstSourceActor: PropTypes.object,
+ selectSource: PropTypes.func.isRequired,
+ };
+ }
+ onContextMenu = e => {
+ showContextMenu({ ...this.props, contextMenuEvent: e });
+ };
+
+ render() {
+ const { cx, 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(cx, source)}
+ onContextMenu={this.onContextMenu}
+ >
+ <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>{`../${path}/..`}</span>}
+ </div>
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = (state, { source }) => ({
+ cx: getContext(state),
+ breakpointsForSource: getBreakpointsForSource(state, source.id),
+ firstSourceActor: getFirstSourceActorForGeneratedSource(state, source.id),
+});
+
+export default connect(mapStateToProps, {
+ selectSource: actions.selectSource,
+ enableBreakpointsInSource: actions.enableBreakpointsInSource,
+ disableBreakpointsInSource: actions.disableBreakpointsInSource,
+ removeBreakpointsInSource: actions.removeBreakpointsInSource,
+})(BreakpointHeading);
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.js
new file mode 100644
index 0000000000..cdd3910b00
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointHeadingsContextMenu.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 { buildMenu, showMenu } from "../../../context-menu/menu";
+
+export default function showContextMenu(props) {
+ const {
+ cx,
+ source,
+ breakpointsForSource,
+ disableBreakpointsInSource,
+ enableBreakpointsInSource,
+ removeBreakpointsInSource,
+ contextMenuEvent,
+ } = props;
+
+ contextMenuEvent.preventDefault();
+
+ const enableInSourceLabel = L10N.getStr(
+ "breakpointHeadingsMenuItem.enableInSource.label"
+ );
+ const disableInSourceLabel = L10N.getStr(
+ "breakpointHeadingsMenuItem.disableInSource.label"
+ );
+ const removeInSourceLabel = L10N.getStr(
+ "breakpointHeadingsMenuItem.removeInSource.label"
+ );
+ const enableInSourceKey = L10N.getStr(
+ "breakpointHeadingsMenuItem.enableInSource.accesskey"
+ );
+ const disableInSourceKey = L10N.getStr(
+ "breakpointHeadingsMenuItem.disableInSource.accesskey"
+ );
+ const removeInSourceKey = L10N.getStr(
+ "breakpointHeadingsMenuItem.removeInSource.accesskey"
+ );
+
+ const disableInSourceItem = {
+ id: "node-menu-disable-in-source",
+ label: disableInSourceLabel,
+ accesskey: disableInSourceKey,
+ disabled: false,
+ click: () => disableBreakpointsInSource(cx, source),
+ };
+
+ const enableInSourceItem = {
+ id: "node-menu-enable-in-source",
+ label: enableInSourceLabel,
+ accesskey: enableInSourceKey,
+ disabled: false,
+ click: () => enableBreakpointsInSource(cx, source),
+ };
+
+ const removeInSourceItem = {
+ id: "node-menu-enable-in-source",
+ label: removeInSourceLabel,
+ accesskey: removeInSourceKey,
+ disabled: false,
+ click: () => removeBreakpointsInSource(cx, source),
+ };
+
+ const hideDisableInSourceItem = breakpointsForSource.every(
+ breakpoint => breakpoint.disabled
+ );
+ const hideEnableInSourceItem = breakpointsForSource.every(
+ breakpoint => !breakpoint.disabled
+ );
+
+ const items = [
+ { item: disableInSourceItem, hidden: () => hideDisableInSourceItem },
+ { item: enableInSourceItem, hidden: () => hideEnableInSourceItem },
+ { item: removeInSourceItem, hidden: () => false },
+ ];
+
+ showMenu(contextMenuEvent, buildMenu(items));
+}
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..98075058b8
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/Breakpoints.css
@@ -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/>. */
+
+.breakpoints-pane > ._content {
+ overflow-x: auto;
+}
+
+.breakpoints-exceptions-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-exceptions,
+.breakpoints-exceptions-caught {
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ padding-top: 2px;
+ padding-bottom: 2px;
+ padding-inline-start: 16px;
+ padding-inline-end: 12px;
+}
+
+.breakpoints-exceptions {
+ padding-bottom: 3px;
+ padding-top: 3px;
+ user-select: none;
+}
+
+.breakpoints-exceptions-caught {
+ padding-bottom: 3px;
+ padding-top: 3px;
+ padding-inline-start: 36px;
+}
+
+.breakpoints-exceptions-options {
+ padding-top: 4px;
+ padding-bottom: 4px;
+}
+
+.xhr-breakpoints-pane .breakpoints-exceptions-options {
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.breakpoints-exceptions-options:not(.empty) {
+ border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.breakpoints-exceptions input,
+.breakpoints-exceptions-caught 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;
+}
+
+html[dir="rtl"] .breakpoints-list .breakpoint,
+html[dir="rtl"] .breakpoints-list .breakpoint-heading,
+html[dir="rtl"] .breakpoints-exceptions {
+ border-right: 4px solid transparent;
+}
+
+html:not([dir="rtl"]) .breakpoints-list .breakpoint,
+html:not([dir="rtl"]) .breakpoints-list .breakpoint-heading,
+html:not([dir="rtl"]) .breakpoints-exceptions {
+ border-left: 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/BreakpointsContextMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js
new file mode 100644
index 0000000000..c2d8f3ff33
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/BreakpointsContextMenu.js
@@ -0,0 +1,365 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { buildMenu, showMenu } from "../../../context-menu/menu";
+import { getSelectedLocation } from "../../../utils/selected-location";
+import { isLineBlackboxed } from "../../../utils/source";
+import { features } from "../../../utils/prefs";
+import { formatKeyShortcut } from "../../../utils/text";
+
+export default function showContextMenu(props) {
+ const {
+ cx,
+ breakpoint,
+ breakpoints,
+ selectedSource,
+ removeBreakpoint,
+ removeBreakpoints,
+ removeAllBreakpoints,
+ toggleBreakpoints,
+ toggleAllBreakpoints,
+ toggleDisabledBreakpoint,
+ selectSpecificLocation,
+ setBreakpointOptions,
+ openConditionalPanel,
+ contextMenuEvent,
+ blackboxedRangesForSource,
+ checkSourceOnIgnoreList,
+ } = props;
+
+ contextMenuEvent.preventDefault();
+
+ const deleteSelfLabel = L10N.getStr("breakpointMenuItem.deleteSelf2.label");
+ const deleteAllLabel = L10N.getStr("breakpointMenuItem.deleteAll2.label");
+ const deleteOthersLabel = L10N.getStr(
+ "breakpointMenuItem.deleteOthers2.label"
+ );
+ const enableSelfLabel = L10N.getStr("breakpointMenuItem.enableSelf2.label");
+ const enableAllLabel = L10N.getStr("breakpointMenuItem.enableAll2.label");
+ const enableOthersLabel = L10N.getStr(
+ "breakpointMenuItem.enableOthers2.label"
+ );
+ const disableSelfLabel = L10N.getStr("breakpointMenuItem.disableSelf2.label");
+ const disableAllLabel = L10N.getStr("breakpointMenuItem.disableAll2.label");
+ const disableOthersLabel = L10N.getStr(
+ "breakpointMenuItem.disableOthers2.label"
+ );
+ const enableDbgStatementLabel = L10N.getStr(
+ "breakpointMenuItem.enabledbg.label"
+ );
+ const disableDbgStatementLabel = L10N.getStr(
+ "breakpointMenuItem.disabledbg.label"
+ );
+ const removeConditionLabel = L10N.getStr(
+ "breakpointMenuItem.removeCondition2.label"
+ );
+ const addConditionLabel = L10N.getStr(
+ "breakpointMenuItem.addCondition2.label"
+ );
+ const editConditionLabel = L10N.getStr(
+ "breakpointMenuItem.editCondition2.label"
+ );
+
+ const deleteSelfKey = L10N.getStr("breakpointMenuItem.deleteSelf2.accesskey");
+ const deleteAllKey = L10N.getStr("breakpointMenuItem.deleteAll2.accesskey");
+ const deleteOthersKey = L10N.getStr(
+ "breakpointMenuItem.deleteOthers2.accesskey"
+ );
+ const enableSelfKey = L10N.getStr("breakpointMenuItem.enableSelf2.accesskey");
+ const enableAllKey = L10N.getStr("breakpointMenuItem.enableAll2.accesskey");
+ const enableOthersKey = L10N.getStr(
+ "breakpointMenuItem.enableOthers2.accesskey"
+ );
+ const disableSelfKey = L10N.getStr(
+ "breakpointMenuItem.disableSelf2.accesskey"
+ );
+ const disableAllKey = L10N.getStr("breakpointMenuItem.disableAll2.accesskey");
+ const disableOthersKey = L10N.getStr(
+ "breakpointMenuItem.disableOthers2.accesskey"
+ );
+ const removeConditionKey = L10N.getStr(
+ "breakpointMenuItem.removeCondition2.accesskey"
+ );
+ const editConditionKey = L10N.getStr(
+ "breakpointMenuItem.editCondition2.accesskey"
+ );
+ const addConditionKey = L10N.getStr(
+ "breakpointMenuItem.addCondition2.accesskey"
+ );
+
+ const selectedLocation = getSelectedLocation(breakpoint, selectedSource);
+ const otherBreakpoints = breakpoints.filter(b => b.id !== breakpoint.id);
+ const enabledBreakpoints = breakpoints.filter(b => !b.disabled);
+ const disabledBreakpoints = breakpoints.filter(b => b.disabled);
+ const otherEnabledBreakpoints = breakpoints.filter(
+ b => !b.disabled && b.id !== breakpoint.id
+ );
+ const otherDisabledBreakpoints = breakpoints.filter(
+ b => b.disabled && b.id !== breakpoint.id
+ );
+
+ const deleteSelfItem = {
+ id: "node-menu-delete-self",
+ label: deleteSelfLabel,
+ accesskey: deleteSelfKey,
+ disabled: false,
+ click: () => {
+ removeBreakpoint(cx, breakpoint);
+ },
+ };
+
+ const deleteAllItem = {
+ id: "node-menu-delete-all",
+ label: deleteAllLabel,
+ accesskey: deleteAllKey,
+ disabled: false,
+ click: () => removeAllBreakpoints(cx),
+ };
+
+ const deleteOthersItem = {
+ id: "node-menu-delete-other",
+ label: deleteOthersLabel,
+ accesskey: deleteOthersKey,
+ disabled: false,
+ click: () => removeBreakpoints(cx, otherBreakpoints),
+ };
+
+ const enableSelfItem = {
+ id: "node-menu-enable-self",
+ label: enableSelfLabel,
+ accesskey: enableSelfKey,
+ disabled: isLineBlackboxed(
+ blackboxedRangesForSource,
+ breakpoint.location.line,
+ checkSourceOnIgnoreList(breakpoint.location.source)
+ ),
+ click: () => {
+ toggleDisabledBreakpoint(cx, breakpoint);
+ },
+ };
+
+ const enableAllItem = {
+ id: "node-menu-enable-all",
+ label: enableAllLabel,
+ accesskey: enableAllKey,
+ disabled: isLineBlackboxed(
+ blackboxedRangesForSource,
+ breakpoint.location.line,
+ checkSourceOnIgnoreList(breakpoint.location.source)
+ ),
+ click: () => toggleAllBreakpoints(cx, false),
+ };
+
+ const enableOthersItem = {
+ id: "node-menu-enable-others",
+ label: enableOthersLabel,
+ accesskey: enableOthersKey,
+ disabled: isLineBlackboxed(
+ blackboxedRangesForSource,
+ breakpoint.location.line,
+ checkSourceOnIgnoreList(breakpoint.location.source)
+ ),
+ click: () => toggleBreakpoints(cx, false, otherDisabledBreakpoints),
+ };
+
+ const disableSelfItem = {
+ id: "node-menu-disable-self",
+ label: disableSelfLabel,
+ accesskey: disableSelfKey,
+ disabled: false,
+ click: () => {
+ toggleDisabledBreakpoint(cx, breakpoint);
+ },
+ };
+
+ const disableAllItem = {
+ id: "node-menu-disable-all",
+ label: disableAllLabel,
+ accesskey: disableAllKey,
+ disabled: false,
+ click: () => toggleAllBreakpoints(cx, true),
+ };
+
+ const disableOthersItem = {
+ id: "node-menu-disable-others",
+ label: disableOthersLabel,
+ accesskey: disableOthersKey,
+ click: () => toggleBreakpoints(cx, true, otherEnabledBreakpoints),
+ };
+
+ const enableDbgStatementItem = {
+ id: "node-menu-enable-dbgStatement",
+ label: enableDbgStatementLabel,
+ disabled: false,
+ click: () =>
+ setBreakpointOptions(cx, selectedLocation, {
+ ...breakpoint.options,
+ condition: null,
+ }),
+ };
+
+ const disableDbgStatementItem = {
+ id: "node-menu-disable-dbgStatement",
+ label: disableDbgStatementLabel,
+ disabled: false,
+ click: () =>
+ setBreakpointOptions(cx, selectedLocation, {
+ ...breakpoint.options,
+ condition: "false",
+ }),
+ };
+
+ const removeConditionItem = {
+ id: "node-menu-remove-condition",
+ label: removeConditionLabel,
+ accesskey: removeConditionKey,
+ disabled: false,
+ click: () =>
+ setBreakpointOptions(cx, selectedLocation, {
+ ...breakpoint.options,
+ condition: null,
+ }),
+ };
+
+ const addConditionItem = {
+ id: "node-menu-add-condition",
+ label: addConditionLabel,
+ accesskey: addConditionKey,
+ click: () => {
+ selectSpecificLocation(cx, selectedLocation);
+ openConditionalPanel(selectedLocation);
+ },
+ accelerator: formatKeyShortcut(
+ L10N.getStr("toggleCondPanel.breakpoint.key")
+ ),
+ };
+
+ const editConditionItem = {
+ id: "node-menu-edit-condition",
+ label: editConditionLabel,
+ accesskey: editConditionKey,
+ click: () => {
+ selectSpecificLocation(cx, selectedLocation);
+ openConditionalPanel(selectedLocation);
+ },
+ accelerator: formatKeyShortcut(
+ L10N.getStr("toggleCondPanel.breakpoint.key")
+ ),
+ };
+
+ const addLogPointItem = {
+ id: "node-menu-add-log-point",
+ label: L10N.getStr("editor.addLogPoint"),
+ accesskey: L10N.getStr("editor.addLogPoint.accesskey"),
+ disabled: false,
+ click: () => {
+ selectSpecificLocation(cx, selectedLocation);
+ openConditionalPanel(selectedLocation, true);
+ },
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")),
+ };
+
+ const editLogPointItem = {
+ id: "node-menu-edit-log-point",
+ label: L10N.getStr("editor.editLogPoint"),
+ accesskey: L10N.getStr("editor.editLogPoint.accesskey"),
+ disabled: false,
+ click: () => {
+ selectSpecificLocation(cx, selectedLocation);
+ openConditionalPanel(selectedLocation, true);
+ },
+ accelerator: formatKeyShortcut(L10N.getStr("toggleCondPanel.logPoint.key")),
+ };
+
+ const removeLogPointItem = {
+ id: "node-menu-remove-log",
+ label: L10N.getStr("editor.removeLogPoint.label"),
+ accesskey: L10N.getStr("editor.removeLogPoint.accesskey"),
+ disabled: false,
+ click: () =>
+ setBreakpointOptions(cx, selectedLocation, {
+ ...breakpoint.options,
+ logValue: null,
+ }),
+ };
+
+ const logPointItem = breakpoint.options.logValue
+ ? editLogPointItem
+ : addLogPointItem;
+
+ const hideEnableSelfItem = !breakpoint.disabled;
+ const hideEnableAllItem = disabledBreakpoints.length === 0;
+ const hideEnableOthersItem = otherDisabledBreakpoints.length === 0;
+ const hideDisableAllItem = enabledBreakpoints.length === 0;
+ const hideDisableOthersItem = otherEnabledBreakpoints.length === 0;
+ const hideDisableSelfItem = breakpoint.disabled;
+ const hideEnableDbgStatementItem =
+ !breakpoint.originalText.startsWith("debugger") ||
+ (breakpoint.originalText.startsWith("debugger") &&
+ breakpoint.options.condition !== "false");
+ const hideDisableDbgStatementItem =
+ !breakpoint.originalText.startsWith("debugger") ||
+ (breakpoint.originalText.startsWith("debugger") &&
+ breakpoint.options.condition === "false");
+ const items = [
+ { item: enableSelfItem, hidden: () => hideEnableSelfItem },
+ { item: enableAllItem, hidden: () => hideEnableAllItem },
+ { item: enableOthersItem, hidden: () => hideEnableOthersItem },
+ {
+ item: { type: "separator" },
+ hidden: () =>
+ hideEnableSelfItem && hideEnableAllItem && hideEnableOthersItem,
+ },
+ { item: deleteSelfItem },
+ { item: deleteAllItem },
+ { item: deleteOthersItem, hidden: () => breakpoints.length === 1 },
+ {
+ item: { type: "separator" },
+ hidden: () =>
+ hideDisableSelfItem && hideDisableAllItem && hideDisableOthersItem,
+ },
+
+ { item: disableSelfItem, hidden: () => hideDisableSelfItem },
+ { item: disableAllItem, hidden: () => hideDisableAllItem },
+ { item: disableOthersItem, hidden: () => hideDisableOthersItem },
+ {
+ item: { type: "separator" },
+ },
+ {
+ item: enableDbgStatementItem,
+ hidden: () => hideEnableDbgStatementItem,
+ },
+ {
+ item: disableDbgStatementItem,
+ hidden: () => hideDisableDbgStatementItem,
+ },
+ {
+ item: { type: "separator" },
+ hidden: () => hideDisableDbgStatementItem && hideEnableDbgStatementItem,
+ },
+ {
+ item: addConditionItem,
+ hidden: () => breakpoint.options.condition,
+ },
+ {
+ item: editConditionItem,
+ hidden: () => !breakpoint.options.condition,
+ },
+ {
+ item: removeConditionItem,
+ hidden: () => !breakpoint.options.condition,
+ },
+ {
+ item: logPointItem,
+ hidden: () => !features.logPoints,
+ },
+ {
+ item: removeLogPointItem,
+ hidden: () => !features.logPoints || !breakpoint.options.logValue,
+ },
+ ];
+
+ showMenu(contextMenuEvent, buildMenu(items));
+ return null;
+}
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..0b7d70fc62
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/ExceptionOption.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 "react";
+import PropTypes from "prop-types";
+
+export default function ExceptionOption({
+ className,
+ isChecked = false,
+ label,
+ onChange,
+}) {
+ return (
+ <div className={className} onClick={onChange}>
+ <input
+ type="checkbox"
+ checked={isChecked ? "checked" : ""}
+ onChange={e => e.stopPropagation() && onChange()}
+ />
+ <div className="breakpoint-exceptions-label">{label}</div>
+ </div>
+ );
+}
+
+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..3a3cc19afa
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/index.js
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../../utils/connect";
+
+import ExceptionOption from "./ExceptionOption";
+
+import Breakpoint from "./Breakpoint";
+import BreakpointHeading from "./BreakpointHeading";
+
+import actions from "../../../actions";
+import { getSelectedLocation } from "../../../utils/selected-location";
+import { createHeadlessEditor } from "../../../utils/editor/create-editor";
+
+import { makeBreakpointId } from "../../../utils/breakpoint";
+
+import {
+ getSelectedSource,
+ getBreakpointSources,
+ getBlackBoxRanges,
+} from "../../../selectors";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./Breakpoints.css";
+
+class Breakpoints extends Component {
+ static get propTypes() {
+ return {
+ breakpointSources: PropTypes.array.isRequired,
+ pauseOnExceptions: PropTypes.func.isRequired,
+ selectedSource: PropTypes.object,
+ shouldPauseOnCaughtExceptions: PropTypes.bool.isRequired,
+ shouldPauseOnExceptions: PropTypes.bool.isRequired,
+ blackboxedRanges: PropTypes.array.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;
+ }
+
+ renderExceptionsOptions() {
+ const {
+ breakpointSources,
+ shouldPauseOnExceptions,
+ shouldPauseOnCaughtExceptions,
+ pauseOnExceptions,
+ } = this.props;
+
+ const isEmpty = !breakpointSources.length;
+
+ return (
+ <div
+ className={classnames("breakpoints-exceptions-options", {
+ empty: isEmpty,
+ })}
+ >
+ <ExceptionOption
+ className="breakpoints-exceptions"
+ label={L10N.getStr("pauseOnExceptionsItem2")}
+ isChecked={shouldPauseOnExceptions}
+ onChange={() => pauseOnExceptions(!shouldPauseOnExceptions, false)}
+ />
+
+ {shouldPauseOnExceptions && (
+ <ExceptionOption
+ className="breakpoints-exceptions-caught"
+ label={L10N.getStr("pauseOnCaughtExceptionsItem")}
+ isChecked={shouldPauseOnCaughtExceptions}
+ onChange={() =>
+ pauseOnExceptions(true, !shouldPauseOnCaughtExceptions)
+ }
+ />
+ )}
+ </div>
+ );
+ }
+
+ renderBreakpoints() {
+ const { breakpointSources, selectedSource, blackboxedRanges } = 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 [
+ <BreakpointHeading
+ key={source.id}
+ source={source}
+ sources={sources}
+ />,
+ breakpoints.map(breakpoint => (
+ <Breakpoint
+ breakpoint={breakpoint}
+ source={source}
+ blackboxedRangesForSource={blackboxedRanges[source.url]}
+ selectedSource={selectedSource}
+ editor={editor}
+ key={makeBreakpointId(
+ getSelectedLocation(breakpoint, selectedSource)
+ )}
+ />
+ )),
+ ];
+ })}
+ </div>
+ );
+ }
+
+ render() {
+ return (
+ <div className="pane">
+ {this.renderExceptionsOptions()}
+ {this.renderBreakpoints()}
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = state => ({
+ breakpointSources: getBreakpointSources(state),
+ selectedSource: getSelectedSource(state),
+ blackboxedRanges: getBlackBoxRanges(state),
+});
+
+export default connect(mapStateToProps, {
+ 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..2b075efdd4
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/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(
+ "Breakpoint.js",
+ "BreakpointHeading.js",
+ "BreakpointHeadingsContextMenu.js",
+ "BreakpointsContextMenu.js",
+ "ExceptionOption.js",
+ "index.js",
+)
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js
new file mode 100644
index 0000000000..a28f9b06d5
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/Breakpoint.spec.js
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import Breakpoint from "../Breakpoint";
+import {
+ createSourceObject,
+ createOriginalSourceObject,
+} from "../../../../utils/test-head";
+
+describe("Breakpoint", () => {
+ it("simple", () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("disabled", () => {
+ const { component } = render({}, makeBreakpoint({ disabled: true }));
+ expect(component).toMatchSnapshot();
+ });
+
+ it("paused at a generatedLocation", () => {
+ const { component } = render({
+ frame: { selectedLocation: generatedLocation },
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("paused at an original location", () => {
+ const source = createSourceObject("foo");
+ const origSource = createOriginalSourceObject(source);
+
+ const { component } = render(
+ {
+ selectedSource: origSource,
+ frame: { selectedLocation: location },
+ },
+ { location, options: {} }
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ it("paused at a different", () => {
+ const { component } = render({
+ frame: { selectedLocation: { ...generatedLocation, line: 14 } },
+ });
+ expect(component).toMatchSnapshot();
+ });
+});
+
+const generatedLocation = { source: { id: "foo" }, line: 53, column: 73 };
+const location = { source: { id: "foo/original" }, line: 5, column: 7 };
+
+function render(overrides = {}, breakpointOverrides = {}) {
+ const props = generateDefaults(overrides, breakpointOverrides);
+ const component = shallow(<Breakpoint.WrappedComponent {...props} />);
+ const defaultState = component.state();
+ const instance = component.instance();
+
+ return { component, props, defaultState, instance };
+}
+
+function makeBreakpoint(overrides = {}) {
+ return {
+ location,
+ generatedLocation,
+ disabled: false,
+ options: {},
+ ...overrides,
+ id: 1,
+ };
+}
+
+function generateDefaults(overrides = {}, breakpointOverrides = {}) {
+ const source = createSourceObject("foo");
+ const breakpoint = makeBreakpoint(breakpointOverrides);
+ const selectedSource = createSourceObject("foo");
+ return {
+ cx: {},
+ disableBreakpoint: () => {},
+ enableBreakpoint: () => {},
+ openConditionalPanel: () => {},
+ removeBreakpoint: () => {},
+ selectSpecificLocation: () => {},
+ blackboxedRangesForSource: [],
+ checkSourceOnIgnoreList: () => {},
+ source,
+ breakpoint,
+ selectedSource,
+ frame: null,
+ editor: {
+ CodeMirror: {
+ runMode: function () {
+ return "";
+ },
+ },
+ },
+ ...overrides,
+ };
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js
new file mode 100644
index 0000000000..87194f762d
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/BreakpointsContextMenu.spec.js
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import BreakpointsContextMenu from "../BreakpointsContextMenu";
+import { buildMenu } from "../../../../context-menu/menu";
+
+import {
+ makeMockBreakpoint,
+ makeMockSource,
+ mockcx,
+} from "../../../../utils/test-mockup";
+
+jest.mock("../../../../context-menu/menu");
+
+function render(disabled = false) {
+ const props = generateDefaults(disabled);
+ const component = shallow(<BreakpointsContextMenu {...props} />);
+ return { component, props };
+}
+
+function generateDefaults(disabled) {
+ const source = makeMockSource(
+ "https://example.com/main.js",
+ "source-https://example.com/main.js"
+ );
+ const breakpoints = [
+ {
+ ...makeMockBreakpoint(source, 1),
+ id: "https://example.com/main.js:1:",
+ disabled,
+ options: {
+ condition: "",
+ logValue: "",
+ hidden: false,
+ },
+ },
+ {
+ ...makeMockBreakpoint(source, 2),
+ id: "https://example.com/main.js:2:",
+ disabled,
+ options: {
+ hidden: false,
+ },
+ },
+ {
+ ...makeMockBreakpoint(source, 3),
+ id: "https://example.com/main.js:3:",
+ disabled,
+ },
+ ];
+
+ const props = {
+ cx: mockcx,
+ breakpoints,
+ breakpoint: breakpoints[0],
+ removeBreakpoint: jest.fn(),
+ removeBreakpoints: jest.fn(),
+ removeAllBreakpoints: jest.fn(),
+ toggleBreakpoints: jest.fn(),
+ toggleAllBreakpoints: jest.fn(),
+ toggleDisabledBreakpoint: jest.fn(),
+ selectSpecificLocation: jest.fn(),
+ setBreakpointCondition: jest.fn(),
+ openConditionalPanel: jest.fn(),
+ contextMenuEvent: { preventDefault: jest.fn() },
+ selectedSource: makeMockSource(),
+ setBreakpointOptions: jest.fn(),
+ checkSourceOnIgnoreList: jest.fn(),
+ };
+ return props;
+}
+
+describe("BreakpointsContextMenu", () => {
+ afterEach(() => {
+ buildMenu.mockReset();
+ });
+
+ describe("context menu actions affecting other breakpoints", () => {
+ it("'remove others' calls removeBreakpoints with proper arguments", () => {
+ const { props } = render();
+ const menuItems = buildMenu.mock.calls[0][0];
+ const deleteOthers = menuItems.find(
+ item => item.item.id === "node-menu-delete-other"
+ );
+ deleteOthers.item.click();
+
+ expect(props.removeBreakpoints).toHaveBeenCalled();
+
+ const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]];
+ expect(props.removeBreakpoints.mock.calls[0][1]).toEqual(
+ otherBreakpoints
+ );
+ });
+
+ it("'enable others' calls toggleBreakpoints with proper arguments", () => {
+ const { props } = render(true);
+ const menuItems = buildMenu.mock.calls[0][0];
+ const enableOthers = menuItems.find(
+ item => item.item.id === "node-menu-enable-others"
+ );
+ enableOthers.item.click();
+
+ expect(props.toggleBreakpoints).toHaveBeenCalled();
+
+ expect(props.toggleBreakpoints.mock.calls[0][1]).toBe(false);
+
+ const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]];
+ expect(props.toggleBreakpoints.mock.calls[0][2]).toEqual(
+ otherBreakpoints
+ );
+ });
+
+ it("'disable others' calls toggleBreakpoints with proper arguments", () => {
+ const { props } = render();
+ const menuItems = buildMenu.mock.calls[0][0];
+ const disableOthers = menuItems.find(
+ item => item.item.id === "node-menu-disable-others"
+ );
+ disableOthers.item.click();
+
+ expect(props.toggleBreakpoints).toHaveBeenCalled();
+ expect(props.toggleBreakpoints.mock.calls[0][1]).toBe(true);
+
+ const otherBreakpoints = [props.breakpoints[1], props.breakpoints[2]];
+ expect(props.toggleBreakpoints.mock.calls[0][2]).toEqual(
+ otherBreakpoints
+ );
+ });
+ });
+});
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..238551cc10
--- /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 "react";
+import { shallow } from "enzyme";
+
+import ExceptionOption from "../ExceptionOption";
+
+describe("ExceptionOption renders", () => {
+ it("with values", () => {
+ const component = shallow(
+ <ExceptionOption
+ label="testLabel"
+ isChecked={true}
+ onChange={() => null}
+ className="testClassName"
+ />
+ );
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap
new file mode 100644
index 0000000000..45f44e42f7
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/Breakpoint.spec.js.snap
@@ -0,0 +1,231 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Breakpoint disabled 1`] = `
+<div
+ className="breakpoint disabled"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ onDoubleClick={[Function]}
+>
+ <input
+ aria-labelledby="1-label"
+ checked={false}
+ className="breakpoint-checkbox"
+ disabled={true}
+ id={1}
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <span
+ className="breakpoint-label cm-s-mozilla devtools-monospace"
+ id="1-label"
+ onClick={[Function]}
+ >
+ <span
+ dangerouslySetInnerHTML={
+ Object {
+ "__html": "",
+ }
+ }
+ />
+ </span>
+ <div
+ className="breakpoint-line-close"
+ >
+ <div
+ className="breakpoint-line devtools-monospace"
+ >
+ 53:73
+ </div>
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove breakpoint"
+ />
+ </div>
+</div>
+`;
+
+exports[`Breakpoint paused at a different 1`] = `
+<div
+ className="breakpoint"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ onDoubleClick={[Function]}
+>
+ <input
+ aria-labelledby="1-label"
+ checked={true}
+ className="breakpoint-checkbox"
+ disabled={true}
+ id={1}
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <span
+ className="breakpoint-label cm-s-mozilla devtools-monospace"
+ id="1-label"
+ onClick={[Function]}
+ >
+ <span
+ dangerouslySetInnerHTML={
+ Object {
+ "__html": "",
+ }
+ }
+ />
+ </span>
+ <div
+ className="breakpoint-line-close"
+ >
+ <div
+ className="breakpoint-line devtools-monospace"
+ >
+ 53:73
+ </div>
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove breakpoint"
+ />
+ </div>
+</div>
+`;
+
+exports[`Breakpoint paused at a generatedLocation 1`] = `
+<div
+ className="breakpoint paused"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ onDoubleClick={[Function]}
+>
+ <input
+ aria-labelledby="1-label"
+ checked={true}
+ className="breakpoint-checkbox"
+ disabled={true}
+ id={1}
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <span
+ className="breakpoint-label cm-s-mozilla devtools-monospace"
+ id="1-label"
+ onClick={[Function]}
+ >
+ <span
+ dangerouslySetInnerHTML={
+ Object {
+ "__html": "",
+ }
+ }
+ />
+ </span>
+ <div
+ className="breakpoint-line-close"
+ >
+ <div
+ className="breakpoint-line devtools-monospace"
+ >
+ 53:73
+ </div>
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove breakpoint"
+ />
+ </div>
+</div>
+`;
+
+exports[`Breakpoint paused at an original location 1`] = `
+<div
+ className="breakpoint paused"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ onDoubleClick={[Function]}
+>
+ <input
+ aria-labelledby="1-label"
+ checked={true}
+ className="breakpoint-checkbox"
+ disabled={true}
+ id={1}
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <span
+ className="breakpoint-label cm-s-mozilla devtools-monospace"
+ id="1-label"
+ onClick={[Function]}
+ >
+ <span
+ dangerouslySetInnerHTML={
+ Object {
+ "__html": "",
+ }
+ }
+ />
+ </span>
+ <div
+ className="breakpoint-line-close"
+ >
+ <div
+ className="breakpoint-line devtools-monospace"
+ >
+ 5:7
+ </div>
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove breakpoint"
+ />
+ </div>
+</div>
+`;
+
+exports[`Breakpoint simple 1`] = `
+<div
+ className="breakpoint"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ onDoubleClick={[Function]}
+>
+ <input
+ aria-labelledby="1-label"
+ checked={true}
+ className="breakpoint-checkbox"
+ disabled={true}
+ id={1}
+ onChange={[Function]}
+ onClick={[Function]}
+ type="checkbox"
+ />
+ <span
+ className="breakpoint-label cm-s-mozilla devtools-monospace"
+ id="1-label"
+ onClick={[Function]}
+ >
+ <span
+ dangerouslySetInnerHTML={
+ Object {
+ "__html": "",
+ }
+ }
+ />
+ </span>
+ <div
+ className="breakpoint-line-close"
+ >
+ <div
+ className="breakpoint-line devtools-monospace"
+ >
+ 53:73
+ </div>
+ <CloseButton
+ handleClick={[Function]}
+ tooltip="Remove breakpoint"
+ />
+ </div>
+</div>
+`;
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..19b5937676
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Breakpoints/tests/__snapshots__/ExceptionOption.spec.js.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ExceptionOption renders with values 1`] = `
+<div
+ className="testClassName"
+ onClick={[Function]}
+>
+ <input
+ checked="checked"
+ onChange={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="breakpoint-exceptions-label"
+ >
+ testLabel
+ </div>
+</div>
+`;
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..a8f4173924
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/CommandBar.js
@@ -0,0 +1,433 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import { connect } from "../../utils/connect";
+import { features, prefs } from "../../utils/prefs";
+import {
+ getIsWaitingOnBreak,
+ getSkipPausing,
+ getCurrentThread,
+ isTopFrameSelected,
+ getThreadContext,
+ getIsCurrentThreadPaused,
+ getIsThreadCurrentlyTracing,
+ getJavascriptTracingLogMethod,
+} from "../../selectors";
+import { formatKeyShortcut } from "../../utils/text";
+import actions from "../../actions";
+import { debugBtn } from "../shared/Button/CommandBarButton";
+import AccessibleImage from "../shared/AccessibleImage";
+import "./CommandBar.css";
+import { showMenu } from "../../context-menu/menu";
+
+const classnames = require("devtools/client/shared/classnames.js");
+const MenuButton = require("devtools/client/shared/components/menu/MenuButton");
+const MenuItem = require("devtools/client/shared/components/menu/MenuItem");
+const MenuList = require("devtools/client/shared/components/menu/MenuList");
+
+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",
+ },
+ Darwin: {
+ resume: "Cmd+\\",
+ stepOver: "Cmd+'",
+ stepIn: "Cmd+;",
+ stepOut: "Cmd+Shift+:",
+ stepOutDisplay: "Cmd+Shift+;",
+ },
+ Linux: {
+ resume: "F8",
+ stepOver: "F10",
+ stepIn: "F11",
+ stepOut: "Shift+F11",
+ },
+};
+
+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);
+ if (isMacOS) {
+ const winKey =
+ getKeyForOS("WINNT", `${action}Display`) || getKeyForOS("WINNT", action);
+ // display both Windows type and Mac specific keys
+ return formatKeyShortcut([key, winKey].join(" "));
+ }
+ return formatKeyShortcut(key);
+}
+
+class CommandBar extends Component {
+ constructor() {
+ super();
+
+ this.state = {};
+ }
+ static get propTypes() {
+ return {
+ breakOnNext: PropTypes.func.isRequired,
+ cx: PropTypes.object.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,
+ 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) {
+ const { cx } = this.props;
+ e.preventDefault();
+ e.stopPropagation();
+ if (action === "resume") {
+ this.props.isPaused ? this.props.resume() : this.props.breakOnNext(cx);
+ } else {
+ this.props[action](cx);
+ }
+ }
+
+ renderStepButtons() {
+ const { isPaused, topFrameSelected } = this.props;
+ const className = isPaused ? "active" : "disabled";
+ const isDisabled = !isPaused;
+
+ return [
+ this.renderTraceButton(),
+ 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;
+ }
+ // Display a button which:
+ // - on left click, would toggle on/off javascript tracing
+ // - on right click, would display a context menu allowing to choose the loggin output (console or stdout)
+ return (
+ <button
+ className={`devtools-button command-bar-button debugger-trace-menu-button ${
+ this.props.isTracingEnabled ? "active" : ""
+ }`}
+ title={
+ this.props.isTracingEnabled
+ ? L10N.getStr("stopTraceButtonTooltip")
+ : L10N.getFormatStr("startTraceButtonTooltip", this.props.logMethod)
+ }
+ onClick={event => {
+ this.props.toggleTracing(this.props.logMethod);
+ }}
+ onContextMenu={event => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ // Avoid showing the menu to avoid having to support chaging 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,
+ click: () => {
+ this.props.setJavascriptTracingLogMethod(LOG_METHODS.CONSOLE);
+ },
+ },
+ {
+ id: "debugger-trace-menu-item-stdout",
+ label: L10N.getStr("traceInStdout"),
+ checked: this.props.logMethod == LOG_METHODS.STDOUT,
+ click: () => {
+ this.props.setJavascriptTracingLogMethod(LOG_METHODS.STDOUT);
+ },
+ },
+ ];
+ showMenu(event, items);
+ }}
+ />
+ );
+ }
+
+ renderPauseButton() {
+ const { cx, 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(cx),
+ "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}
+ >
+ <AccessibleImage
+ className={skipPausing ? "enable-pausing" : "disable-pausing"}
+ />
+ </button>
+ );
+ }
+
+ renderSettingsButton() {
+ const { toolboxDoc } = this.context;
+
+ return (
+ <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()}
+ </MenuButton>
+ );
+ }
+
+ renderSettingsMenuItems() {
+ return (
+ <MenuList id="debugger-settings-menu-list">
+ <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);
+ }}
+ />
+ <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)
+ }
+ />
+ <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)}
+ />
+ <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)
+ }
+ />
+ <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)
+ }
+ />
+ <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(
+ this.props.cx,
+ !prefs.sourceMapIgnoreListEnabled
+ )
+ }
+ />
+ </MenuList>
+ );
+ }
+
+ render() {
+ return (
+ <div
+ className={classnames("command-bar", {
+ vertical: !this.props.horizontal,
+ })}
+ >
+ {this.renderStepButtons()}
+ <div className="filler" />
+ {this.renderSkipPausingButton()}
+ <div className="devtools-separator" />
+ {this.renderSettingsButton()}
+ </div>
+ );
+ }
+}
+
+CommandBar.contextTypes = {
+ shortcuts: PropTypes.object,
+ toolboxDoc: PropTypes.object,
+};
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+ isWaitingOnBreak: getIsWaitingOnBreak(state, getCurrentThread(state)),
+ skipPausing: getSkipPausing(state),
+ topFrameSelected: isTopFrameSelected(state, getCurrentThread(state)),
+ javascriptEnabled: state.ui.javascriptEnabled,
+ isPaused: getIsCurrentThreadPaused(state),
+ isTracingEnabled: getIsThreadCurrentlyTracing(state, getCurrentThread(state)),
+ logMethod: getJavascriptTracingLogMethod(state),
+});
+
+export default connect(mapStateToProps, {
+ toggleTracing: actions.toggleTracing,
+ setJavascriptTracingLogMethod: actions.setJavascriptTracingLogMethod,
+ 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..375dad5563
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/DOMMutationBreakpoints.js
@@ -0,0 +1,175 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { Rep },
+ MODE,
+} = Reps;
+import { translateNodeFrontToGrip } from "inspector-shared-utils";
+
+import {
+ deleteDOMMutationBreakpoint,
+ toggleDOMMutationBreakpointState,
+} from "framework-actions";
+
+import actions from "../../actions";
+import { connect } from "../../utils/connect";
+
+import { CloseButton } from "../shared/Button";
+
+import "./DOMMutationBreakpoints.css";
+
+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>
+ <div className="dom-mutation-type">
+ {localizationTerms[mutationType] || mutationType}
+ </div>
+ </div>
+ <CloseButton
+ handleClick={() => deleteBreakpoint(nodeFront, mutationType)}
+ />
+ </li>
+ );
+ }
+
+ /* 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 }}
+ />
+ </div>
+ );
+ }
+
+ render() {
+ const { breakpoints } = this.props;
+
+ if (breakpoints.length === 0) {
+ return this.renderEmpty();
+ }
+
+ return (
+ <ul className="dom-mutation-list">
+ {breakpoints.map(breakpoint => this.renderItem(breakpoint))}
+ </ul>
+ );
+ }
+}
+
+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 (
+ <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..2ca0670367
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.css
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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;
+}
+
+.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;
+ outline: 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;
+}
+
+: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..8b7c9975b0
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/EventListeners.js
@@ -0,0 +1,295 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import { connect } from "../../utils/connect";
+import actions from "../../actions";
+import {
+ getActiveEventListeners,
+ getEventListenerBreakpointTypes,
+ getEventListenerExpanded,
+} from "../../selectors";
+
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./EventListeners.css";
+
+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}
+ />
+ </form>
+ );
+ }
+
+ 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)}
+ </li>
+ );
+ })}
+ </ul>
+ );
+ }
+
+ 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);
+ });
+ })}
+ </ul>
+ );
+ }
+
+ 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)}
+ >
+ <AccessibleImage className={classnames("arrow", { expanded })} />
+ </button>
+ <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}</span>
+ </label>
+ </div>
+ );
+ }
+
+ renderCategoryListing(category) {
+ const { expandedCategories } = this.props;
+
+ const expanded = expandedCategories.includes(category.name);
+ if (!expanded) {
+ return null;
+ }
+
+ return (
+ <ul>
+ {category.events.map(event => {
+ return this.renderListenerEvent(event, category.name);
+ })}
+ </ul>
+ );
+ }
+
+ renderCategory(category) {
+ return <span className="category-label">{category} ▸ </span>;
+ }
+
+ 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}
+ </span>
+ </label>
+ </li>
+ );
+ }
+
+ render() {
+ const { searchText } = this.state;
+
+ return (
+ <div className="event-listeners">
+ <div className="event-search-container">
+ {this.renderSearchInput()}
+ {this.renderClearSearchButton()}
+ </div>
+ <div className="event-listeners-content">
+ {searchText
+ ? this.renderSearchResultsList()
+ : this.renderCategoriesList()}
+ </div>
+ </div>
+ );
+ }
+}
+
+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..c4291c80ff
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.css
@@ -0,0 +1,175 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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-input-container.error {
+ border: 1px solid red;
+}
+
+.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..308e6d4de5
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Expressions.js
@@ -0,0 +1,395 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import { features } from "../../utils/prefs";
+
+import { objectInspector } from "devtools/client/shared/components/reps/index";
+
+import actions from "../../actions";
+import {
+ getExpressions,
+ getExpressionError,
+ getAutocompleteMatchset,
+ getThreadContext,
+} from "../../selectors";
+import { getExpressionResultGripAndFront } from "../../utils/expressions";
+
+import { CloseButton } from "../shared/Button";
+
+import "./Expressions.css";
+
+const { debounce } = require("devtools/shared/debounce");
+const classnames = require("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,
+ clearExpressionError: PropTypes.func.isRequired,
+ cx: PropTypes.object.isRequired,
+ deleteExpression: PropTypes.func.isRequired,
+ expressionError: PropTypes.bool.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,
+ };
+ }
+
+ 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(() => {
+ this.props.clearExpressionError();
+ return { editing: false, editIndex: -1, inputValue: "", focused: false };
+ });
+ };
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ if (this.state.editing && !nextProps.expressionError) {
+ 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, expressionError, showInput, autocompleteMatches } =
+ this.props;
+
+ return (
+ autocompleteMatches !== nextProps.autocompleteMatches ||
+ expressions !== nextProps.expressions ||
+ expressionError !== nextProps.expressionError ||
+ 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(this.props.cx, value, selectionStart);
+ }, 250);
+
+ handleKeyDown = e => {
+ if (e.key === "Escape") {
+ this.clear();
+ }
+ };
+
+ hideInput = () => {
+ this.setState({ focused: false });
+ this.props.onExpressionAdded();
+ this.props.clearExpressionError();
+ };
+
+ 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.props.cx,
+ this.state.inputValue,
+ expression
+ );
+ };
+
+ handleNewSubmit = async e => {
+ const { inputValue } = this.state;
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.props.clearExpressionError();
+ await this.props.addExpression(this.props.cx, this.state.inputValue);
+ this.setState({
+ editing: false,
+ editIndex: -1,
+ inputValue: this.props.expressionError ? inputValue : "",
+ });
+
+ this.props.clearAutocomplete();
+ };
+
+ renderExpression = (expression, index) => {
+ const {
+ expressionError,
+ openLink,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ } = this.props;
+
+ const { editing, editIndex } = this.state;
+ const { input, updating } = expression;
+ const isEditingExpr = editing && editIndex === index;
+ if (isEditingExpr || (isEditingExpr && expressionError)) {
+ 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">
+ <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">
+ <CloseButton
+ handleClick={e => this.deleteExpression(e, expression)}
+ tooltip={L10N.getStr("expressions.remove.tooltip")}
+ />
+ </div>
+ </div>
+ </li>
+ );
+ };
+
+ renderExpressions() {
+ const { expressions, showInput } = this.props;
+
+ return (
+ <>
+ <ul className="pane expressions-list">
+ {expressions.map(this.renderExpression)}
+ </ul>
+ {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} />;
+ })}
+ </datalist>
+ );
+ }
+ return <datalist id="autocomplete-matches" />;
+ }
+
+ renderNewExpressionInput() {
+ const { expressionError } = this.props;
+ const { editing, inputValue, focused } = this.state;
+ const error = editing === false && expressionError === true;
+ const placeholder = error
+ ? L10N.getStr("expressions.errorMsg")
+ : L10N.getStr("expressions.placeholder");
+
+ return (
+ <form
+ className={classnames(
+ "expression-input-container expression-input-form",
+ { focused, error }
+ )}
+ onSubmit={this.handleNewSubmit}
+ >
+ <input
+ className="input-expression"
+ type="text"
+ placeholder={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" }} />
+ </form>
+ );
+ }
+
+ renderExpressionEditInput(expression) {
+ const { expressionError } = this.props;
+ const { inputValue, editing, focused } = this.state;
+ const error = editing === true && expressionError === true;
+
+ return (
+ <form
+ key={expression.input}
+ className={classnames(
+ "expression-input-container expression-input-form",
+ { focused, error }
+ )}
+ onSubmit={e => this.handleExistingSubmit(e, expression)}
+ >
+ <input
+ className={classnames("input-expression", { error })}
+ 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" }} />
+ </form>
+ );
+ }
+
+ render() {
+ const { expressions } = this.props;
+
+ if (expressions.length === 0) {
+ return this.renderNewExpressionInput();
+ }
+
+ return this.renderExpressions();
+ }
+}
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+ autocompleteMatches: getAutocompleteMatchset(state),
+ expressions: getExpressions(state),
+ expressionError: getExpressionError(state),
+});
+
+export default connect(mapStateToProps, {
+ autocomplete: actions.autocomplete,
+ clearAutocomplete: actions.clearAutocomplete,
+ addExpression: actions.addExpression,
+ clearExpressionError: actions.clearExpressionError,
+ 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..4ea94df95d
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Frame.js
@@ -0,0 +1,197 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "react";
+import PropTypes from "prop-types";
+
+import AccessibleImage from "../../shared/AccessibleImage";
+import { formatDisplayName } from "../../../utils/pause/frames";
+import { getFilename, getFileURL } from "../../../utils/source";
+import FrameMenu from "./FrameMenu";
+import FrameIndent from "./FrameIndent";
+const classnames = require("devtools/client/shared/classnames.js");
+
+function FrameTitle({ frame, options = {}, l10n }) {
+ const displayName = formatDisplayName(frame, options, l10n);
+ return <span className="title">{displayName}</span>;
+}
+
+FrameTitle.propTypes = {
+ frame: PropTypes.object.isRequired,
+ options: PropTypes.object.isRequired,
+ l10n: PropTypes.object.isRequired,
+};
+
+const FrameLocation = memo(({ frame, displayFullUrl = false }) => {
+ if (!frame.source) {
+ return null;
+ }
+
+ if (frame.library) {
+ return (
+ <span className="location">
+ {frame.library}
+ <AccessibleImage
+ className={`annotation-logo ${frame.library.toLowerCase()}`}
+ />
+ </span>
+ );
+ }
+
+ const { location, source } = frame;
+ const filename = displayFullUrl
+ ? getFileURL(source, false)
+ : getFilename(source);
+
+ return (
+ <span className="location" title={source.url}>
+ <span className="filename">{filename}</span>:
+ <span className="line">{location.line}</span>
+ </span>
+ );
+});
+
+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 {
+ copyStackTrace: PropTypes.func.isRequired,
+ cx: PropTypes.object,
+ disableContextMenu: PropTypes.bool.isRequired,
+ displayFullUrl: PropTypes.bool.isRequired,
+ frame: PropTypes.object.isRequired,
+ frameworkGroupingOn: PropTypes.bool.isRequired,
+ getFrameTitle: PropTypes.func,
+ hideLocation: PropTypes.bool.isRequired,
+ panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired,
+ restart: PropTypes.func,
+ selectFrame: PropTypes.func.isRequired,
+ selectedFrame: PropTypes.object,
+ shouldMapDisplayName: PropTypes.bool.isRequired,
+ toggleBlackBox: PropTypes.func,
+ toggleFrameworkGrouping: PropTypes.func.isRequired,
+ };
+ }
+
+ get isSelectable() {
+ return this.props.panel == "webconsole";
+ }
+
+ get isDebugger() {
+ return this.props.panel == "debugger";
+ }
+
+ onContextMenu(event) {
+ const {
+ frame,
+ copyStackTrace,
+ toggleFrameworkGrouping,
+ toggleBlackBox,
+ frameworkGroupingOn,
+ cx,
+ restart,
+ } = this.props;
+ FrameMenu(
+ frame,
+ frameworkGroupingOn,
+ { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox, restart },
+ event,
+ cx
+ );
+ }
+
+ onMouseDown(e, frame, selectedFrame) {
+ if (e.button !== 0) {
+ return;
+ }
+
+ this.props.selectFrame(this.props.cx, frame);
+ }
+
+ onKeyUp(event, frame, selectedFrame) {
+ if (event.key != "Enter") {
+ return;
+ }
+
+ this.props.selectFrame(this.props.cx, frame);
+ }
+
+ render() {
+ const {
+ frame,
+ selectedFrame,
+ hideLocation,
+ shouldMapDisplayName,
+ displayFullUrl,
+ getFrameTitle,
+ disableContextMenu,
+ } = this.props;
+ const { l10n } = this.context;
+
+ const className = classnames("frame", {
+ selected: selectedFrame && selectedFrame.id === frame.id,
+ });
+
+ if (!frame.source) {
+ throw new Error("no frame source");
+ }
+
+ const title = getFrameTitle
+ ? getFrameTitle(
+ `${getFileURL(frame.source, false)}:${frame.location.line}`
+ )
+ : undefined;
+
+ return (
+ <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 && (
+ <span className="location-async-cause">
+ {this.isSelectable && <FrameIndent />}
+ {this.isDebugger ? (
+ <span className="async-label">{frame.asyncCause}</span>
+ ) : (
+ l10n.getFormatStr("stacktrace.asyncStack", frame.asyncCause)
+ )}
+ {this.isSelectable && <br className="clipboard-only" />}
+ </span>
+ )}
+ {this.isSelectable && <FrameIndent />}
+ <FrameTitle
+ frame={frame}
+ options={{ shouldMapDisplayName }}
+ l10n={l10n}
+ />
+ {!hideLocation && <span className="clipboard-only"> </span>}
+ {!hideLocation && (
+ <FrameLocation frame={frame} displayFullUrl={displayFullUrl} />
+ )}
+ {this.isSelectable && <br className="clipboard-only" />}
+ </div>
+ );
+ }
+}
+
+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..55eb5da08a
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameIndent.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+
+export default function FrameIndent() {
+ return (
+ <span className="frame-indent clipboard-only">
+ &nbsp;&nbsp;&nbsp;&nbsp;
+ </span>
+ );
+}
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js
new file mode 100644
index 0000000000..a92db936ba
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/FrameMenu.js
@@ -0,0 +1,105 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { showMenu } from "../../../context-menu/menu";
+import { copyToTheClipboard } from "../../../utils/clipboard";
+
+const blackboxString = "ignoreContextItem.ignore";
+const unblackboxString = "ignoreContextItem.unignore";
+
+function formatMenuElement(labelString, click, disabled = false) {
+ const label = L10N.getStr(labelString);
+ const accesskey = L10N.getStr(`${labelString}.accesskey`);
+ const id = `node-menu-${labelString}`;
+ return {
+ id,
+ label,
+ accesskey,
+ disabled,
+ click,
+ };
+}
+
+function copySourceElement(url) {
+ return formatMenuElement("copySourceUri2", () => copyToTheClipboard(url));
+}
+
+function copyStackTraceElement(copyStackTrace) {
+ return formatMenuElement("copyStackTrace", () => copyStackTrace());
+}
+
+function toggleFrameworkGroupingElement(
+ toggleFrameworkGrouping,
+ frameworkGroupingOn
+) {
+ const actionType = frameworkGroupingOn
+ ? "framework.disableGrouping"
+ : "framework.enableGrouping";
+
+ return formatMenuElement(actionType, () => toggleFrameworkGrouping());
+}
+
+function blackBoxSource(cx, source, toggleBlackBox) {
+ const toggleBlackBoxString = source.isBlackBoxed
+ ? unblackboxString
+ : blackboxString;
+
+ return formatMenuElement(toggleBlackBoxString, () =>
+ toggleBlackBox(cx, source)
+ );
+}
+
+function restartFrame(cx, frame, restart) {
+ return formatMenuElement("restartFrame", () => restart(cx, frame));
+}
+
+function isValidRestartFrame(frame, callbacks) {
+ // Hides 'Restart Frame' item for call stack groups context menu,
+ // otherwise can be misleading for the user which frame gets restarted.
+ if (!callbacks.restart) {
+ return false;
+ }
+
+ // Any frame state than 'on-stack' is either dismissed by the server
+ // or can potentially cause unexpected errors.
+ // Global frame has frame.callee equal to null and can't be restarted.
+ return frame.type === "call" && frame.state === "on-stack";
+}
+
+export default function FrameMenu(
+ frame,
+ frameworkGroupingOn,
+ callbacks,
+ event,
+ cx
+) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const menuOptions = [];
+
+ if (isValidRestartFrame(frame, callbacks)) {
+ const restartFrameItem = restartFrame(cx, frame, callbacks.restart);
+ menuOptions.push(restartFrameItem);
+ }
+
+ const toggleFrameworkElement = toggleFrameworkGroupingElement(
+ callbacks.toggleFrameworkGrouping,
+ frameworkGroupingOn
+ );
+ menuOptions.push(toggleFrameworkElement);
+
+ const { source } = frame;
+ if (source) {
+ const copySourceUri2 = copySourceElement(source.url);
+ menuOptions.push(copySourceUri2);
+ menuOptions.push(blackBoxSource(cx, source, callbacks.toggleBlackBox));
+ }
+
+ const copyStackTraceItem = copyStackTraceElement(callbacks.copyStackTrace);
+
+ menuOptions.push(copyStackTraceItem);
+
+ showMenu(event, menuOptions);
+}
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..162c89a2a6
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/Group.js
@@ -0,0 +1,197 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import { getLibraryFromUrl } from "../../../utils/pause/frames";
+
+import FrameMenu from "./FrameMenu";
+import AccessibleImage from "../../shared/AccessibleImage";
+import FrameComponent from "./Frame";
+
+import "./Group.css";
+
+import Badge from "../../shared/Badge";
+import FrameIndent from "./FrameIndent";
+
+const classnames = require("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 (
+ <span className="group-description">
+ <AccessibleImage className={arrowClassName} />
+ <AccessibleImage className={`annotation-logo ${library.toLowerCase()}`} />
+ <span className="group-description-name">{library}</span>
+ </span>
+ );
+}
+
+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 {
+ copyStackTrace: PropTypes.func.isRequired,
+ cx: PropTypes.object,
+ disableContextMenu: PropTypes.bool.isRequired,
+ displayFullUrl: PropTypes.bool.isRequired,
+ frameworkGroupingOn: PropTypes.bool.isRequired,
+ getFrameTitle: PropTypes.func,
+ group: PropTypes.array.isRequired,
+ panel: PropTypes.oneOf(["debugger", "webconsole"]).isRequired,
+ restart: PropTypes.func,
+ selectFrame: PropTypes.func.isRequired,
+ selectLocation: PropTypes.func,
+ selectedFrame: PropTypes.object,
+ toggleBlackBox: PropTypes.func,
+ toggleFrameworkGrouping: PropTypes.func.isRequired,
+ };
+ }
+
+ get isSelectable() {
+ return this.props.panel == "webconsole";
+ }
+
+ onContextMenu(event) {
+ const {
+ group,
+ copyStackTrace,
+ toggleFrameworkGrouping,
+ toggleBlackBox,
+ frameworkGroupingOn,
+ cx,
+ } = this.props;
+ const frame = group[0];
+ FrameMenu(
+ frame,
+ frameworkGroupingOn,
+ { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox },
+ event,
+ cx
+ );
+ }
+
+ toggleFrames = event => {
+ event.stopPropagation();
+ this.setState(prevState => ({ expanded: !prevState.expanded }));
+ };
+
+ renderFrames() {
+ const {
+ cx,
+ group,
+ selectFrame,
+ selectLocation,
+ selectedFrame,
+ toggleFrameworkGrouping,
+ frameworkGroupingOn,
+ toggleBlackBox,
+ copyStackTrace,
+ displayFullUrl,
+ getFrameTitle,
+ disableContextMenu,
+ panel,
+ restart,
+ } = this.props;
+
+ const { expanded } = this.state;
+ if (!expanded) {
+ return null;
+ }
+
+ return (
+ <div className="frames-list">
+ {group.reduce((acc, frame, i) => {
+ if (this.isSelectable) {
+ acc.push(<FrameIndent key={`frame-indent-${i}`} />);
+ }
+ return acc.concat(
+ <FrameComponent
+ cx={cx}
+ copyStackTrace={copyStackTrace}
+ frame={frame}
+ frameworkGroupingOn={frameworkGroupingOn}
+ hideLocation={true}
+ key={frame.id}
+ selectedFrame={selectedFrame}
+ selectFrame={selectFrame}
+ selectLocation={selectLocation}
+ shouldMapDisplayName={false}
+ toggleBlackBox={toggleBlackBox}
+ toggleFrameworkGrouping={toggleFrameworkGrouping}
+ displayFullUrl={displayFullUrl}
+ getFrameTitle={getFrameTitle}
+ disableContextMenu={disableContextMenu}
+ panel={panel}
+ restart={restart}
+ />
+ );
+ }, [])}
+ </div>
+ );
+ }
+
+ 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 (
+ <div
+ role="listitem"
+ key={frame.id}
+ className="group"
+ onClick={this.toggleFrames}
+ tabIndex={0}
+ title={title}
+ >
+ {this.isSelectable && <FrameIndent />}
+ <FrameLocation frame={frame} expanded={expanded} />
+ {this.isSelectable && <span className="clipboard-only"> </span>}
+ <Badge>{this.props.group.length}</Badge>
+ {this.isSelectable && <br className="clipboard-only" />}
+ </div>
+ );
+ }
+
+ render() {
+ const { expanded } = this.state;
+ const { disableContextMenu } = this.props;
+ return (
+ <div
+ className={classnames("frames-group", { expanded })}
+ onContextMenu={disableContextMenu ? null : e => this.onContextMenu(e)}
+ >
+ {this.renderDescription()}
+ {this.renderFrames()}
+ </div>
+ );
+ }
+}
+
+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..5c48af8cb3
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/index.js
@@ -0,0 +1,231 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import { connect } from "../../../utils/connect";
+import PropTypes from "prop-types";
+
+import FrameComponent from "./Frame";
+import Group from "./Group";
+
+import actions from "../../../actions";
+import { collapseFrames, formatCopyName } from "../../../utils/pause/frames";
+import { copyToTheClipboard } from "../../../utils/clipboard";
+
+import {
+ getFrameworkGroupingState,
+ getSelectedFrame,
+ getCallStackFrames,
+ getCurrentThread,
+ getThreadContext,
+} from "../../../selectors";
+
+import "./Frames.css";
+
+const NUM_FRAMES_SHOWN = 7;
+
+class Frames extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ showAllFrames: !!props.disableFrameTruncate,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ cx: PropTypes.object,
+ 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,
+ restart: PropTypes.func,
+ selectFrame: PropTypes.func.isRequired,
+ selectLocation: PropTypes.func,
+ selectedFrame: PropTypes.object,
+ toggleBlackBox: PropTypes.func,
+ toggleFrameworkGrouping: PropTypes.func,
+ };
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ const { frames, selectedFrame, frameworkGroupingOn } = this.props;
+ const { showAllFrames } = this.state;
+ return (
+ frames !== nextProps.frames ||
+ selectedFrame !== nextProps.selectedFrame ||
+ showAllFrames !== nextState.showAllFrames ||
+ frameworkGroupingOn !== nextProps.frameworkGroupingOn
+ );
+ }
+
+ 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);
+ }
+
+ copyStackTrace = () => {
+ const { frames } = this.props;
+ const { l10n } = this.context;
+ const framesToCopy = frames.map(f => formatCopyName(f, l10n)).join("\n");
+ copyToTheClipboard(framesToCopy);
+ };
+
+ toggleFrameworkGrouping = () => {
+ const { toggleFrameworkGrouping, frameworkGroupingOn } = this.props;
+ toggleFrameworkGrouping(!frameworkGroupingOn);
+ };
+
+ renderFrames(frames) {
+ const {
+ cx,
+ selectFrame,
+ selectLocation,
+ selectedFrame,
+ toggleBlackBox,
+ frameworkGroupingOn,
+ displayFullUrl,
+ getFrameTitle,
+ disableContextMenu,
+ panel,
+ restart,
+ } = 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 (
+ <div role="list">
+ {framesOrGroups.map(frameOrGroup =>
+ frameOrGroup.id ? (
+ <FrameComponent
+ cx={cx}
+ frame={frameOrGroup}
+ toggleFrameworkGrouping={this.toggleFrameworkGrouping}
+ copyStackTrace={this.copyStackTrace}
+ frameworkGroupingOn={frameworkGroupingOn}
+ selectFrame={selectFrame}
+ selectLocation={selectLocation}
+ selectedFrame={selectedFrame}
+ toggleBlackBox={toggleBlackBox}
+ key={String(frameOrGroup.id)}
+ displayFullUrl={displayFullUrl}
+ getFrameTitle={getFrameTitle}
+ disableContextMenu={disableContextMenu}
+ panel={panel}
+ restart={restart}
+ />
+ ) : (
+ <Group
+ cx={cx}
+ group={frameOrGroup}
+ toggleFrameworkGrouping={this.toggleFrameworkGrouping}
+ copyStackTrace={this.copyStackTrace}
+ frameworkGroupingOn={frameworkGroupingOn}
+ selectFrame={selectFrame}
+ selectLocation={selectLocation}
+ selectedFrame={selectedFrame}
+ toggleBlackBox={toggleBlackBox}
+ key={frameOrGroup[0].id}
+ displayFullUrl={displayFullUrl}
+ getFrameTitle={getFrameTitle}
+ disableContextMenu={disableContextMenu}
+ panel={panel}
+ restart={restart}
+ />
+ )
+ )}
+ </div>
+ );
+ }
+
+ 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 (
+ <div className="show-more-container">
+ <button className="show-more" onClick={this.toggleFramesDisplay}>
+ {buttonMessage}
+ </button>
+ </div>
+ );
+ }
+
+ render() {
+ const { frames, disableFrameTruncate } = this.props;
+
+ if (!frames) {
+ return (
+ <div className="pane frames">
+ <div className="pane-info empty">
+ {L10N.getStr("callStack.notPaused")}
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="pane frames">
+ {this.renderFrames(frames)}
+ {disableFrameTruncate ? null : this.renderToggleButton(frames)}
+ </div>
+ );
+ }
+}
+
+Frames.contextTypes = { l10n: PropTypes.object };
+
+const mapStateToProps = state => ({
+ cx: getThreadContext(state),
+ frames: getCallStackFrames(state),
+ frameworkGroupingOn: getFrameworkGroupingState(state),
+ selectedFrame: getSelectedFrame(state, getCurrentThread(state)),
+ disableFrameTruncate: false,
+ disableContextMenu: false,
+ displayFullUrl: false,
+});
+
+export default connect(mapStateToProps, {
+ selectFrame: actions.selectFrame,
+ selectLocation: actions.selectLocation,
+ toggleBlackBox: actions.toggleBlackBox,
+ toggleFrameworkGrouping: actions.toggleFrameworkGrouping,
+ restart: actions.restart,
+})(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..f775363b14
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/moz.build
@@ -0,0 +1,14 @@
+# 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",
+ "FrameMenu.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..10ec961858
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frame.spec.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 from "react";
+import { shallow, mount } from "enzyme";
+import Frame from "../Frame.js";
+import {
+ makeMockFrame,
+ makeMockSource,
+ mockthreadcx,
+} from "../../../../utils/test-mockup";
+
+import FrameMenu from "../FrameMenu";
+jest.mock("../FrameMenu", () => jest.fn());
+
+function frameProperties(frame, selectedFrame, overrides = {}) {
+ return {
+ cx: mockthreadcx,
+ 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(<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(<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(<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(<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(<Frame {...props} />);
+ expect(component.prop("title")).toBe(`Jump to ${url}:10`);
+ expect(component).toMatchSnapshot();
+ });
+
+ describe("mouse events", () => {
+ it("does not call FrameMenu when disableContextMenu is true", () => {
+ const { component } = render(undefined, undefined, {
+ disableContextMenu: true,
+ });
+
+ const mockEvent = "mockEvent";
+ component.simulate("contextmenu", mockEvent);
+
+ expect(FrameMenu).toHaveBeenCalledTimes(0);
+ });
+
+ it("calls FrameMenu on right click", () => {
+ const { component, props } = render();
+ const {
+ copyStackTrace,
+ toggleFrameworkGrouping,
+ toggleBlackBox,
+ cx,
+ restart,
+ } = props;
+ const mockEvent = "mockEvent";
+ component.simulate("contextmenu", mockEvent);
+
+ expect(FrameMenu).toHaveBeenCalledWith(
+ props.frame,
+ props.frameworkGroupingOn,
+ {
+ copyStackTrace,
+ toggleFrameworkGrouping,
+ toggleBlackBox,
+ restart,
+ },
+ mockEvent,
+ cx
+ );
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js
new file mode 100644
index 0000000000..dbaa98f5cf
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/FrameMenu.spec.js
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 FrameMenu from "../FrameMenu";
+
+import { showMenu } from "../../../../context-menu/menu";
+import { copyToTheClipboard } from "../../../../utils/clipboard";
+import {
+ makeMockFrame,
+ makeMockSource,
+ mockthreadcx,
+} from "../../../../utils/test-mockup";
+
+jest.mock("../../../../context-menu/menu", () => ({ showMenu: jest.fn() }));
+jest.mock("../../../../utils/clipboard", () => ({
+ copyToTheClipboard: jest.fn(),
+}));
+
+function generateMockId(labelString) {
+ return `node-menu-${labelString}`;
+}
+
+describe("FrameMenu", () => {
+ let mockEvent;
+ let mockFrame;
+ let emptyFrame;
+ let callbacks;
+ let frameworkGroupingOn;
+ let toggleFrameworkGrouping;
+
+ beforeEach(() => {
+ mockFrame = makeMockFrame(undefined, makeMockSource("isFake"));
+ mockEvent = {
+ stopPropagation: jest.fn(),
+ preventDefault: jest.fn(),
+ };
+ callbacks = {
+ toggleFrameworkGrouping,
+ toggleBlackbox: jest.fn(),
+ copyToTheClipboard,
+ restart: jest.fn(),
+ };
+ emptyFrame = {};
+ });
+
+ afterEach(() => {
+ showMenu.mockClear();
+ });
+
+ it("sends three element in menuOpts to showMenu if source is present", () => {
+ const restartFrameId = generateMockId("restartFrame");
+ const sourceId = generateMockId("copySourceUri2");
+ const stacktraceId = generateMockId("copyStackTrace");
+ const frameworkGroupingId = generateMockId("framework.enableGrouping");
+ const blackBoxId = generateMockId("ignoreContextItem.ignore");
+
+ FrameMenu(
+ mockFrame,
+ frameworkGroupingOn,
+ callbacks,
+ mockEvent,
+ mockthreadcx
+ );
+
+ const receivedArray = showMenu.mock.calls[0][1];
+ expect(showMenu).toHaveBeenCalledWith(mockEvent, receivedArray);
+ const receivedArrayIds = receivedArray.map(item => item.id);
+ expect(receivedArrayIds).toEqual([
+ restartFrameId,
+ frameworkGroupingId,
+ sourceId,
+ blackBoxId,
+ stacktraceId,
+ ]);
+ });
+
+ it("sends one element in menuOpts without source", () => {
+ const stacktraceId = generateMockId("copyStackTrace");
+ const frameworkGrouping = generateMockId("framework.enableGrouping");
+
+ FrameMenu(
+ emptyFrame,
+ frameworkGroupingOn,
+ callbacks,
+ mockEvent,
+ mockthreadcx
+ );
+
+ const receivedArray = showMenu.mock.calls[0][1];
+ expect(showMenu).toHaveBeenCalledWith(mockEvent, receivedArray);
+ const receivedArrayIds = receivedArray.map(item => item.id);
+ expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]);
+ });
+
+ it("uses the disableGrouping text if frameworkGroupingOn is false", () => {
+ const stacktraceId = generateMockId("copyStackTrace");
+ const frameworkGrouping = generateMockId("framework.disableGrouping");
+
+ FrameMenu(emptyFrame, true, callbacks, mockEvent, mockthreadcx);
+
+ const receivedArray = showMenu.mock.calls[0][1];
+ const receivedArrayIds = receivedArray.map(item => item.id);
+ expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]);
+ });
+
+ it("uses the enableGrouping text if frameworkGroupingOn is true", () => {
+ const stacktraceId = generateMockId("copyStackTrace");
+ const frameworkGrouping = generateMockId("framework.enableGrouping");
+
+ FrameMenu(emptyFrame, false, callbacks, mockEvent, mockthreadcx);
+
+ const receivedArray = showMenu.mock.calls[0][1];
+ const receivedArrayIds = receivedArray.map(item => item.id);
+ expect(receivedArrayIds).toEqual([frameworkGrouping, stacktraceId]);
+ });
+});
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..da60418b07
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Frames.spec.js
@@ -0,0 +1,295 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { mount, shallow } from "enzyme";
+import Frames from "../index.js";
+// eslint-disable-next-line
+import { formatCallStackFrames } from "../../../../selectors/getCallStackFrames";
+import { makeMockFrame, makeMockSource } from "../../../../utils/test-mockup";
+
+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(<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: {
+ line: 55,
+ },
+ source: {
+ url: "http://myfile.com/mahscripts.js",
+ },
+ },
+ ];
+
+ 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: {
+ line: 55,
+ },
+ source: {
+ url: "http://myfile.com/mahscripts.js",
+ },
+ },
+ ];
+ 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: {
+ line: 55,
+ },
+ source: {
+ url: "http://myfile.com/mahscripts.js",
+ },
+ },
+ {
+ id: 2,
+ library: "back",
+ displayName: "a",
+ location: {
+ line: 55,
+ },
+ source: {
+ url: "http://myfile.com/back.js",
+ },
+ },
+ {
+ id: 3,
+ library: "back",
+ displayName: "b",
+ location: {
+ line: 55,
+ },
+ source: {
+ url: "http://myfile.com/back.js",
+ },
+ },
+ ];
+ const getFrameTitle = () => {};
+ const component = render({
+ frames,
+ getFrameTitle,
+ frameworkGroupingOn: true,
+ });
+
+ expect(component.find("Group").prop("getFrameTitle")).toBe(getFrameTitle);
+ });
+ });
+
+ describe("Blackboxed Frames", () => {
+ it("filters blackboxed frames", () => {
+ const source1 = makeMockSource("source1", "1");
+ const source2 = makeMockSource("source2", "2");
+ source2.isBlackBoxed = true;
+
+ const frames = [
+ makeMockFrame("1", source1),
+ makeMockFrame("2", source2),
+ makeMockFrame("3", source1),
+ makeMockFrame("8", source2),
+ ];
+
+ const blackboxedRanges = {
+ source2: [],
+ };
+
+ const processedFrames = formatCallStackFrames(
+ frames,
+ source1,
+ blackboxedRanges
+ );
+ const selectedFrame = frames[0];
+
+ const component = render({
+ frames: processedFrames,
+ frameworkGroupingOn: false,
+ selectedFrame,
+ });
+
+ expect(component.find("Frame")).toHaveLength(2);
+ expect(component).toMatchSnapshot();
+ });
+ });
+
+ describe("Library Frames", () => {
+ it("toggling framework frames", () => {
+ const frames = [
+ { id: 1 },
+ { id: 2, library: "back" },
+ { id: 3, library: "back" },
+ { id: 8 },
+ ];
+
+ 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" },
+ {
+ id: "2-webpackBootstrapFrame",
+ source: { url: "webpack:///webpack/bootstrap 01d88449ca6e9335a66f" },
+ },
+ {
+ id: "3-webpackBundleFrame",
+ source: { url: "https://foo.com/bundle.js" },
+ },
+ {
+ id: "4-webpackBootstrapFrame",
+ source: { url: "webpack:///webpack/bootstrap 01d88449ca6e9335a66f" },
+ },
+ {
+ id: "5-webpackBundleFrame",
+ 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 },
+ { id: 2, library: "back" },
+ { id: 3, library: "back" },
+ { id: 8 },
+ ];
+
+ 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..8ff1454d1a
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/Group.spec.js
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import Group from "../Group.js";
+import {
+ makeMockFrame,
+ makeMockSource,
+ mockthreadcx,
+} from "../../../../utils/test-mockup";
+
+import FrameMenu from "../FrameMenu";
+jest.mock("../FrameMenu", () => jest.fn());
+
+function render(overrides = {}) {
+ const frame = { ...makeMockFrame(), displayName: "foo", library: "Back" };
+ const defaultProps = {
+ cx: mockthreadcx,
+ 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(<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();
+ });
+
+ describe("mouse events", () => {
+ it("does not call FrameMenu when disableContextMenu is true", () => {
+ const { component } = render({
+ disableContextMenu: true,
+ });
+
+ const mockEvent = "mockEvent";
+ component.simulate("contextmenu", mockEvent);
+
+ expect(FrameMenu).toHaveBeenCalledTimes(0);
+ });
+
+ it("calls FrameMenu on right click", () => {
+ const { component, props } = render();
+ const { copyStackTrace, toggleFrameworkGrouping, toggleBlackBox, cx } =
+ props;
+ const mockEvent = "mockEvent";
+ component.simulate("contextmenu", mockEvent);
+
+ expect(FrameMenu).toHaveBeenCalledWith(
+ props.group[0],
+ props.frameworkGroupingOn,
+ {
+ copyStackTrace,
+ toggleFrameworkGrouping,
+ toggleBlackBox,
+ },
+ mockEvent,
+ cx
+ );
+ });
+ });
+});
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..2b1edaeef7
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frame.spec.js.snap
@@ -0,0 +1,1196 @@
+// 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 />
+ <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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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 />
+ <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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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 />
+ <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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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 />
+ <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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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..9a9c2a379f
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Frames.spec.js.snap
@@ -0,0 +1,1651 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Frames Blackboxed Frames filters blackboxed frames 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-1",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "sourceActor": Object {
+ "actor": "1-actor",
+ "id": "1-actor",
+ "source": "1",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ },
+ "sourceActorId": "1-actor",
+ "sourceId": "1",
+ "sourceUrl": "",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "sourceActor": Object {
+ "actor": "1-actor",
+ "id": "1-actor",
+ "source": "1",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ },
+ "sourceActorId": "1-actor",
+ "sourceId": "1",
+ "sourceUrl": "",
+ },
+ "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": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-1",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "sourceActor": Object {
+ "actor": "1-actor",
+ "id": "1-actor",
+ "source": "1",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ },
+ "sourceActorId": "1-actor",
+ "sourceId": "1",
+ "sourceUrl": "",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "sourceActor": Object {
+ "actor": "1-actor",
+ "id": "1-actor",
+ "source": "1",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ },
+ "sourceActorId": "1-actor",
+ "sourceId": "1",
+ "sourceUrl": "",
+ },
+ "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": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-3",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "sourceActor": Object {
+ "actor": "1-actor",
+ "id": "1-actor",
+ "source": "1",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ },
+ "sourceActorId": "1-actor",
+ "sourceId": "1",
+ "sourceUrl": "",
+ },
+ "id": "3",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "sourceActor": Object {
+ "actor": "1-actor",
+ "id": "1-actor",
+ "source": "1",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ },
+ "sourceActorId": "1-actor",
+ "sourceId": "1",
+ "sourceUrl": "",
+ },
+ "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": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "asyncCause": null,
+ "displayName": "display-1",
+ "generatedLocation": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "sourceActor": Object {
+ "actor": "1-actor",
+ "id": "1-actor",
+ "source": "1",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ },
+ "sourceActorId": "1-actor",
+ "sourceId": "1",
+ "sourceUrl": "",
+ },
+ "id": "1",
+ "index": 0,
+ "location": Object {
+ "column": undefined,
+ "line": 4,
+ "source": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "sourceActor": Object {
+ "actor": "1-actor",
+ "id": "1-actor",
+ "source": "1",
+ "sourceObject": Object {
+ "displayURL": Object {
+ "fileExtension": "",
+ "filename": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ },
+ "sourceActorId": "1-actor",
+ "sourceId": "1",
+ "sourceUrl": "",
+ },
+ "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": "source1",
+ "group": "",
+ "path": "source1",
+ "search": "",
+ },
+ "extensionName": null,
+ "id": "1",
+ "isExtension": false,
+ "isOriginal": false,
+ "isPrettyPrinted": false,
+ "isWasm": false,
+ "thread": "FakeThread",
+ "url": "source1",
+ },
+ "state": "on-stack",
+ "this": Object {},
+ "thread": "FakeThread",
+ "type": "call",
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames groups all the Webpack-related frames 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": "1-appFrame",
+ }
+ }
+ frameworkGroupingOn={true}
+ hideLocation={false}
+ key="1-appFrame"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": "1-appFrame",
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Group
+ copyStackTrace={[Function]}
+ frameworkGroupingOn={true}
+ group={
+ Array [
+ Object {
+ "id": "2-webpackBootstrapFrame",
+ "source": Object {
+ "url": "webpack:///webpack/bootstrap 01d88449ca6e9335a66f",
+ },
+ },
+ Object {
+ "id": "3-webpackBundleFrame",
+ "source": Object {
+ "url": "https://foo.com/bundle.js",
+ },
+ },
+ Object {
+ "id": "4-webpackBootstrapFrame",
+ "source": Object {
+ "url": "webpack:///webpack/bootstrap 01d88449ca6e9335a66f",
+ },
+ },
+ Object {
+ "id": "5-webpackBundleFrame",
+ "source": Object {
+ "url": "https://foo.com/bundle.js",
+ },
+ },
+ ]
+ }
+ key="2-webpackBootstrapFrame"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": "1-appFrame",
+ }
+ }
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames selectable framework frames 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 2,
+ "library": "back",
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 3,
+ "library": "back",
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames selectable framework frames 2`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ frameworkGroupingOn={true}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Group
+ copyStackTrace={[Function]}
+ frameworkGroupingOn={true}
+ group={
+ Array [
+ Object {
+ "id": 2,
+ "library": "back",
+ },
+ Object {
+ "id": 3,
+ "library": "back",
+ },
+ ]
+ }
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ }
+ }
+ frameworkGroupingOn={true}
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames toggling framework frames 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 2,
+ "library": "back",
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 3,
+ "library": "back",
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Library Frames toggling framework frames 2`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ frameworkGroupingOn={true}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Group
+ copyStackTrace={[Function]}
+ frameworkGroupingOn={true}
+ group={
+ Array [
+ Object {
+ "id": 2,
+ "library": "back",
+ },
+ Object {
+ "id": 3,
+ "library": "back",
+ },
+ ]
+ }
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ }
+ }
+ frameworkGroupingOn={true}
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Supports different number of frames disable frame truncation 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 2,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 3,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 4,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="4"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 5,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="5"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 6,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="6"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 7,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="7"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 9,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="9"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 10,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="10"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 11,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="11"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 12,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="12"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 13,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="13"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 14,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="14"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 15,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="15"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 16,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="16"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 17,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="17"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 18,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="18"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 19,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="19"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 20,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="20"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </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
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </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
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "displayName": "renderFoo",
+ "id": 1,
+ "location": Object {
+ "line": 55,
+ },
+ "source": Object {
+ "url": "http://myfile.com/mahscripts.js",
+ },
+ }
+ }
+ frameworkGroupingOn={false}
+ getFrameTitle={[Function]}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={null}
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </div>
+</div>
+`;
+
+exports[`Frames Supports different number of frames toggling the show more button 1`] = `
+<div
+ className="pane frames"
+>
+ <div
+ role="list"
+ >
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 1,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="1"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 2,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="2"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 3,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="3"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 4,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="4"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 5,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="5"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 6,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="6"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 7,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="7"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 8,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="8"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 9,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="9"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ <Frame
+ copyStackTrace={[Function]}
+ disableContextMenu={false}
+ frame={
+ Object {
+ "id": 10,
+ }
+ }
+ frameworkGroupingOn={false}
+ hideLocation={false}
+ key="10"
+ selectFrame={[MockFunction]}
+ selectedFrame={
+ Object {
+ "id": 1,
+ }
+ }
+ shouldMapDisplayName={true}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[Function]}
+ />
+ </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..d6542f7fd2
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Frames/tests/__snapshots__/Group.spec.js.snap
@@ -0,0 +1,2440 @@
+// 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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>
+ 1
+ </Badge>
+ <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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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>
+ 3
+ </Badge>
+ <br
+ className="clipboard-only"
+ />
+ </div>
+ <div
+ className="frames-list"
+ >
+ <FrameIndent
+ key="frame-indent-0"
+ />
+ <Frame
+ copyStackTrace={[MockFunction]}
+ cx={
+ Object {
+ "isPaused": false,
+ "navigateCounter": 0,
+ "pauseCounter": 0,
+ "thread": "FakeThread",
+ }
+ }
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ }
+ }
+ frameworkGroupingOn={true}
+ getFrameTitle={[Function]}
+ hideLocation={true}
+ key="1"
+ panel="webconsole"
+ restart={[MockFunction]}
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[MockFunction]}
+ />
+ <FrameIndent
+ key="frame-indent-1"
+ />
+ <Frame
+ copyStackTrace={[MockFunction]}
+ cx={
+ Object {
+ "isPaused": false,
+ "navigateCounter": 0,
+ "pauseCounter": 0,
+ "thread": "FakeThread",
+ }
+ }
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ }
+ }
+ frameworkGroupingOn={true}
+ getFrameTitle={[Function]}
+ hideLocation={true}
+ key="2"
+ panel="webconsole"
+ restart={[MockFunction]}
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[MockFunction]}
+ />
+ <FrameIndent
+ key="frame-indent-2"
+ />
+ <Frame
+ copyStackTrace={[MockFunction]}
+ cx={
+ Object {
+ "isPaused": false,
+ "navigateCounter": 0,
+ "pauseCounter": 0,
+ "thread": "FakeThread",
+ }
+ }
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ }
+ }
+ frameworkGroupingOn={true}
+ getFrameTitle={[Function]}
+ hideLocation={true}
+ key="3"
+ panel="webconsole"
+ restart={[MockFunction]}
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[MockFunction]}
+ />
+ </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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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>
+ 3
+ </Badge>
+ <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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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>
+ 3
+ </Badge>
+ <br
+ className="clipboard-only"
+ />
+ </div>
+ <div
+ className="frames-list"
+ >
+ <FrameIndent
+ key="frame-indent-0"
+ />
+ <Frame
+ copyStackTrace={[MockFunction]}
+ cx={
+ Object {
+ "isPaused": false,
+ "navigateCounter": 0,
+ "pauseCounter": 0,
+ "thread": "FakeThread",
+ }
+ }
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ }
+ }
+ frameworkGroupingOn={true}
+ hideLocation={true}
+ key="1"
+ panel="webconsole"
+ restart={[MockFunction]}
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[MockFunction]}
+ />
+ <FrameIndent
+ key="frame-indent-1"
+ />
+ <Frame
+ copyStackTrace={[MockFunction]}
+ cx={
+ Object {
+ "isPaused": false,
+ "navigateCounter": 0,
+ "pauseCounter": 0,
+ "thread": "FakeThread",
+ }
+ }
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ }
+ }
+ frameworkGroupingOn={true}
+ hideLocation={true}
+ key="2"
+ panel="webconsole"
+ restart={[MockFunction]}
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[MockFunction]}
+ />
+ <FrameIndent
+ key="frame-indent-2"
+ />
+ <Frame
+ copyStackTrace={[MockFunction]}
+ cx={
+ Object {
+ "isPaused": false,
+ "navigateCounter": 0,
+ "pauseCounter": 0,
+ "thread": "FakeThread",
+ }
+ }
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ }
+ }
+ frameworkGroupingOn={true}
+ hideLocation={true}
+ key="3"
+ panel="webconsole"
+ restart={[MockFunction]}
+ 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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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",
+ "sourceId": "source",
+ "sourceUrl": "",
+ },
+ "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}
+ toggleBlackBox={[MockFunction]}
+ toggleFrameworkGrouping={[MockFunction]}
+ />
+ </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..6f47c45d19
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.css
@@ -0,0 +1,104 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 .pane.scopes-list {
+ font-family: var(--monospace-font-family);
+}
+
+.scopes-content .toggle-map-scopes a.mdn {
+ padding-inline-start: 3px;
+}
+
+.scopes-content .toggle-map-scopes .img.shortcuts {
+ background: var(--theme-comment);
+}
+
+.object-node.default-property {
+ opacity: 0.6;
+}
+
+.object-node {
+ padding-inline-start: 20px;
+}
+
+html[dir="rtl"] .object-node {
+ padding-right: 4px;
+}
+
+.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..2b6b5f94c9
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Scopes.js
@@ -0,0 +1,311 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { showMenu } from "../../context-menu/menu";
+import { connect } from "../../utils/connect";
+import actions from "../../actions";
+
+import {
+ getSelectedSource,
+ getSelectedFrame,
+ getGeneratedFrameScope,
+ getOriginalFrameScope,
+ getPauseReason,
+ isMapScopesEnabled,
+ getThreadContext,
+ getLastExpandedScopes,
+ getIsCurrentThreadPaused,
+} from "../../selectors";
+import { getScopes } from "../../utils/pause/scopes";
+import { getScopeItemPath } from "../../utils/pause/scopes/utils";
+import { clientCommands } from "../../client/firefox";
+
+import { objectInspector } from "devtools/client/shared/components/reps/index";
+
+import "./Scopes.css";
+
+const { ObjectInspector } = objectInspector;
+
+class Scopes extends PureComponent {
+ constructor(props) {
+ const { why, selectedFrame, originalFrameScopes, generatedFrameScopes } =
+ props;
+
+ super(props);
+
+ this.state = {
+ originalScopes: getScopes(why, selectedFrame, originalFrameScopes),
+ generatedScopes: getScopes(why, selectedFrame, generatedFrameScopes),
+ showOriginal: true,
+ };
+ }
+
+ static get propTypes() {
+ return {
+ addWatchpoint: PropTypes.func.isRequired,
+ cx: PropTypes.object.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,
+ selectedFrame: PropTypes.object.isRequired,
+ setExpandedScope: PropTypes.func.isRequired,
+ toggleMapScopes: PropTypes.func.isRequired,
+ unHighlightDomElement: PropTypes.func.isRequired,
+ why: PropTypes.object.isRequired,
+ };
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const {
+ selectedFrame,
+ originalFrameScopes,
+ generatedFrameScopes,
+ isPaused,
+ } = this.props;
+ const isPausedChanged = isPaused !== nextProps.isPaused;
+ const selectedFrameChanged = selectedFrame !== nextProps.selectedFrame;
+ const originalFrameScopesChanged =
+ originalFrameScopes !== nextProps.originalFrameScopes;
+ const generatedFrameScopesChanged =
+ generatedFrameScopes !== nextProps.generatedFrameScopes;
+
+ if (
+ isPausedChanged ||
+ selectedFrameChanged ||
+ originalFrameScopesChanged ||
+ generatedFrameScopesChanged
+ ) {
+ this.setState({
+ originalScopes: getScopes(
+ nextProps.why,
+ nextProps.selectedFrame,
+ nextProps.originalFrameScopes
+ ),
+ generatedScopes: getScopes(
+ nextProps.why,
+ nextProps.selectedFrame,
+ nextProps.generatedFrameScopes
+ ),
+ });
+ }
+ }
+
+ onToggleMapScopes = () => {
+ this.props.toggleMapScopes();
+ };
+
+ 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 {
+ cx,
+ isLoading,
+ openLink,
+ openElementInInspector,
+ highlightDomElement,
+ unHighlightDomElement,
+ mapScopesEnabled,
+ selectedFrame,
+ setExpandedScope,
+ expandedScopes,
+ } = this.props;
+ const { originalScopes, generatedScopes, showOriginal } = this.state;
+
+ const scopes =
+ (showOriginal && mapScopesEnabled && originalScopes) || generatedScopes;
+
+ function initiallyExpanded(item) {
+ return expandedScopes.some(path => path == getScopeItemPath(item));
+ }
+
+ if (scopes && !!scopes.length && !isLoading) {
+ return (
+ <div className="pane scopes-list">
+ <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(cx, path, expand)}
+ initiallyExpanded={initiallyExpanded}
+ renderItemActions={this.renderWatchpointButton}
+ shouldRenderTooltip={true}
+ />
+ </div>
+ );
+ }
+
+ let stateText = L10N.getStr("scopes.notPaused");
+ if (this.props.isPaused) {
+ if (isLoading) {
+ stateText = L10N.getStr("loadingText");
+ } else {
+ stateText = L10N.getStr("scopes.notAvailable");
+ }
+ }
+
+ return (
+ <div className="pane scopes-list">
+ <div className="pane-info">{stateText}</div>
+ </div>
+ );
+ }
+
+ render() {
+ return <div className="scopes-content">{this.renderScopesList()}</div>;
+ }
+}
+
+const mapStateToProps = state => {
+ const cx = getThreadContext(state);
+ const selectedFrame = getSelectedFrame(state, cx.thread);
+ const selectedSource = getSelectedSource(state);
+
+ const { scope: originalFrameScopes, pending: originalPending } =
+ getOriginalFrameScope(
+ state,
+ cx.thread,
+ selectedSource?.id,
+ selectedFrame?.id
+ ) || { scope: null, pending: false };
+
+ const { scope: generatedFrameScopes, pending: generatedPending } =
+ getGeneratedFrameScope(state, cx.thread, selectedFrame?.id) || {
+ scope: null,
+ pending: false,
+ };
+
+ return {
+ cx,
+ selectedFrame,
+ mapScopesEnabled: isMapScopesEnabled(state),
+ isLoading: generatedPending || originalPending,
+ why: getPauseReason(state, cx.thread),
+ originalFrameScopes,
+ generatedFrameScopes,
+ expandedScopes: getLastExpandedScopes(state, cx.thread),
+ isPaused: getIsCurrentThreadPaused(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ openLink: actions.openLink,
+ openElementInInspector: actions.openElementInInspectorCommand,
+ highlightDomElement: actions.highlightDomElement,
+ unHighlightDomElement: actions.unHighlightDomElement,
+ toggleMapScopes: actions.toggleMapScopes,
+ setExpandedScope: actions.setExpandedScope,
+ addWatchpoint: actions.addWatchpoint,
+ removeWatchpoint: actions.removeWatchpoint,
+})(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..dec84252f8
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/SecondaryPanes.css
@@ -0,0 +1,86 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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;
+}
+
+.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 {
+ font-style: italic;
+ text-align: center;
+ padding: 0.5em;
+ user-select: none;
+ cursor: default;
+}
+
+.secondary-panes .breakpoints-buttons {
+ display: flex;
+}
+
+.dropdown {
+ width: 20em;
+ overflow: auto;
+}
+
+.secondary-panes input[type="checkbox"] {
+ margin: 0;
+ margin-inline-end: 4px;
+ vertical-align: middle;
+}
+
+.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..c9db8a25ef
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Thread.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+import actions from "../../actions";
+import { getCurrentThread, getIsPaused, getContext } from "../../selectors";
+import AccessibleImage from "../shared/AccessibleImage";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+export class Thread extends Component {
+ static get propTypes() {
+ return {
+ currentThread: PropTypes.string.isRequired,
+ cx: PropTypes.object.isRequired,
+ isPaused: PropTypes.bool.isRequired,
+ selectThread: PropTypes.func.isRequired,
+ thread: PropTypes.object.isRequired,
+ };
+ }
+
+ onSelectThread = () => {
+ const { thread } = this.props;
+ this.props.selectThread(this.props.cx, 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,
+ })}
+ key={thread.actor}
+ onClick={this.onSelectThread}
+ >
+ <div className="icon">
+ <AccessibleImage className={isWorker ? "worker" : "window"} />
+ </div>
+ <div className="label">{label}</div>
+ {isPaused ? (
+ <div className="pause-badge">
+ <AccessibleImage className="pause" />
+ </div>
+ ) : null}
+ </div>
+ );
+ }
+}
+
+const mapStateToProps = (state, props) => ({
+ cx: getContext(state),
+ 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..49e150dd44
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Threads.css
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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;
+}
+
+.threads-list > .thread:hover {
+ background-color: var(--search-overlays-semitransparent);
+}
+
+.threads-list > .thread.selected {
+ background-color: var(--tab-line-selected-color);
+}
+
+.threads-list .icon {
+ flex: none;
+ margin-inline-end: 4px;
+}
+
+.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;
+ margin-inline-start: 4px;
+}
+
+.threads-list > .thread.selected {
+ background: var(--theme-selection-background);
+ color: var(--theme-selection-color);
+}
+
+.threads-list > .thread.selected .img {
+ background-color: currentColor;
+}
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..4dbf0ff081
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/Threads.js
@@ -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/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+
+import { getAllThreads } from "../../selectors";
+import Thread from "./Thread";
+
+import "./Threads.css";
+
+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 => (
+ <Thread thread={thread} key={thread.actor} />
+ ))}
+ </div>
+ );
+ }
+}
+
+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..cbe2ebf4c9
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.css
@@ -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/>. */
+
+.why-paused {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ border-bottom: 1px solid var(--theme-splitter-color);
+ background-color: hsl(54, 100%, 92%);
+ color: var(--theme-body-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;
+}
+
+.theme-dark .secondary-panes .why-paused {
+ background-color: hsl(42, 37%, 19%);
+ color: hsl(43, 94%, 81%);
+}
+
+.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..5123649f37
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/WhyPaused.js
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+const {
+ LocalizationProvider,
+ Localized,
+} = require("devtools/client/shared/vendor/fluent-react");
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import AccessibleImage from "../shared/AccessibleImage";
+import actions from "../../actions";
+
+import Reps from "devtools/client/shared/components/reps/index";
+const {
+ REPS: { Rep },
+ MODE,
+} = Reps;
+
+import { getPauseReason } from "../../utils/pause";
+import {
+ getCurrentThread,
+ getPaneCollapse,
+ getPauseReason as getWhy,
+} from "../../selectors";
+
+import "./WhyPaused.css";
+
+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}</div>;
+ }
+
+ 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>
+ <div className="message">{why.message}</div>
+ <div className="mutationNode">
+ {ancestorRep}
+ {ancestorGrip ? (
+ <span className="why-paused-ancestor">
+ <Localized
+ id={
+ action === "remove"
+ ? "whypaused-mutation-breakpoint-removed"
+ : "whypaused-mutation-breakpoint-added"
+ }
+ ></Localized>
+ {targetRep}
+ </span>
+ ) : (
+ targetRep
+ )}
+ </div>
+ </div>
+ );
+ }
+
+ if (typeof message == "string") {
+ return <div className="message">{message}</div>;
+ }
+
+ 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.
+ <LocalizationProvider bundles={fluentBundles || []}>
+ <div className="pane why-paused">
+ <div>
+ <div className="info icon">
+ <AccessibleImage className="info" />
+ </div>
+ <div className="pause reason">
+ <Localized id={reason}></Localized>
+ {this.renderMessage(why)}
+ </div>
+ </div>
+ </div>
+ </LocalizationProvider>
+ );
+ }
+}
+
+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..5f0352a93c
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.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/>. */
+
+.xhr-breakpoints-pane ._content {
+ overflow-x: auto;
+}
+
+.xhr-input-container {
+ display: flex;
+ border: 1px solid transparent;
+}
+
+.xhr-input-container.focused {
+ border: 1px solid var(--theme-highlight-blue);
+}
+
+:root.theme-dark .xhr-input-container.focused {
+ border: 1px solid var(--blue-50);
+}
+
+.xhr-input-container.error {
+ border: 1px solid red;
+}
+
+.xhr-input-form {
+ display: inline-flex;
+ width: 100%;
+ 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-grow: 1;
+ background-color: var(--theme-sidebar-background);
+ font-size: inherit;
+ height: 24px;
+ color: var(--theme-body-color);
+}
+
+.xhr-input-url::placeholder {
+ color: var(--theme-text-color-alt);
+ opacity: 1;
+}
+
+.xhr-input-url:focus {
+ cursor: text;
+ outline: none;
+}
+
+.expressions-list .xhr-input-container {
+ height: var(--expression-item-height);
+}
+
+.expressions-list .xhr-input-url {
+ /* Prevent vertical bounce when editing an existing XHR Breakpoint */
+ height: 100%;
+}
+
+.xhr-container {
+ border-left: 4px solid transparent;
+ width: 100%;
+ color: var(--theme-body-color);
+ padding-inline-start: 16px;
+ padding-inline-end: 12px;
+ 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-method {
+ display: none;
+ /* Vertically center select in form */
+ margin-top: 2px;
+}
+
+.expressions-list .xhr-input-method {
+ margin-top: 0px;
+}
+
+.xhr-input-container.focused .xhr-input-method {
+ display: block;
+}
+
+.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;
+ overflow-x: hidden;
+}
+
+.xhr-container__close-btn {
+ display: flex;
+ padding-top: 2px;
+ padding-bottom: 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..721b132a3b
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/XHRBreakpoints.js
@@ -0,0 +1,361 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import actions from "../../actions";
+
+import { CloseButton } from "../shared/Button";
+
+import "./XHRBreakpoints.css";
+import { getXHRBreakpoints, shouldPauseOnAnyXHR } from "../../selectors";
+import ExceptionOption from "./Breakpoints/ExceptionOption";
+
+const classnames = require("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" }} />
+ </form>
+ );
+ }
+
+ 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>
+ <input
+ type="checkbox"
+ className="xhr-checkbox"
+ checked={!disabled}
+ onChange={() => this.handleCheckbox(index)}
+ onClick={ev => ev.stopPropagation()}
+ />
+ <div className="xhr-label-method">{method}</div>
+ <div className="xhr-label-url">{path}</div>
+ <div className="xhr-container__close-btn">
+ <CloseButton handleClick={e => removeXHRBreakpoint(index)} />
+ </div>
+ </label>
+ </li>
+ );
+ };
+
+ renderBreakpoints = explicitXhrBreakpoints => {
+ const { showInput } = this.props;
+
+ return (
+ <>
+ <ul className="pane expressions-list">
+ {explicitXhrBreakpoints.map(this.renderBreakpoint)}
+ </ul>
+ {showInput && this.renderXHRInput(this.handleNewSubmit)}
+ </>
+ );
+ };
+
+ renderCheckbox = explicitXhrBreakpoints => {
+ const { shouldPauseOnAny, togglePauseOnAny } = this.props;
+
+ return (
+ <div
+ className={classnames("breakpoints-exceptions-options", {
+ empty: explicitXhrBreakpoints.length === 0,
+ })}
+ >
+ <ExceptionOption
+ className="breakpoints-exceptions"
+ label={L10N.getStr("pauseOnAnyXHR")}
+ isChecked={shouldPauseOnAny}
+ onChange={() => togglePauseOnAny()}
+ />
+ </div>
+ );
+ };
+
+ 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}
+ </option>
+ );
+ };
+
+ renderMethodSelectElement = () => {
+ return (
+ <select
+ value={this.state.inputMethod}
+ className="xhr-input-method"
+ onChange={this.handleMethodChange}
+ onMouseDown={this.onMouseDown}
+ onKeyDown={this.handleTab}
+ >
+ {xhrMethods.map(this.renderMethodOption)}
+ </select>
+ );
+ };
+
+ render() {
+ const { xhrBreakpoints } = this.props;
+ const explicitXhrBreakpoints = getExplicitXHRBreakpoints(xhrBreakpoints);
+
+ return (
+ <>
+ {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..9b1e2dca60
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/index.js
@@ -0,0 +1,537 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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("devtools/client/shared/components/splitter/SplitBox");
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { isGeneratedId } from "devtools/client/shared/source-map-loader/index";
+import { connect } from "../../utils/connect";
+
+import actions from "../../actions";
+import {
+ getTopFrame,
+ getExpressions,
+ getPauseCommand,
+ isMapScopesEnabled,
+ getSelectedFrame,
+ getShouldPauseOnExceptions,
+ getShouldPauseOnCaughtExceptions,
+ getThreads,
+ getCurrentThread,
+ getThreadContext,
+ getPauseReason,
+ getShouldBreakpointsPaneOpenOnPause,
+ getSkipPausing,
+ shouldLogEventBreakpoints,
+} from "../../selectors";
+
+import AccessibleImage from "../shared/AccessibleImage";
+import { prefs } from "../../utils/prefs";
+
+import Breakpoints from "./Breakpoints";
+import Expressions from "./Expressions";
+import Frames from "./Frames";
+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("devtools/client/shared/classnames.js");
+
+import "./SecondaryPanes.css";
+
+function debugBtn(onClick, type, className, tooltip) {
+ return (
+ <button
+ onClick={onClick}
+ className={`${type} ${className}`}
+ key={type}
+ title={tooltip}
+ >
+ <AccessibleImage className={type} title={tooltip} aria-label={tooltip} />
+ </button>
+ );
+}
+
+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 {
+ cx: PropTypes.object.isRequired,
+ evaluateExpressions: PropTypes.func.isRequired,
+ expressions: PropTypes.array.isRequired,
+ hasFrames: PropTypes.bool.isRequired,
+ horizontal: PropTypes.bool.isRequired,
+ logEventBreakpoints: PropTypes.bool.isRequired,
+ mapScopesEnabled: PropTypes.bool.isRequired,
+ pauseOnExceptions: PropTypes.func.isRequired,
+ pauseReason: PropTypes.string.isRequired,
+ shouldBreakpointsPaneOpenOnPause: PropTypes.bool.isRequired,
+ thread: PropTypes.string.isRequired,
+ renderWhyPauseDelay: PropTypes.number.isRequired,
+ selectedFrame: PropTypes.object,
+ shouldPauseOnCaughtExceptions: PropTypes.bool.isRequired,
+ shouldPauseOnExceptions: PropTypes.bool.isRequired,
+ 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(
+ evt => {
+ evt.stopPropagation();
+ this.props.evaluateExpressions(this.props.cx);
+ },
+ "refresh",
+ "active",
+ L10N.getStr("watchExpressions.refreshButton")
+ )
+ );
+ }
+ buttons.push(
+ debugBtn(
+ evt => {
+ if (prefs.expressionsVisible) {
+ evt.stopPropagation();
+ }
+ this.setState({ showExpressionsInput: true });
+ },
+ "plus",
+ "active",
+ L10N.getStr("expressions.placeholder")
+ )
+ );
+ return buttons;
+ }
+
+ xhrBreakpointsHeaderButtons() {
+ return [
+ debugBtn(
+ evt => {
+ if (prefs.xhrBreakpointsVisible) {
+ evt.stopPropagation();
+ }
+ this.setState({ showXHRInput: true });
+ },
+ "plus",
+ "active",
+ L10N.getStr("xhrBreakpoints.label")
+ ),
+
+ debugBtn(
+ evt => {
+ evt.stopPropagation();
+ this.props.removeAllXHRBreakpoints();
+ },
+ "removeAll",
+ "active",
+ L10N.getStr("xhrBreakpoints.removeAll.tooltip")
+ ),
+ ];
+ }
+
+ breakpointsHeaderButtons() {
+ return [
+ debugBtn(
+ evt => {
+ evt.stopPropagation();
+ this.props.removeAllBreakpoints(this.props.cx);
+ },
+ "removeAll",
+ "active",
+ L10N.getStr("breakpointMenuItem.deleteAll")
+ ),
+ ];
+ }
+
+ getScopeItem() {
+ return {
+ header: L10N.getStr("scopes.header"),
+ className: "scopes-pane",
+ component: <Scopes />,
+ opened: prefs.scopesVisible,
+ buttons: this.getScopesButtons(),
+ onToggle: opened => {
+ prefs.scopesVisible = opened;
+ },
+ };
+ }
+
+ getScopesButtons() {
+ const { selectedFrame, mapScopesEnabled, source } = this.props;
+
+ if (
+ !selectedFrame ||
+ isGeneratedId(selectedFrame.location.sourceId) ||
+ source?.isPrettyPrinted
+ ) {
+ return null;
+ }
+
+ return [
+ <div key="scopes-buttons">
+ <label
+ className="map-scopes-header"
+ title={L10N.getStr("scopes.mapping.label")}
+ onClick={e => e.stopPropagation()}
+ >
+ <input
+ type="checkbox"
+ checked={mapScopesEnabled ? "checked" : ""}
+ onChange={e => this.props.toggleMapScopes()}
+ />
+ {L10N.getStr("scopes.map.label")}
+ </label>
+ <a
+ className="mdn"
+ target="_blank"
+ href={mdnLink}
+ onClick={e => e.stopPropagation()}
+ title={L10N.getStr("scopes.helpTooltip.label")}
+ >
+ <AccessibleImage className="shortcuts" />
+ </a>
+ </div>,
+ ];
+ }
+
+ getEventButtons() {
+ const { logEventBreakpoints } = this.props;
+ return [
+ <div key="events-buttons">
+ <label
+ className="events-header"
+ title={L10N.getStr("eventlisteners.log.label")}
+ onClick={e => e.stopPropagation()}
+ >
+ <input
+ type="checkbox"
+ checked={logEventBreakpoints ? "checked" : ""}
+ onChange={e => this.props.toggleEventLogging()}
+ onKeyDown={e => e.stopPropagation()}
+ />
+ {L10N.getStr("eventlisteners.log")}
+ </label>
+ </div>,
+ ];
+ }
+
+ getWatchItem() {
+ return {
+ header: L10N.getStr("watchExpressions.header"),
+ className: "watch-expressions-pane",
+ buttons: this.watchExpressionHeaderButtons(),
+ component: (
+ <Expressions
+ showInput={this.state.showExpressionsInput}
+ onExpressionAdded={this.onExpressionAdded}
+ />
+ ),
+ opened: prefs.expressionsVisible,
+ onToggle: opened => {
+ prefs.expressionsVisible = opened;
+ },
+ };
+ }
+
+ getXHRItem() {
+ const { pauseReason } = this.props;
+
+ return {
+ header: L10N.getStr("xhrBreakpoints.header"),
+ className: "xhr-breakpoints-pane",
+ buttons: this.xhrBreakpointsHeaderButtons(),
+ component: (
+ <XHRBreakpoints
+ showInput={this.state.showXHRInput}
+ onXHRAdded={this.onXHRAdded}
+ />
+ ),
+ opened: prefs.xhrBreakpointsVisible || pauseReason === "XHR",
+ onToggle: opened => {
+ prefs.xhrBreakpointsVisible = opened;
+ },
+ };
+ }
+
+ getCallStackItem() {
+ return {
+ header: L10N.getStr("callStack.header"),
+ className: "call-stack-pane",
+ component: <Frames panel="debugger" />,
+ opened: prefs.callStackVisible,
+ onToggle: opened => {
+ prefs.callStackVisible = opened;
+ },
+ };
+ }
+
+ getThreadsItem() {
+ return {
+ header: L10N.getStr("threadsHeader"),
+ className: "threads-pane",
+ component: <Threads />,
+ opened: prefs.threadsVisible,
+ onToggle: opened => {
+ prefs.threadsVisible = opened;
+ },
+ };
+ }
+
+ getBreakpointsItem() {
+ const {
+ shouldPauseOnExceptions,
+ shouldPauseOnCaughtExceptions,
+ pauseOnExceptions,
+ pauseReason,
+ shouldBreakpointsPaneOpenOnPause,
+ thread,
+ } = this.props;
+
+ return {
+ header: L10N.getStr("breakpoints.header"),
+ className: "breakpoints-pane",
+ buttons: this.breakpointsHeaderButtons(),
+ component: (
+ <Breakpoints
+ shouldPauseOnExceptions={shouldPauseOnExceptions}
+ shouldPauseOnCaughtExceptions={shouldPauseOnCaughtExceptions}
+ pauseOnExceptions={pauseOnExceptions}
+ />
+ ),
+ 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"),
+ className: "event-listeners-pane",
+ buttons: this.getEventButtons(),
+ component: <EventListeners />,
+ opened: prefs.eventListenersVisible || pauseReason === "eventBreakpoint",
+ onToggle: opened => {
+ prefs.eventListenersVisible = opened;
+ },
+ };
+ }
+
+ getDOMMutationsItem() {
+ const { pauseReason } = this.props;
+
+ return {
+ header: L10N.getStr("domMutationHeader"),
+ className: "dom-mutations-pane",
+ buttons: [],
+ component: <DOMMutationBreakpoints />,
+ 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>
+ <WhyPaused delay={renderWhyPauseDelay} />
+ <Accordion items={this.getItems()} />
+ </div>
+ );
+ }
+
+ renderVerticalLayout() {
+ return (
+ <SplitBox
+ initialSize="300px"
+ minSize={10}
+ maxSize="50%"
+ splitterSize={1}
+ startPanel={
+ <div style={{ width: "inherit" }}>
+ <WhyPaused delay={this.props.renderWhyPauseDelay} />
+ <Accordion items={this.getStartItems()} />
+ </div>
+ }
+ endPanel={<Accordion items={this.getEndItems()} />}
+ />
+ );
+ }
+
+ render() {
+ const { skipPausing } = this.props;
+ return (
+ <div className="secondary-panes-wrapper">
+ <CommandBar horizontal={this.props.horizontal} />
+ <div
+ className={classnames(
+ "secondary-panes",
+ skipPausing && "skip-pausing"
+ )}
+ >
+ {this.props.horizontal
+ ? this.renderHorizontalLayout()
+ : this.renderVerticalLayout()}
+ </div>
+ </div>
+ );
+ }
+}
+
+// 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 {
+ cx: getThreadContext(state),
+ expressions: getExpressions(state),
+ hasFrames: !!getTopFrame(state, thread),
+ renderWhyPauseDelay: getRenderWhyPauseDelay(state, thread),
+ selectedFrame,
+ mapScopesEnabled: isMapScopesEnabled(state),
+ shouldPauseOnExceptions: getShouldPauseOnExceptions(state),
+ shouldPauseOnCaughtExceptions: getShouldPauseOnCaughtExceptions(state),
+ threads: getThreads(state),
+ skipPausing: getSkipPausing(state),
+ logEventBreakpoints: shouldLogEventBreakpoints(state),
+ source: selectedFrame && selectedFrame.location.source,
+ pauseReason: pauseReason?.type ?? "",
+ shouldBreakpointsPaneOpenOnPause,
+ thread,
+ };
+};
+
+export default connect(mapStateToProps, {
+ evaluateExpressions: actions.evaluateExpressions,
+ pauseOnExceptions: actions.pauseOnExceptions,
+ 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..69dd75a187
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/CommandBar.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 "react";
+import { shallow } from "enzyme";
+import CommandBar from "../CommandBar";
+import { mockthreadcx } from "../../../utils/test-mockup";
+
+describe("CommandBar", () => {
+ it("f8 key command calls props.breakOnNext when not in paused state", () => {
+ const props = {
+ cx: mockthreadcx,
+ 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(<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 = {
+ cx: { ...mockthreadcx, isPaused: true },
+ 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(<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..f82b2093c9
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/EventListeners.spec.js
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import 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(<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..ad14190276
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/Expressions.spec.js
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "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(<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..e269e89ac5
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/XHRBreakpoints.spec.js
@@ -0,0 +1,345 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { mount } from "enzyme";
+import 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(
+ <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-exceptions-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-exceptions-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..4869b15a73
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/Expressions.spec.js.snap
@@ -0,0 +1,199 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Expressions should always have unique keys 1`] = `
+<Fragment>
+ <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>
+</Fragment>
+`;
+
+exports[`Expressions should render 1`] = `
+<Fragment>
+ <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>
+</Fragment>
+`;
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..5611f6ceef
--- /dev/null
+++ b/devtools/client/debugger/src/components/SecondaryPanes/tests/__snapshots__/XHRBreakpoints.spec.js.snap
@@ -0,0 +1,621 @@
+// 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-exceptions-options empty"
+ >
+ <ExceptionOption
+ className="breakpoints-exceptions"
+ isChecked={false}
+ label="Pause on any URL"
+ onChange={[Function]}
+ >
+ <div
+ className="breakpoints-exceptions"
+ onClick={[Function]}
+ >
+ <input
+ checked=""
+ onChange={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="breakpoint-exceptions-label"
+ >
+ Pause on any URL
+ </div>
+ </div>
+ </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-exceptions-options"
+ >
+ <ExceptionOption
+ className="breakpoints-exceptions"
+ isChecked={false}
+ label="Pause on any URL"
+ onChange={[Function]}
+ >
+ <div
+ className="breakpoints-exceptions"
+ onClick={[Function]}
+ >
+ <input
+ checked=""
+ onChange={[Function]}
+ type="checkbox"
+ />
+ <div
+ className="breakpoint-exceptions-label"
+ >
+ Pause on any URL
+ </div>
+ </div>
+ </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..fd9696e93f
--- /dev/null
+++ b/devtools/client/debugger/src/components/ShortcutsModal.js
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import Modal from "./shared/Modal";
+import { formatKeyShortcut } from "../utils/text";
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./ShortcutsModal.css";
+
+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}
+ </span>
+ ))
+ .reduce((prev, curr) => [prev, " + ", curr]);
+ }
+
+ renderShorcutItem(title, combo) {
+ return (
+ <li>
+ <span>{title}</span>
+ <span>{this.renderPrettyCombos(combo)}</span>
+ </li>
+ );
+ }
+
+ 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"))
+ )}
+ </ul>
+ );
+ }
+
+ 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"))
+ )}
+ </ul>
+ );
+ }
+
+ 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"))
+ )}
+ </ul>
+ );
+ }
+
+ renderShortcutsContent() {
+ return (
+ <div className={classnames("shortcuts-content", isMacOS ? "mac" : "")}>
+ <div className="shortcuts-section">
+ <h2>{L10N.getStr("shortcuts.header.editor")}</h2>
+ {this.renderEditorShortcuts()}
+ </div>
+ <div className="shortcuts-section">
+ <h2>{L10N.getStr("shortcuts.header.stepping")}</h2>
+ {this.renderSteppingShortcuts()}
+ </div>
+ <div className="shortcuts-section">
+ <h2>{L10N.getStr("shortcuts.header.search")}</h2>
+ {this.renderSearchShortcuts()}
+ </div>
+ </div>
+ );
+ }
+
+ render() {
+ const { enabled } = this.props;
+
+ if (!enabled) {
+ return null;
+ }
+
+ return (
+ <Modal
+ in={enabled}
+ additionalClass="shortcuts-modal"
+ handleClose={this.props.handleClose}
+ >
+ {this.renderShortcutsContent()}
+ </Modal>
+ );
+ }
+}
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..18567d31f7
--- /dev/null
+++ b/devtools/client/debugger/src/components/WelcomeBox.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 "react";
+import PropTypes from "prop-types";
+
+import { connect } from "../utils/connect";
+import { primaryPaneTabs } from "../constants";
+
+import actions from "../actions";
+import { getPaneCollapse } from "../selectors";
+import { formatKeyShortcut } from "../utils/text";
+
+import "./WelcomeBox.css";
+
+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>
+ <span className="shortcutLabel">{searchSourcesLabel}</span>
+ </p>
+ <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>
+ <span className="shortcutLabel">{searchProjectLabel}</span>
+ </p>
+ <p
+ className="welcomebox__allShortcuts"
+ role="button"
+ tabIndex="0"
+ onClick={() => this.props.toggleShortcutsModal()}
+ >
+ <span className="shortcutKey">{allShortcutsShortcut}</span>
+ <span className="shortcutLabel">{allShortcutsLabel}</span>
+ </p>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
+
+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..41ced8d474
--- /dev/null
+++ b/devtools/client/debugger/src/components/moz.build
@@ -0,0 +1,19 @@
+# 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(
+ "A11yIntention.js",
+ "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..06b8149325
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/AccessibleImage.css
@@ -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/>. */
+
+.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.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(chrome://devtools/content/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(chrome://devtools/content/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..1ac3510c36
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/AccessibleImage.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 "react";
+import PropTypes from "prop-types";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./AccessibleImage.css";
+
+const AccessibleImage = props => {
+ props = {
+ ...props,
+ className: classnames("img", props.className),
+ };
+ return <span {...props} />;
+};
+
+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..e87fa41a6f
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Accordion.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/>. */
+
+.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;
+ 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 .arrow {
+ margin-inline-end: 4px;
+}
+
+.accordion ._header .header-label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--theme-toolbar-color);
+}
+
+.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..fba307abaf
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Accordion.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, { cloneElement, Component } from "react";
+import PropTypes from "prop-types";
+import AccessibleImage from "./AccessibleImage";
+
+import "./Accordion.css";
+
+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();
+ }
+
+ onHandleHeaderKeyDown(e, i) {
+ if (e && (e.key === " " || e.key === "Enter")) {
+ this.handleHeaderClick(i);
+ }
+ }
+
+ renderContainer = (item, i) => {
+ const { opened } = item;
+
+ return (
+ <li className={item.className} key={i}>
+ <h2
+ className="_header"
+ tabIndex="0"
+ onKeyDown={e => this.onHandleHeaderKeyDown(e, i)}
+ onClick={() => this.handleHeaderClick(i)}
+ >
+ <AccessibleImage className={`arrow ${opened ? "expanded" : ""}`} />
+ <span className="header-label">{item.header}</span>
+ {item.buttons ? (
+ <div className="header-buttons" tabIndex="-1">
+ {item.buttons}
+ </div>
+ ) : null}
+ </h2>
+ {opened && (
+ <div className="_content">
+ {cloneElement(item.component, item.componentProps || {})}
+ </div>
+ )}
+ </li>
+ );
+ };
+ render() {
+ return (
+ <ul className="accordion">
+ {this.props.items.map(this.renderContainer)}
+ </ul>
+ );
+ }
+}
+
+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..58519e0246
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Badge.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import PropTypes from "prop-types";
+import "./Badge.css";
+
+const Badge = ({ children }) => (
+ <span className="badge text-white text-center">{children}</span>
+);
+
+Badge.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+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..2e0c3fbf0e
--- /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 React from "react";
+import PropTypes from "prop-types";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./BracketArrow.css";
+
+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..2450b4aae2
--- /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 "react";
+import PropTypes from "prop-types";
+
+import AccessibleImage from "../AccessibleImage";
+
+import "./styles/CloseButton.css";
+
+function CloseButton({ handleClick, buttonClass, tooltip }) {
+ return (
+ <button
+ className={buttonClass ? `close-btn ${buttonClass}` : "close-btn"}
+ onClick={handleClick}
+ title={tooltip}
+ >
+ <AccessibleImage className="close" />
+ </button>
+ );
+}
+
+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..f1579b6f7a
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/CommandBarButton.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 "react";
+import PropTypes from "prop-types";
+
+import AccessibleImage from "../AccessibleImage";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./styles/CommandBarButton.css";
+
+export function debugBtn(
+ onClick,
+ type,
+ className,
+ tooltip,
+ disabled = false,
+ ariaPressed = false
+) {
+ return (
+ <CommandBarButton
+ className={classnames(type, className)}
+ disabled={disabled}
+ key={type}
+ onClick={onClick}
+ pressed={ariaPressed}
+ title={tooltip}
+ >
+ <AccessibleImage className={type} />
+ </CommandBarButton>
+ );
+}
+
+const CommandBarButton = props => {
+ const { children, className, pressed = false, ...rest } = props;
+
+ return (
+ <button
+ aria-pressed={pressed}
+ className={classnames("command-bar-button", className)}
+ {...rest}
+ >
+ {children}
+ </button>
+ );
+};
+
+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..ba2f20e882
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/PaneToggleButton.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, { PureComponent } from "react";
+import PropTypes from "prop-types";
+import AccessibleImage from "../AccessibleImage";
+import { CommandBarButton } from "./";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./styles/PaneToggleButton.css";
+
+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 (
+ <CommandBarButton
+ className={classnames("toggle-button", position, {
+ collapsed,
+ vertical: !horizontal,
+ })}
+ onClick={() => handleClick(position, !collapsed)}
+ title={this.label(position, collapsed)}
+ >
+ <AccessibleImage
+ className={collapsed ? "pane-expand" : "pane-collapse"}
+ />
+ </CommandBarButton>
+ );
+ }
+}
+
+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..b0093ff4de
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/styles/CloseButton.css
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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,
+.close-btn:focus {
+ 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..5b03bca8ec
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/styles/CommandBarButton.css
@@ -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/>. */
+
+.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;
+}
+
+.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.active::before {
+ fill: var(--theme-icon-checked-color);
+}
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..cb426ddada
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/tests/CloseButton.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 "react";
+import { shallow } from "enzyme";
+import { CloseButton } from "../";
+
+describe("CloseButton", () => {
+ it("renders with tooltip", () => {
+ const tooltip = "testTooltip";
+ const wrapper = shallow(
+ <CloseButton tooltip={tooltip} handleClick={() => {}} />
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("handles click event", () => {
+ const handleClickSpy = jest.fn();
+ const wrapper = shallow(<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..1da7dc9fed
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Button/tests/CommandBarButton.spec.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import { CommandBarButton, debugBtn } from "../";
+
+describe("CommandBarButton", () => {
+ it("renders", () => {
+ const wrapper = shallow(<CommandBarButton children={[]} className={""} />);
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("renders children", () => {
+ const children = [1, 2, 3, 4];
+ const wrapper = shallow(
+ <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..59fbe11fc6
--- /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 "react";
+import { shallow } from "enzyme";
+import { PaneToggleButton } from "../";
+
+describe("PaneToggleButton", () => {
+ const handleClickSpy = jest.fn();
+ const wrapper = shallow(
+ <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..bae5656c8f
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Dropdown.css
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+.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;
+}
+
+.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..7051cec9c5
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Dropdown.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, { Component } from "react";
+import PropTypes from "prop-types";
+import "./Dropdown.css";
+
+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}
+ </div>
+ );
+ }
+
+ renderButton() {
+ return (
+ <button className="dropdown-button" onClick={this.toggleDropdown}>
+ {this.props.icon}
+ </button>
+ );
+ }
+
+ 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()}
+ </div>
+ );
+ }
+}
+
+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..072390b001
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Modal.css
@@ -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/>. */
+
+.modal-wrapper {
+ position: fixed;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ transition: z-index 200ms;
+ z-index: 100;
+}
+
+.modal {
+ display: flex;
+ width: 80%;
+ max-height: 80vh;
+ overflow-y: auto;
+ background-color: var(--theme-toolbar-background);
+ transition: transform 150ms cubic-bezier(0.07, 0.95, 0, 1);
+ box-shadow: 1px 1px 6px 1px var(--popup-shadow-color);
+}
+
+.modal.entering,
+.modal.exited {
+ transform: translateY(-101%);
+}
+
+.modal.entered,
+.modal.exiting {
+ transform: translateY(5px);
+ flex-direction: column;
+}
+
+/* This rule is active when the screen is not narrow */
+@media (min-width: 580px) {
+ .modal {
+ width: 50%;
+ }
+}
+
+@media (min-height: 340px) {
+ .modal.entered,
+ .modal.exiting {
+ transform: translateY(30px);
+ }
+}
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..dec65e627b
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Modal.js
@@ -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/>. */
+
+import PropTypes from "prop-types";
+import React from "react";
+import Transition from "react-transition-group/Transition";
+const classnames = require("devtools/client/shared/classnames.js");
+import "./Modal.css";
+
+export const transitionTimeout = 50;
+
+export class Modal extends React.Component {
+ static get propTypes() {
+ return {
+ additionalClass: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ handleClose: PropTypes.func.isRequired,
+ status: PropTypes.string.isRequired,
+ };
+ }
+
+ onClick = e => {
+ e.stopPropagation();
+ };
+
+ render() {
+ const { additionalClass, children, handleClose, status } = this.props;
+
+ return (
+ <div className="modal-wrapper" onClick={handleClose}>
+ <div
+ className={classnames("modal", additionalClass, status)}
+ onClick={this.onClick}
+ >
+ {children}
+ </div>
+ </div>
+ );
+ }
+}
+
+Modal.contextTypes = {
+ shortcuts: PropTypes.object,
+};
+
+export default function Slide({
+ in: inProp,
+ children,
+ additionalClass,
+ handleClose,
+}) {
+ return (
+ <Transition in={inProp} timeout={transitionTimeout} appear>
+ {status => (
+ <Modal
+ status={status}
+ additionalClass={additionalClass}
+ handleClose={handleClose}
+ >
+ {children}
+ </Modal>
+ )}
+ </Transition>
+ );
+}
+
+Slide.propTypes = {
+ additionalClass: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ handleClose: PropTypes.func.isRequired,
+ in: PropTypes.bool.isRequired,
+};
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..fde7d40a21
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/Popover.js
@@ -0,0 +1,299 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import BracketArrow from "./BracketArrow";
+import SmartGap from "./SmartGap";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./Popover.css";
+
+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();
+ }
+
+ 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)}>
+ <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}
+ />
+ </div>
+ );
+ }
+
+ 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 <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()}
+ </div>
+ );
+ }
+
+ renderTooltip() {
+ const { top, left, orientation } = this.state.coords;
+ return (
+ <div
+ className={`tooltip orientation-${orientation}`}
+ style={{ top, left }}
+ ref={c => (this.$tooltip = c)}
+ >
+ {this.getChildren()}
+ </div>
+ );
+ }
+
+ 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..760a45db5d
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/PreviewFunction.js
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import { formatDisplayName } from "../../utils/pause/frames";
+
+import "./PreviewFunction.css";
+
+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}</span>;
+ }
+
+ renderParams(func) {
+ const { parameterNames = [] } = func;
+
+ return parameterNames
+ .filter(Boolean)
+ .map((param, i, arr) => {
+ const elements = [
+ <span className="param" key={param}>
+ {param}
+ </span>,
+ ];
+ // if this isn't the last param, add a comma
+ if (i !== arr.length - 1) {
+ elements.push(
+ <span className="delimiter" key={i}>
+ {", "}
+ </span>
+ );
+ }
+ 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">(</span>
+ {this.renderParams(func)}
+ <span className="paren">)</span>
+ {this.jumpToDefinitionButton(func)}
+ </span>
+ );
+ }
+}
+
+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..bb915b8f24
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/ResultList.js
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+import AccessibleImage from "./AccessibleImage";
+
+const classnames = require("devtools/client/shared/classnames.js");
+
+import "./ResultList.css";
+
+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"]),
+ };
+ }
+
+ 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}`,
+ ref: String(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">
+ <AccessibleImage className={item.icon} />
+ </div>
+ )}
+ <div id={`${item.id}-title`} className="title">
+ {item.title}
+ </div>
+ {item.subtitle != item.title ? (
+ <div id={`${item.id}-subtitle`} className="subtitle">
+ {item.subtitle}
+ </div>
+ ) : null}
+ </li>
+ );
+ };
+
+ render() {
+ const { size, items, role } = this.props;
+
+ return (
+ <ul
+ className={classnames("result-list", size)}
+ id="result-list"
+ role={role}
+ aria-live="polite"
+ >
+ {items.map(this.renderListItem)}
+ </ul>
+ );
+ }
+}
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..33d217321a
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SearchInput.css
@@ -0,0 +1,225 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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%;
+ background-color: var(--theme-toolbar-background);
+}
+
+.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;
+ outline: none;
+ padding: 4px;
+ padding-inline-start: 28px;
+ line-height: 16px;
+ font-family: inherit;
+ font-size: inherit;
+ color: var(--theme-body-color);
+ background-color: transparent;
+}
+
+.exclude-patterns-field {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ flex-shrink: 0;
+ min-height: 24px;
+ width: 100%;
+ background-color: var(--theme-toolbar-background);
+ border-top: 1px solid var(--theme-splitter-color);
+ margin-top: 1px;
+}
+
+.exclude-patterns-field input:focus {
+ outline: 1px solid var(--blue-50);
+}
+
+.exclude-patterns-field label {
+ padding-inline-start: 8px;
+ padding-top: 5px;
+ padding-bottom: 3px;
+ align-self: stretch;
+ background-color: var(--theme-body-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;
+}
+
+.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:focus-within {
+ outline: 1px solid var(--blue-50);
+}
+
+.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;
+}
+
+.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;
+ background-color: var(--theme-toolbar-background);
+ 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..c07d7c86c7
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SearchInput.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 "react";
+import PropTypes from "prop-types";
+import { connect } from "../../utils/connect";
+import { CloseButton } from "./Button";
+
+import AccessibleImage from "./AccessibleImage";
+import actions from "../../actions";
+import "./SearchInput.css";
+import { getSearchOptions } from "../../selectors";
+
+const classnames = require("devtools/client/shared/classnames.js");
+const SearchModifiers = require("devtools/client/shared/components/SearchModifiers");
+
+const arrowBtn = (onClick, type, className, tooltip) => {
+ const props = {
+ className,
+ key: type,
+ onClick,
+ title: tooltip,
+ type,
+ };
+
+ return (
+ <button {...props}>
+ <AccessibleImage className={type} />
+ </button>
+ );
+};
+
+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}</div>;
+ }
+
+ renderSpinner() {
+ const { isLoading } = this.props;
+ if (!isLoading) {
+ return null;
+ }
+ return <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()}</div>
+ );
+ }
+
+ renderSearchModifiers() {
+ if (!this.props.showSearchModifiers) {
+ return null;
+ }
+ return (
+ <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>{this.props.excludePatternsLabel}</label>
+ <input
+ placeholder={this.props.excludePatternsPlaceholder}
+ value={this.state.excludePatterns}
+ onKeyDown={this.onExcludeKeyDown}
+ onChange={e => this.setState({ excludePatterns: e.target.value })}
+ />
+ </div>
+ );
+ }
+
+ renderClose() {
+ if (!this.props.showClose) {
+ return null;
+ }
+ return (
+ <React.Fragment>
+ <span className="pipe-divider" />
+ <CloseButton
+ handleClick={this.props.handleClose}
+ buttonClass={this.props.size}
+ />
+ </React.Fragment>
+ );
+ }
+
+ 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}
+ >
+ <AccessibleImage className="search" />
+ <input {...inputProps} />
+ {this.renderSpinner()}
+ {this.renderSummaryMsg()}
+ {this.renderNav()}
+ <div className="search-buttons-bar">
+ {this.renderSearchModifiers()}
+ {this.renderClose()}
+ </div>
+ </div>
+ {this.renderExcludePatterns()}
+ </div>
+ );
+ }
+}
+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..785d7496fb
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SmartGap.js
@@ -0,0 +1,166 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import PropTypes from "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" />
+ </svg>
+ );
+}
+
+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..fed2e01f57
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/SourceIcon.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React, { PureComponent } from "react";
+import PropTypes from "prop-types";
+
+import { connect } from "../../utils/connect";
+
+import AccessibleImage from "./AccessibleImage";
+
+import { getSourceClassnames } from "../../utils/source";
+import { getSymbols, isSourceBlackBoxed, hasPrettyTab } from "../../selectors";
+
+import "./SourceIcon.css";
+
+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 <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.url);
+
+ // 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..c15dbb827c
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Accordion.spec.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 from "react";
+import { shallow } from "enzyme";
+
+import Accordion from "../Accordion";
+
+describe("Accordion", () => {
+ const testItems = [
+ {
+ header: "Test Accordion Item 1",
+ className: "accordion-item-1",
+ component: <div />,
+ opened: false,
+ onToggle: jest.fn(),
+ },
+ {
+ header: "Test Accordion Item 2",
+ className: "accordion-item-2",
+ component: <div />,
+ buttons: <button />,
+ opened: false,
+ onToggle: jest.fn(),
+ },
+ {
+ header: "Test Accordion Item 3",
+ className: "accordion-item-3",
+ component: <div />,
+ opened: true,
+ onToggle: jest.fn(),
+ },
+ ];
+ const wrapper = shallow(<Accordion items={testItems} />);
+ it("basic render", () => expect(wrapper).toMatchSnapshot());
+ wrapper.find(".accordion-item-1 ._header").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..6a10b7f9e4
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Badge.spec.js
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import Badge from "../Badge";
+
+describe("Badge", () => {
+ it("render", () => expect(shallow(<Badge>{3}</Badge>)).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..37f58fbfdc
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/BracketArrow.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 "react";
+import { shallow } from "enzyme";
+
+import BracketArrow from "../BracketArrow";
+
+describe("BracketArrow", () => {
+ const wrapper = shallow(
+ <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..b01f6fa059
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Dropdown.spec.js
@@ -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/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import Dropdown from "../Dropdown";
+
+describe("Dropdown", () => {
+ const wrapper = shallow(<Dropdown panel={<div />} 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..d609d3fda0
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Modal.spec.js
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import { Modal } from "../Modal";
+
+describe("Modal", () => {
+ it("renders", () => {
+ const wrapper = shallow(<Modal handleClose={() => {}} status="entering" />);
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("handles close modal click", () => {
+ const handleCloseSpy = jest.fn();
+ const wrapper = shallow(
+ <Modal handleClose={handleCloseSpy} status="entering" />
+ );
+ wrapper.find(".modal-wrapper").simulate("click");
+ expect(handleCloseSpy).toHaveBeenCalled();
+ });
+
+ it("renders children", () => {
+ const children = <div className="aChild" />;
+ const wrapper = shallow(
+ <Modal children={children} handleClose={() => {}} status="entering" />
+ );
+ expect(wrapper.find(".aChild")).toHaveLength(1);
+ });
+
+ it("passes additionalClass to child div class", () => {
+ const additionalClass = "testAddon";
+ const wrapper = shallow(
+ <Modal
+ additionalClass={additionalClass}
+ handleClose={() => {}}
+ status="entering"
+ />
+ );
+ expect(wrapper.find(`.modal-wrapper .${additionalClass}`)).toHaveLength(1);
+ });
+
+ it("passes status to child div class", () => {
+ const status = "testStatus";
+ const wrapper = shallow(<Modal status={status} handleClose={() => {}} />);
+ expect(wrapper.find(`.modal-wrapper .${status}`)).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..fb44f16597
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/Popover.spec.js
@@ -0,0 +1,200 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { mount } from "enzyme";
+
+import 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(
+ <Popover
+ onMouseLeave={onMouseLeave}
+ onKeyDown={onKeyDown}
+ editorRef={editorRef}
+ targetPosition={targetPosition}
+ mouseout={() => {}}
+ target={targetRef}
+ >
+ <h1>Poppy!</h1>
+ </Popover>
+ );
+
+ const tooltip = mount(
+ <Popover
+ type="tooltip"
+ onMouseLeave={onMouseLeave}
+ onKeyDown={onKeyDown}
+ editorRef={editorRef}
+ targetPosition={targetPosition}
+ mouseout={() => {}}
+ target={targetRef}
+ >
+ <h1>Toolie!</h1>
+ </Popover>
+ );
+
+ beforeEach(() => {
+ onMouseLeave.mockClear();
+ onKeyDown.mockClear();
+ });
+
+ it("render", () => expect(popover).toMatchSnapshot());
+
+ it("render (tooltip)", () => expect(tooltip).toMatchSnapshot());
+
+ it("mount popover", () => {
+ const mountedPopover = mount(
+ <Popover
+ onMouseLeave={onMouseLeave}
+ onKeyDown={onKeyDown}
+ editorRef={editorRef}
+ targetPosition={targetPosition}
+ mouseout={() => {}}
+ target={targetRef}
+ >
+ <h1>Poppy!</h1>
+ </Popover>
+ );
+ expect(mountedPopover).toMatchSnapshot();
+ });
+
+ it("mount tooltip", () => {
+ const mountedTooltip = mount(
+ <Popover
+ type="tooltip"
+ onMouseLeave={onMouseLeave}
+ onKeyDown={onKeyDown}
+ editorRef={editorRef}
+ targetPosition={targetPosition}
+ mouseout={() => {}}
+ target={targetRef}
+ >
+ <h1>Toolie!</h1>
+ </Popover>
+ );
+ 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(
+ <Popover
+ type="tooltip"
+ onMouseLeave={onMouseLeave}
+ onKeyDown={onKeyDown}
+ editorRef={editor}
+ targetPosition={target}
+ mouseout={() => {}}
+ target={targetRef}
+ >
+ <h1>Toolie!</h1>
+ </Popover>
+ );
+
+ 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(
+ <Popover
+ type="tooltip"
+ onMouseLeave={onMouseLeave}
+ onKeyDown={onKeyDown}
+ editorRef={editor}
+ targetPosition={target}
+ mouseout={() => {}}
+ target={targetRef}
+ >
+ <h1>Toolie!</h1>
+ </Popover>
+ );
+
+ 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..391e5628df
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/PreviewFunction.spec.js
@@ -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/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import PreviewFunction from "../PreviewFunction";
+
+function render(props) {
+ return shallow(<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: "",
+ userDisplayName: "chuck",
+ displayName: "norris",
+ };
+ 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..2751f3abd6
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/ResultList.spec.js
@@ -0,0 +1,49 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import 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(<ResultList {...payload} />);
+
+ wrapper.childAt(selectedIndex).simulate("click");
+ expect(selectItem).toHaveBeenCalled();
+ });
+
+ it("should render the component", () => {
+ const wrapper = shallow(<ResultList {...payload} />);
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("selected index should have 'selected class'", () => {
+ const wrapper = shallow(<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..c0fff81b24
--- /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 "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(
+ <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..7ab4ed1ee6
--- /dev/null
+++ b/devtools/client/debugger/src/components/shared/tests/__snapshots__/Accordion.spec.js.snap
@@ -0,0 +1,84 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Accordion basic render 1`] = `
+<ul
+ className="accordion"
+>
+ <li
+ className="accordion-item-1"
+ key="0"
+ >
+ <h2
+ className="_header"
+ onClick={[Function]}
+ onKeyDown={[Function]}
+ tabIndex="0"
+ >
+ <AccessibleImage
+ className="arrow expanded"
+ />
+ <span
+ className="header-label"
+ >
+ Test Accordion Item 1
+ </span>
+ </h2>
+ <div
+ className="_content"
+ >
+ <div />
+ </div>
+ </li>
+ <li
+ className="accordion-item-2"
+ key="1"
+ >
+ <h2
+ className="_header"
+ onClick={[Function]}
+ onKeyDown={[Function]}
+ tabIndex="0"
+ >
+ <AccessibleImage
+ className="arrow "
+ />
+ <span
+ className="header-label"
+ >
+ Test Accordion Item 2
+ </span>
+ <div
+ className="header-buttons"
+ tabIndex="-1"
+ >
+ <button />
+ </div>
+ </h2>
+ </li>
+ <li
+ className="accordion-item-3"
+ key="2"
+ >
+ <h2
+ className="_header"
+ onClick={[Function]}
+ onKeyDown={[Function]}
+ tabIndex="0"
+ >
+ <AccessibleImage
+ className="arrow expanded"
+ />
+ <span
+ className="header-label"
+ >
+ Test Accordion Item 3
+ </span>
+ </h2>
+ <div
+ className="_content"
+ >
+ <div />
+ </div>
+ </li>
+</ul>
+`;
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..e9b9639749
--- /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 entering"
+ 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/A11yIntention.spec.js b/devtools/client/debugger/src/components/test/A11yIntention.spec.js
new file mode 100644
index 0000000000..6a529b851d
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/A11yIntention.spec.js
@@ -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/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import A11yIntention from "../A11yIntention";
+
+function render() {
+ return shallow(
+ <A11yIntention>
+ <span>hello world</span>
+ </A11yIntention>
+ );
+}
+
+describe("A11yIntention", () => {
+ it("renders its children", () => {
+ const component = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("indicates that the mouse or keyboard is being used", () => {
+ const component = render();
+ expect(component.prop("className")).toEqual("A11y-mouse");
+
+ component.simulate("keyDown");
+ expect(component.prop("className")).toEqual("A11y-keyboard");
+
+ component.simulate("mouseDown");
+ expect(component.prop("className")).toEqual("A11y-mouse");
+ });
+});
diff --git a/devtools/client/debugger/src/components/test/Outline.spec.js b/devtools/client/debugger/src/components/test/Outline.spec.js
new file mode 100644
index 0000000000..c104da53c3
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/Outline.spec.js
@@ -0,0 +1,304 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import Outline from "../../components/PrimaryPanes/Outline";
+import { makeSymbolDeclaration } from "../../utils/test-head";
+import { mockcx } from "../../utils/test-mockup";
+import { showMenu } from "../../context-menu/menu";
+import { copyToTheClipboard } from "../../utils/clipboard";
+
+jest.mock("../../context-menu/menu", () => ({ showMenu: jest.fn() }));
+jest.mock("../../utils/clipboard", () => ({ copyToTheClipboard: jest.fn() }));
+
+const sourceId = "id";
+const mockFunctionText = "mock function text";
+
+function generateDefaults(overrides) {
+ return {
+ cx: mockcx,
+ selectLocation: jest.fn(),
+ selectedSource: { id: sourceId },
+ getFunctionText: jest.fn().mockReturnValue(mockFunctionText),
+ flashLineRange: jest.fn(),
+ isHidden: false,
+ symbols: {},
+ selectedLocation: { id: sourceId },
+ onAlphabetizeClick: jest.fn(),
+ ...overrides,
+ };
+}
+
+function render(overrides = {}) {
+ const props = generateDefaults(overrides);
+ const component = shallow(<Outline.WrappedComponent {...props} />);
+ const instance = component.instance();
+ return { component, props, instance };
+}
+
+describe("Outline", () => {
+ afterEach(() => {
+ copyToTheClipboard.mockClear();
+ showMenu.mockClear();
+ });
+
+ it("renders a list of functions when properties change", async () => {
+ const symbols = {
+ functions: [
+ makeSymbolDeclaration("my_example_function1", 21),
+ makeSymbolDeclaration("my_example_function2", 22),
+ ],
+ };
+
+ const { component } = render({ symbols });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("selects a line of code in the current file on click", async () => {
+ const startLine = 12;
+ const symbols = {
+ functions: [makeSymbolDeclaration("my_example_function", startLine)],
+ };
+
+ const { component, props } = render({ symbols });
+
+ const { selectLocation } = props;
+ const listItem = component.find("li").first();
+ listItem.simulate("click");
+ expect(selectLocation).toHaveBeenCalledWith(mockcx, {
+ line: startLine,
+ column: undefined,
+ sourceId,
+ source: {
+ id: sourceId,
+ },
+ sourceActor: null,
+ sourceActorId: undefined,
+ sourceUrl: "",
+ });
+ });
+
+ describe("renders outline", () => {
+ describe("renders loading", () => {
+ it("if symbols is not defined", () => {
+ const { component } = render({
+ symbols: null,
+ });
+ expect(component).toMatchSnapshot();
+ });
+ });
+
+ it("renders ignore anonymous functions", async () => {
+ const symbols = {
+ functions: [
+ makeSymbolDeclaration("my_example_function1", 21),
+ makeSymbolDeclaration("anonymous", 25),
+ ],
+ };
+
+ const { component } = render({ symbols });
+ expect(component).toMatchSnapshot();
+ });
+ describe("renders placeholder", () => {
+ it("`No File Selected` if selectedSource is not defined", async () => {
+ const { component } = render({
+ selectedSource: null,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("`No functions` if all func are anonymous", async () => {
+ const symbols = {
+ functions: [
+ makeSymbolDeclaration("anonymous", 25),
+ makeSymbolDeclaration("anonymous", 30),
+ ],
+ };
+
+ const { component } = render({ symbols });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("`No functions` if symbols has no func", async () => {
+ const symbols = {
+ functions: [],
+ };
+ const { component } = render({ symbols });
+ expect(component).toMatchSnapshot();
+ });
+ });
+
+ it("sorts functions alphabetically by function name", async () => {
+ const symbols = {
+ functions: [
+ makeSymbolDeclaration("c_function", 25),
+ makeSymbolDeclaration("x_function", 30),
+ makeSymbolDeclaration("a_function", 70),
+ ],
+ };
+
+ const { component } = render({
+ symbols,
+ alphabetizeOutline: true,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("calls onAlphabetizeClick when sort button is clicked", async () => {
+ const symbols = {
+ functions: [makeSymbolDeclaration("example_function", 25)],
+ };
+
+ const { component, props } = render({ symbols });
+
+ await component
+ .find(".outline-footer")
+ .find("button")
+ .simulate("click", {});
+
+ expect(props.onAlphabetizeClick).toHaveBeenCalled();
+ });
+
+ it("renders functions by function class", async () => {
+ const symbols = {
+ functions: [
+ makeSymbolDeclaration("x_function", 25, 26, "x_klass"),
+ makeSymbolDeclaration("a2_function", 30, 31, "a_klass"),
+ makeSymbolDeclaration("a1_function", 70, 71, "a_klass"),
+ ],
+ classes: [
+ makeSymbolDeclaration("x_klass", 24, 27),
+ makeSymbolDeclaration("a_klass", 29, 72),
+ ],
+ };
+
+ const { component } = render({ symbols });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("renders functions by function class, alphabetically", async () => {
+ const symbols = {
+ functions: [
+ makeSymbolDeclaration("x_function", 25, 26, "x_klass"),
+ makeSymbolDeclaration("a2_function", 30, 31, "a_klass"),
+ makeSymbolDeclaration("a1_function", 70, 71, "a_klass"),
+ ],
+ classes: [
+ makeSymbolDeclaration("x_klass", 24, 27),
+ makeSymbolDeclaration("a_klass", 29, 72),
+ ],
+ };
+
+ const { component } = render({
+ symbols,
+ alphabetizeOutline: true,
+ });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("selects class on click on class headline", async () => {
+ const symbols = {
+ functions: [makeSymbolDeclaration("x_function", 25, 26, "x_klass")],
+ classes: [makeSymbolDeclaration("x_klass", 24, 27)],
+ };
+
+ const { component, props } = render({ symbols });
+
+ await component.find("h2").simulate("click", {});
+
+ expect(props.selectLocation).toHaveBeenCalledWith(mockcx, {
+ line: 24,
+ column: undefined,
+ sourceId,
+ source: {
+ id: sourceId,
+ },
+ sourceActor: null,
+ sourceActorId: undefined,
+ sourceUrl: "",
+ });
+ });
+
+ it("does not select an item if selectedSource is not defined", async () => {
+ const { instance, props } = render({ selectedSource: null });
+ await instance.selectItem({});
+ expect(props.selectLocation).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("onContextMenu of Outline", () => {
+ it("is called onContextMenu for each item", async () => {
+ const event = { event: "oncontextmenu" };
+ const fn = makeSymbolDeclaration("exmple_function", 2);
+ const symbols = {
+ functions: [fn],
+ };
+
+ const { component, instance } = render({ symbols });
+ instance.onContextMenu = jest.fn(() => {});
+ await component
+ .find(".outline-list__element")
+ .simulate("contextmenu", event);
+
+ expect(instance.onContextMenu).toHaveBeenCalledWith(event, fn);
+ });
+
+ it("does not show menu with no selected source", async () => {
+ const mockEvent = {
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ };
+ const { instance } = render({
+ selectedSource: null,
+ });
+ await instance.onContextMenu(mockEvent, {});
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
+ expect(mockEvent.stopPropagation).toHaveBeenCalled();
+ expect(showMenu).not.toHaveBeenCalled();
+ });
+
+ it("shows menu to copy func, copies to clipboard on click", async () => {
+ const startLine = 12;
+ const endLine = 21;
+ const func = makeSymbolDeclaration(
+ "my_example_function",
+ startLine,
+ endLine
+ );
+ const symbols = {
+ functions: [func],
+ };
+ const mockEvent = {
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ };
+ const { instance, props } = render({ symbols });
+ await instance.onContextMenu(mockEvent, func);
+
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
+ expect(mockEvent.stopPropagation).toHaveBeenCalled();
+
+ const expectedMenuOptions = [
+ {
+ accesskey: "F",
+ click: expect.any(Function),
+ disabled: false,
+ id: "node-menu-copy-function",
+ label: "Copy function",
+ },
+ ];
+ expect(props.getFunctionText).toHaveBeenCalledWith(12);
+ expect(showMenu).toHaveBeenCalledWith(mockEvent, expectedMenuOptions);
+
+ showMenu.mock.calls[0][1][0].click();
+ expect(copyToTheClipboard).toHaveBeenCalledWith(mockFunctionText);
+ expect(props.flashLineRange).toHaveBeenCalledWith({
+ end: endLine,
+ sourceId,
+ start: startLine,
+ });
+ });
+ });
+});
diff --git a/devtools/client/debugger/src/components/test/OutlineFilter.spec.js b/devtools/client/debugger/src/components/test/OutlineFilter.spec.js
new file mode 100644
index 0000000000..91ec7c0d97
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/OutlineFilter.spec.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 React from "react";
+import { shallow } from "enzyme";
+import OutlineFilter from "../../components/PrimaryPanes/OutlineFilter";
+
+function generateDefaults(overrides) {
+ return {
+ filter: "",
+ updateFilter: jest.fn(),
+ ...overrides,
+ };
+}
+
+function render(overrides = {}) {
+ const props = generateDefaults(overrides);
+ const component = shallow(<OutlineFilter {...props} />);
+ const instance = component.instance();
+ return { component, props, instance };
+}
+
+describe("OutlineFilter", () => {
+ it("shows an input with no value when filter is empty", async () => {
+ const { component } = render({ filter: "" });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("shows an input with the filter when it is not empty", async () => {
+ const { component } = render({ filter: "abc" });
+ expect(component).toMatchSnapshot();
+ });
+
+ it("calls props.updateFilter on change", async () => {
+ const updateFilter = jest.fn();
+ const { component } = render({ updateFilter });
+ const input = component.find("input");
+ input.simulate("change", { target: { value: "a" } });
+ input.simulate("change", { target: { value: "ab" } });
+ expect(updateFilter).toHaveBeenCalled();
+ expect(updateFilter.mock.calls[0][0]).toBe("a");
+ expect(updateFilter.mock.calls[1][0]).toBe("ab");
+ });
+});
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..3cd21bac05
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/QuickOpenModal.spec.js
@@ -0,0 +1,898 @@
+/* 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 "react";
+import { Provider } from "react-redux";
+import configureStore from "redux-mock-store";
+
+import { shallow, mount } from "enzyme";
+import { QuickOpenModal } from "../QuickOpenModal";
+import { mockcx } from "../../utils/test-mockup";
+import { getDisplayURL } from "../../utils/sources-tree/getURL";
+import { searchKeys } from "../../constants";
+
+jest.mock("fuzzaldrin-plus");
+
+import { filter } from "fuzzaldrin-plus";
+
+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 = {
+ cx: mockcx,
+ enabled: false,
+ query: "",
+ searchType: "sources",
+ displayedSources: [],
+ blackBoxRanges: {},
+ tabUrls: [],
+ selectSpecificLocation: jest.fn(),
+ setQuickOpenQuery: jest.fn(),
+ highlightLineRange: jest.fn(),
+ clearHighlightLineRange: jest.fn(),
+ closeQuickOpen: jest.fn(),
+ shortcutsModalEnabled: false,
+ symbols: { functions: [] },
+ symbolsLoading: 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,
+ };
+}
+
+function generateQuickOpenResult(title) {
+ return {
+ id: "qor",
+ value: "",
+ title,
+ };
+}
+
+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",
+ symbols: {
+ functions: [],
+ variables: [],
+ },
+ },
+ "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"),
+ },
+ ],
+ tabUrls: ["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",
+ symbolsLoading: true,
+ },
+ "shallow"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+ });
+
+ test("Ensure anonymous functions do not render in QuickOpenModal", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "@",
+ searchType: "functions",
+ symbols: {
+ functions: [
+ generateQuickOpenResult("anonymous"),
+ generateQuickOpenResult("c"),
+ generateQuickOpenResult("anonymous"),
+ ],
+ variables: [],
+ },
+ },
+ "mount"
+ );
+ expect(wrapper.find("ResultList")).toHaveLength(1);
+ expect(wrapper.find("li")).toHaveLength(1);
+ });
+
+ test("Basic render with mount & searchType = variables", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "#",
+ searchType: "variables",
+ symbols: {
+ functions: [],
+ variables: [],
+ },
+ },
+ "mount"
+ );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ test("Basic render with mount & searchType = shortcuts", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ query: "?",
+ searchType: "shortcuts",
+ symbols: {
+ functions: [],
+ variables: [],
+ },
+ },
+ "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,
+ symbols: {
+ functions: [],
+ variables: [],
+ },
+ },
+ "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",
+ symbols: {
+ functions: [],
+ variables: [],
+ },
+ },
+ "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",
+ symbols: {
+ functions: [],
+ variables: [],
+ },
+ // symbol searching relies on a source being selected.
+ // So we dummy out the source and the API.
+ selectedSource: { id: "foo", text: "yo" },
+ selectedContentLoaded: true,
+ },
+ "mount"
+ );
+
+ wrapper
+ .find("input")
+ .simulate("change", { target: { value: "@someFunc" } });
+ await waitForUpdateResultsThrottle();
+ expect(filter).toHaveBeenCalledWith([], "someFunc", {
+ key: "value",
+ maxResults: 100,
+ });
+ });
+
+ it("does not do symbol search if no selected source", () => {
+ const { wrapper } = generateModal(
+ {
+ enabled: true,
+ searchType: "functions",
+ symbols: {
+ functions: [],
+ variables: [],
+ },
+ // symbol searching relies on a source being selected.
+ // So we dummy out the source and the API.
+ selectedSource: 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",
+ symbols: {
+ functions: [],
+ variables: [],
+ },
+ },
+ "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",
+ selectedSource: { id: "foo" },
+ },
+ "shallow"
+ );
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, {
+ column: 12,
+ line: 34,
+ sourceId: "foo",
+ source: {
+ id: "foo",
+ },
+ sourceActorId: undefined,
+ sourceActor: null,
+ sourceUrl: "",
+ });
+ });
+
+ it("on Enter go to location with sourceId", () => {
+ const sourceId = "source_id";
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: ":34:12",
+ searchType: "goto",
+ selectedSource: { id: sourceId },
+ selectedContentLoaded: true,
+ },
+ "shallow"
+ );
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, {
+ column: 12,
+ line: 34,
+ sourceId,
+ source: {
+ id: sourceId,
+ },
+ sourceActorId: undefined,
+ sourceActor: null,
+ sourceUrl: "",
+ });
+ });
+
+ 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",
+ selectedSource: { id },
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [{}, { id }],
+ selectedIndex: 1,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, {
+ column: undefined,
+ sourceId: id,
+ line: 0,
+ source: { id },
+ sourceActorId: undefined,
+ sourceActor: null,
+ sourceUrl: "",
+ });
+ 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",
+ symbols: {
+ functions: [],
+ variables: {},
+ },
+ selectedSource: { id },
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [{}, { id }],
+ selectedIndex: 1,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, {
+ column: undefined,
+ line: 0,
+ sourceId: id,
+ source: { id },
+ sourceActorId: undefined,
+ sourceActor: null,
+ sourceUrl: "",
+ });
+ 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",
+ symbols: {
+ functions: [],
+ variables: {},
+ },
+ selectedSource: { id },
+ },
+ "shallow"
+ );
+ wrapper.setState(() => ({
+ results: [{}, { id }],
+ selectedIndex: 1,
+ }));
+ const event = {
+ key: "Enter",
+ };
+ wrapper.find("Connect(SearchInput)").simulate("keydown", event);
+ expect(props.selectSpecificLocation).toHaveBeenCalledWith(mockcx, {
+ column: 4,
+ line: 3,
+ sourceId: id,
+ source: { id },
+ sourceActorId: undefined,
+ sourceActor: null,
+ sourceUrl: "",
+ });
+ expect(props.setQuickOpenQuery).not.toHaveBeenCalled();
+ });
+
+ it("on Enter with results, handle shortcuts search", () => {
+ const { wrapper, props } = generateModal(
+ {
+ enabled: true,
+ query: "@",
+ searchType: "shortcuts",
+ symbols: {
+ functions: [],
+ variables: {},
+ },
+ },
+ "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",
+ selectedSource: { id: sourceId },
+ selectedContentLoaded: true,
+ symbols: {
+ functions: [],
+ variables: {},
+ },
+ },
+ "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",
+ selectedSource: { id: sourceId },
+ selectedContentLoaded: true,
+ symbols: {
+ functions: [],
+ variables: {},
+ },
+ },
+ "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",
+ selectedSource: null,
+ selectedContentLoaded: true,
+ symbols: {
+ functions: [],
+ variables: {},
+ },
+ },
+ "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",
+ selectedSource: { id: sourceId },
+ selectedContentLoaded: true,
+ symbols: {
+ functions: [],
+ variables: {},
+ },
+ },
+ "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/ShortcutsModal.spec.js b/devtools/client/debugger/src/components/test/ShortcutsModal.spec.js
new file mode 100644
index 0000000000..d3264c02e0
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/ShortcutsModal.spec.js
@@ -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/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import { ShortcutsModal } from "../ShortcutsModal";
+
+function render(overrides = {}) {
+ const props = {
+ enabled: true,
+ handleClose: jest.fn(),
+ ...overrides,
+ };
+ const component = shallow(<ShortcutsModal {...props} />);
+
+ return { component, props };
+}
+
+describe("ShortcutsModal", () => {
+ it("renders when enabled", () => {
+ const { component } = render();
+ expect(component).toMatchSnapshot();
+ });
+
+ it("renders nothing when not enabled", () => {
+ const { component } = render({
+ enabled: false,
+ });
+ expect(component.text()).toBe("");
+ });
+});
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..0a1dbc7459
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/WelcomeBox.spec.js
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+
+import { 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(<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..eff87c7cd1
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/WhyPaused.spec.js
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+
+import React from "react";
+import { shallow } from "enzyme";
+import WhyPaused from "../SecondaryPanes/WhyPaused.js";
+
+function render(why, delay) {
+ const props = { why, delay };
+ const component = shallow(<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__/A11yIntention.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap
new file mode 100644
index 0000000000..80fdfa1dec
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/__snapshots__/A11yIntention.spec.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`A11yIntention renders its children 1`] = `
+<div
+ className="A11y-mouse"
+ onKeyDown={[Function]}
+ onMouseDown={[Function]}
+>
+ <span>
+ hello world
+ </span>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap
new file mode 100644
index 0000000000..4e2e2c98fd
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/__snapshots__/Outline.spec.js.snap
@@ -0,0 +1,505 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Outline renders a list of functions when properties change 1`] = `
+<div
+ className="outline"
+>
+ <div>
+ <OutlineFilter
+ filter=""
+ updateFilter={[Function]}
+ />
+ <ul
+ className="outline-list devtools-monospace"
+ dir="ltr"
+ >
+ <li
+ className="outline-list__element"
+ key="my_example_function1:21:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "my_example_function1",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ <li
+ className="outline-list__element"
+ key="my_example_function2:22:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "my_example_function2",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ </ul>
+ <div
+ className="outline-footer"
+ >
+ <button
+ className=""
+ onClick={[MockFunction]}
+ >
+ Sort by name
+ </button>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Outline renders outline renders functions by function class 1`] = `
+<div
+ className="outline"
+>
+ <div>
+ <OutlineFilter
+ filter=""
+ updateFilter={[Function]}
+ />
+ <ul
+ className="outline-list devtools-monospace"
+ dir="ltr"
+ >
+ <li
+ className="outline-list__class"
+ key="x_klass"
+ >
+ <h2
+ className=""
+ onClick={[Function]}
+ >
+ <div>
+ <span
+ className="keyword"
+ >
+ class
+ </span>
+
+ x_klass
+ </div>
+ </h2>
+ <ul
+ className="outline-list__class-list"
+ >
+ <li
+ className="outline-list__element"
+ key="x_function:25:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "x_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ </ul>
+ </li>
+ <li
+ className="outline-list__class"
+ key="a_klass"
+ >
+ <h2
+ className=""
+ onClick={[Function]}
+ >
+ <div>
+ <span
+ className="keyword"
+ >
+ class
+ </span>
+
+ a_klass
+ </div>
+ </h2>
+ <ul
+ className="outline-list__class-list"
+ >
+ <li
+ className="outline-list__element"
+ key="a2_function:30:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "a2_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ <li
+ className="outline-list__element"
+ key="a1_function:70:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "a1_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <div
+ className="outline-footer"
+ >
+ <button
+ className=""
+ onClick={[MockFunction]}
+ >
+ Sort by name
+ </button>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Outline renders outline renders functions by function class, alphabetically 1`] = `
+<div
+ className="outline"
+>
+ <div>
+ <OutlineFilter
+ filter=""
+ updateFilter={[Function]}
+ />
+ <ul
+ className="outline-list devtools-monospace"
+ dir="ltr"
+ >
+ <li
+ className="outline-list__class"
+ key="a_klass"
+ >
+ <h2
+ className=""
+ onClick={[Function]}
+ >
+ <div>
+ <span
+ className="keyword"
+ >
+ class
+ </span>
+
+ a_klass
+ </div>
+ </h2>
+ <ul
+ className="outline-list__class-list"
+ >
+ <li
+ className="outline-list__element"
+ key="a1_function:70:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "a1_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ <li
+ className="outline-list__element"
+ key="a2_function:30:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "a2_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ </ul>
+ </li>
+ <li
+ className="outline-list__class"
+ key="x_klass"
+ >
+ <h2
+ className=""
+ onClick={[Function]}
+ >
+ <div>
+ <span
+ className="keyword"
+ >
+ class
+ </span>
+
+ x_klass
+ </div>
+ </h2>
+ <ul
+ className="outline-list__class-list"
+ >
+ <li
+ className="outline-list__element"
+ key="x_function:25:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "x_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <div
+ className="outline-footer"
+ >
+ <button
+ className="active"
+ onClick={[MockFunction]}
+ >
+ Sort by name
+ </button>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Outline renders outline renders ignore anonymous functions 1`] = `
+<div
+ className="outline"
+>
+ <div>
+ <OutlineFilter
+ filter=""
+ updateFilter={[Function]}
+ />
+ <ul
+ className="outline-list devtools-monospace"
+ dir="ltr"
+ >
+ <li
+ className="outline-list__element"
+ key="my_example_function1:21:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "my_example_function1",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ </ul>
+ <div
+ className="outline-footer"
+ >
+ <button
+ className=""
+ onClick={[MockFunction]}
+ >
+ Sort by name
+ </button>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Outline renders outline renders loading if symbols is not defined 1`] = `
+<div
+ className="outline-pane-info"
+>
+ Loading…
+</div>
+`;
+
+exports[`Outline renders outline renders placeholder \`No File Selected\` if selectedSource is not defined 1`] = `
+<div
+ className="outline-pane-info"
+>
+ No file selected
+</div>
+`;
+
+exports[`Outline renders outline renders placeholder \`No functions\` if all func are anonymous 1`] = `
+<div
+ className="outline-pane-info"
+>
+ No functions
+</div>
+`;
+
+exports[`Outline renders outline renders placeholder \`No functions\` if symbols has no func 1`] = `
+<div
+ className="outline-pane-info"
+>
+ No functions
+</div>
+`;
+
+exports[`Outline renders outline sorts functions alphabetically by function name 1`] = `
+<div
+ className="outline"
+>
+ <div>
+ <OutlineFilter
+ filter=""
+ updateFilter={[Function]}
+ />
+ <ul
+ className="outline-list devtools-monospace"
+ dir="ltr"
+ >
+ <li
+ className="outline-list__element"
+ key="a_function:70:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "a_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ <li
+ className="outline-list__element"
+ key="c_function:25:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "c_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ <li
+ className="outline-list__element"
+ key="x_function:30:undefined"
+ onClick={[Function]}
+ onContextMenu={[Function]}
+ >
+ <span
+ className="outline-list__element-icon"
+ >
+ λ
+ </span>
+ <PreviewFunction
+ func={
+ Object {
+ "name": "x_function",
+ "parameterNames": undefined,
+ }
+ }
+ />
+ </li>
+ </ul>
+ <div
+ className="outline-footer"
+ >
+ <button
+ className="active"
+ onClick={[MockFunction]}
+ >
+ Sort by name
+ </button>
+ </div>
+ </div>
+</div>
+`;
diff --git a/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap
new file mode 100644
index 0000000000..c4e03b77cd
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/__snapshots__/OutlineFilter.spec.js.snap
@@ -0,0 +1,39 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`OutlineFilter shows an input with no value when filter is empty 1`] = `
+<div
+ className="outline-filter"
+>
+ <form>
+ <input
+ className="outline-filter-input devtools-filterinput"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Filter functions"
+ type="text"
+ value=""
+ />
+ </form>
+</div>
+`;
+
+exports[`OutlineFilter shows an input with the filter when it is not empty 1`] = `
+<div
+ className="outline-filter"
+>
+ <form>
+ <input
+ className="outline-filter-input devtools-filterinput"
+ onBlur={[Function]}
+ onChange={[Function]}
+ onFocus={[Function]}
+ onKeyDown={[Function]}
+ placeholder="Filter functions"
+ type="text"
+ value="abc"
+ />
+ </form>
+</div>
+`;
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..83d643a597
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/__snapshots__/QuickOpenModal.spec.js.snap
@@ -0,0 +1,1694 @@
+// 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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query="@"
+ searchType="functions"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ "variables": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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>
+ </Transition>
+ </Slide>
+ </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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query="#"
+ searchType="variables"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ "variables": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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)>
+ </div>
+ </div>
+ </Modal>
+ </Transition>
+ </Slide>
+ </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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query=""
+ searchType="sources"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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>
+ </Transition>
+ </Slide>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal Doesn't render when disabled 1`] = `""`;
+
+exports[`QuickOpenModal Renders when enabled 1`] = `
+<Slide
+ handleClose={[Function]}
+ in={true}
+>
+ <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"
+ />
+</Slide>
+`;
+
+exports[`QuickOpenModal Simple goto search query = :abc & searchType = goto 1`] = `
+<QuickOpenModal
+ blackBoxRanges={Object {}}
+ clearHighlightLineRange={[MockFunction]}
+ closeQuickOpen={[MockFunction]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query=":abc"
+ searchType="goto"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ "variables": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+>
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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>
+ </Transition>
+ </Slide>
+</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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query="dasdasdas"
+ searchType="sources"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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)>
+ </div>
+ </div>
+ </Modal>
+ </Transition>
+ </Slide>
+ </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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query=":2222"
+ searchType="goto"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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>
+ </Transition>
+ </Slide>
+ </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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query=""
+ searchType="other"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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>
+ </Transition>
+ </Slide>
+ </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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query=":22k22"
+ searchType="goto"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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>
+ </Transition>
+ </Slide>
+ </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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={true}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query="test"
+ searchType="other"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ >
+ <Slide
+ handleClose={[Function]}
+ in={true}
+ >
+ <Transition
+ appear={true}
+ enter={true}
+ exit={true}
+ in={true}
+ mountOnEnter={false}
+ onEnter={[Function]}
+ onEntered={[Function]}
+ onEntering={[Function]}
+ onExit={[Function]}
+ onExited={[Function]}
+ onExiting={[Function]}
+ timeout={50}
+ unmountOnExit={false}
+ >
+ <Modal
+ handleClose={[Function]}
+ status="entering"
+ >
+ <div
+ className="modal-wrapper"
+ onClick={[Function]}
+ >
+ <div
+ className="modal entering"
+ 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)>
+ </div>
+ </div>
+ </Modal>
+ </Transition>
+ </Slide>
+ </QuickOpenModal>
+</Provider>
+`;
+
+exports[`QuickOpenModal shows loading loads with function type search 1`] = `
+<Slide
+ handleClose={[Function]}
+ in={true}
+>
+ <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="Loading…"
+ />
+ <ResultList
+ expanded={false}
+ items={Array []}
+ key="results"
+ role="listbox"
+ selectItem={[Function]}
+ selected={0}
+ size="small"
+ />
+</Slide>
+`;
+
+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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={false}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query=""
+ searchType="sources"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ />
+</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]}
+ cx={
+ Object {
+ "navigateCounter": 0,
+ }
+ }
+ displayedSources={Array []}
+ enabled={false}
+ highlightLineRange={[MockFunction]}
+ isOriginal={false}
+ query=""
+ searchType="sources"
+ selectSpecificLocation={[MockFunction]}
+ setQuickOpenQuery={[MockFunction]}
+ shortcutsModalEnabled={false}
+ symbols={
+ Object {
+ "functions": Array [],
+ }
+ }
+ symbolsLoading={false}
+ tabUrls={Array []}
+ thread="FakeThread"
+ toggleShortcutsModal={[MockFunction]}
+ />
+</Provider>
+`;
diff --git a/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap b/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap
new file mode 100644
index 0000000000..06ddc45c91
--- /dev/null
+++ b/devtools/client/debugger/src/components/test/__snapshots__/ShortcutsModal.spec.js.snap
@@ -0,0 +1,190 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ShortcutsModal renders when enabled 1`] = `
+<Slide
+ additionalClass="shortcuts-modal"
+ handleClose={[MockFunction]}
+ in={true}
+>
+ <div
+ className="shortcuts-content"
+ >
+ <div
+ className="shortcuts-section"
+ >
+ <h2>
+ Editor
+ </h2>
+ <ul
+ className="shortcuts-list"
+ >
+ <li>
+ <span>
+ Toggle Breakpoint
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="Ctrl+B"
+ >
+ Ctrl+B
+ </span>
+ </span>
+ </li>
+ <li>
+ <span>
+ Edit Conditional Breakpoint
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="Ctrl+Shift+B"
+ >
+ Ctrl+Shift+B
+ </span>
+ </span>
+ </li>
+ <li>
+ <span>
+ Edit Log Point
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="Ctrl+Shift+Y"
+ >
+ Ctrl+Shift+Y
+ </span>
+ </span>
+ </li>
+ </ul>
+ </div>
+ <div
+ className="shortcuts-section"
+ >
+ <h2>
+ Stepping
+ </h2>
+ <ul
+ className="shortcuts-list"
+ >
+ <li>
+ <span>
+ Pause/Resume
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="F8"
+ >
+ F8
+ </span>
+ </span>
+ </li>
+ <li>
+ <span>
+ Step Over
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="F10"
+ >
+ F10
+ </span>
+ </span>
+ </li>
+ <li>
+ <span>
+ Step In
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="F11"
+ >
+ F11
+ </span>
+ </span>
+ </li>
+ <li>
+ <span>
+ Step Out
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="Shift+F11"
+ >
+ Shift+F11
+ </span>
+ </span>
+ </li>
+ </ul>
+ </div>
+ <div
+ className="shortcuts-section"
+ >
+ <h2>
+ Search
+ </h2>
+ <ul
+ className="shortcuts-list"
+ >
+ <li>
+ <span>
+ Go to file
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="Ctrl+P"
+ >
+ Ctrl+P
+ </span>
+ </span>
+ </li>
+ <li>
+ <span>
+ Find in files
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="Ctrl+Shift+F"
+ >
+ Ctrl+Shift+F
+ </span>
+ </span>
+ </li>
+ <li>
+ <span>
+ Find function
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="Ctrl+Shift+O"
+ >
+ Ctrl+Shift+O
+ </span>
+ </span>
+ </li>
+ <li>
+ <span>
+ Go to line
+ </span>
+ <span>
+ <span
+ className="keystroke"
+ key="Ctrl+G"
+ >
+ Ctrl+G
+ </span>
+ </span>
+ </li>
+ </ul>
+ </div>
+ </div>
+</Slide>
+`;
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); }
+}