Bug 1853899 - [devtools] Introduce a new Menu Button dedicated to source maps. r=devtools-reviewers,nchevobbe

It allows
* toggling Source Map support entirely
* open the source map URL (when a bundle file is currently selected)
* open the mapped source (original or bundle)
* toggle the "open original source by default" setting
* show source map status (source map error, is it original or bundle file, is this a regular source?)

Differential Revision: https://phabricator.services.mozilla.com/D187577
This commit is contained in:
Alexandre Poirot 2024-02-08 16:43:59 +00:00
parent d0672e750c
commit 25bbec4417
26 changed files with 653 additions and 94 deletions

View File

@ -0,0 +1,6 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="context-fill" fill-rule="evenodd" clip-rule="evenodd" d="M8.17949 1.03333C8.06395 0.988891 7.93604 0.988891 7.82051 1.03333L1.32051 3.53333C1.12741 3.60759 1 3.79311 1 4V12C1 12.2069 1.12741 12.3924 1.32051 12.4667L7.82051 14.9667C7.93604 15.0111 8.06395 15.0111 8.17949 14.9667L14.6795 12.4667C14.8726 12.3924 15 12.2069 15 12V4C15 3.79311 14.8726 3.60759 14.6795 3.53333L8.17949 1.03333ZM8.5 13.772V6.8434L14 4.72801V11.6566L8.5 13.772ZM8 5.96429L13.1072 4L8 2.03571L2.89284 4L8 5.96429Z"/>
</svg>

After

Width:  |  Height:  |  Size: 810 B

View File

@ -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/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.146447 14.1464C-0.0488155 14.3417 -0.0488155 14.6583 0.146447 14.8536C0.341709 15.0488 0.658291 15.0488 0.853553 14.8536L14.8536 0.853553C15.0488 0.658291 15.0488 0.341709 14.8536 0.146447C14.6583 -0.0488156 14.3417 -0.0488154 14.1464 0.146447L11.6262 2.66668L8.67949 1.53333C8.56396 1.48889 8.43604 1.48889 8.32051 1.53333L1.82051 4.03333C1.62741 4.10759 1.5 4.29311 1.5 4.5V12.5C1.5 12.5836 1.52082 12.6638 1.55839 12.7345L0.146447 14.1464ZM8.0151 6.27779L10.8524 3.44048L8.5 2.53571L3.39284 4.5L8.0151 6.27779Z" fill="context-fill"/>
<path d="M3.77977 13.7202L9 8.5V14.272L14.5 12.1566V3.77199L15.1795 4.03333C15.3726 4.10759 15.5 4.29311 15.5 4.5V12.5C15.5 12.7069 15.3726 12.8924 15.1795 12.9667L8.67949 15.4667C8.56396 15.5111 8.43604 15.5111 8.32051 15.4667L3.77977 13.7202Z" fill="context-fill"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -3,4 +3,4 @@
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="context-fill" fill-rule="evenodd" clip-rule="evenodd" d="M8.17949 1.03333C8.06395 0.988891 7.93604 0.988891 7.82051 1.03333L1.32051 3.53333C1.12741 3.60759 1 3.79311 1 4V12C1 12.2069 1.12741 12.3924 1.32051 12.4667L7.82051 14.9667C7.93604 15.0111 8.06395 15.0111 8.17949 14.9667L14.6795 12.4667C14.8726 12.3924 15 12.2069 15 12V4C15 3.79311 14.8726 3.60759 14.6795 3.53333L8.17949 1.03333ZM8.5 13.772V6.8434L14 4.72801V11.6566L8.5 13.772ZM8 5.96429L13.1072 4L11.25 3.28571L6.14284 5.25L8 5.96429ZM8 2.03571L9.85716 2.75L4.75 4.71429L2.89284 4L8 2.03571Z"/>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 873 B

After

Width:  |  Height:  |  Size: 874 B

View File

@ -61,6 +61,11 @@ export const clearSelectedLocation = () => ({
type: "CLEAR_SELECTED_LOCATION",
});
export const setDefaultSelectedLocation = shouldSelectOriginalLocation => ({
type: "SET_DEFAULT_SELECTED_LOCATION",
shouldSelectOriginalLocation,
});
/**
* Deterministically select a source that has a given URL. This will
* work regardless of the connection status or if the source exists
@ -102,6 +107,76 @@ export function selectSource(source, sourceActor) {
};
}
/**
* Helper for `selectLocation`.
* Based on `keepContext` argument passed to `selectLocation`,
* this will automatically select the related mapped source (original or generated).
*
* @param {Object} location
* The location to select.
* @param {Boolean} keepContext
* If true, will try to select a mapped source.
* @param {Object} thunkArgs
* @return {Object}
* Object with two attributes:
* - `shouldSelectOriginalLocation`, to know if we should keep trying to select the original location
* - `newLocation`, for the final location to select
*/
async function mayBeSelectMappedSource(location, keepContext, thunkArgs) {
const { getState, dispatch } = thunkArgs;
// Preserve the current source map context (original / generated)
// when navigating to a new location.
// i.e. if keepContext isn't manually overriden to false,
// we will convert the source we want to select to either
// original/generated in order to match the currently selected one.
// If the currently selected source is original, we will
// automatically map `location` to refer to the original source,
// even if that used to refer only to the generated source.
let shouldSelectOriginalLocation = getShouldSelectOriginalLocation(
getState()
);
if (keepContext) {
// Pretty print source may not be registered yet and getRelatedMapLocation may not return it.
// Wait for the pretty print source to be fully processed.
if (
!location.source.isOriginal &&
shouldSelectOriginalLocation &&
hasPrettyTab(getState(), location.source)
) {
// Note that prettyPrintAndSelectSource has already been called a bit before when this generated source has been added
// but it is a slow operation and is most likely not resolved yet.
// prettyPrintAndSelectSource uses memoization to avoid doing the operation more than once, while waiting from both callsites.
await dispatch(prettyPrintAndSelectSource(location.source));
}
if (shouldSelectOriginalLocation != location.source.isOriginal) {
// Only try to map if the source is mapped. i.e. is original source or a bundle with a valid source map comment
if (
location.source.isOriginal ||
isSourceActorWithSourceMap(getState(), location.sourceActor.id)
) {
// getRelatedMapLocation will convert to the related generated/original location.
// i.e if the original location is passed, the related generated location will be returned and vice versa.
location = await getRelatedMapLocation(location, thunkArgs);
}
// Note that getRelatedMapLocation may return the exact same location.
// For example, if the source-map is half broken, it may return a generated location
// while we were selecting original locations. So we may be seeing bundles intermittently
// when stepping through broken source maps. And we will see original sources when stepping
// through functional original sources.
}
} else if (
location.source.isOriginal ||
isSourceActorWithSourceMap(getState(), location.sourceActor.id)
) {
// Only update this setting if the source is mapped. i.e. don't update if we select a regular source.
// The source is mapped when it is either:
// - an original source,
// - a bundle with a source map comment referencing a source map URL.
shouldSelectOriginalLocation = location.source.isOriginal;
}
return { shouldSelectOriginalLocation, newLocation: location };
}
/**
* Select a new location.
* This will automatically select the source in the source tree (if visible)
@ -136,47 +211,22 @@ export function selectLocation(location, { keepContext = true } = {}) {
return;
}
// Preserve the current source map context (original / generated)
// when navigating to a new location.
// i.e. if keepContext isn't manually overriden to false,
// we will convert the source we want to select to either
// original/generated in order to match the currently selected one.
// If the currently selected source is original, we will
// automatically map `location` to refer to the original source,
// even if that used to refer only to the generated source.
let shouldSelectOriginalLocation = getShouldSelectOriginalLocation(
getState()
);
if (keepContext) {
// Pretty print source may not be registered yet and getRelatedMapLocation may not return it.
// Wait for the pretty print source to be fully processed.
if (
!location.source.isOriginal &&
shouldSelectOriginalLocation &&
hasPrettyTab(getState(), location.source)
) {
// Note that prettyPrintAndSelectSource has already been called a bit before when this generated source has been added
// but it is a slow operation and is most likely not resolved yet.
// prettyPrintAndSelectSource uses memoization to avoid doing the operation more than once, while waiting from both callsites.
await dispatch(prettyPrintAndSelectSource(location.source));
}
if (shouldSelectOriginalLocation != location.source.isOriginal) {
// getRelatedMapLocation will convert to the related generated/original location.
// i.e if the original location is passed, the related generated location will be returned and vice versa.
location = await getRelatedMapLocation(location, thunkArgs);
// Note that getRelatedMapLocation may return the exact same location.
// For example, if the source-map is half broken, it may return a generated location
// while we were selecting original locations. So we may be seeing bundles intermittently
// when stepping through broken source maps. And we will see original sources when stepping
// through functional original sources.
source = location.source;
}
} else {
shouldSelectOriginalLocation = location.source.isOriginal;
let sourceActor = location.sourceActor;
if (!sourceActor) {
sourceActor = getFirstSourceActorForGeneratedSource(
getState(),
source.id
);
location = createLocation({ ...location, sourceActor });
}
let sourceActor = location.sourceActor;
const { shouldSelectOriginalLocation, newLocation } =
await mayBeSelectMappedSource(location, keepContext, thunkArgs);
// Update all local variables after mapping
location = newLocation;
source = location.source;
sourceActor = location.sourceActor;
if (!sourceActor) {
sourceActor = getFirstSourceActorForGeneratedSource(
getState(),
@ -341,11 +391,16 @@ export function jumpToMappedLocation(location) {
// Map to either an original or a generated source location
const pairedLocation = await getRelatedMapLocation(location, thunkArgs);
// If we are on a non-mapped source, this will return the same location
// so ignore the request.
if (pairedLocation == location) {
return null;
}
return dispatch(selectSpecificLocation(pairedLocation));
};
}
// This is only used by tests
export function jumpToMappedSelectedLocation() {
return async function ({ dispatch, getState }) {
const location = getSelectedLocation(getState());

View File

@ -7,10 +7,9 @@ import {
selectors,
createStore,
makeSource,
makeSourceURL,
makeOriginalSource,
} from "../../../utils/test-head";
const { getSource, getSourceCount, getSelectedSource } = selectors;
const { getSource, getSourceCount } = selectors;
import { mockCommandClient } from "../../tests/helpers/mockCommandClient";
@ -49,20 +48,6 @@ describe("sources - new sources", () => {
expect(getSourceCount(getState())).toEqual(1);
});
it("should automatically select a pending source", async () => {
const { dispatch, getState } = createStore(mockCommandClient);
const baseSourceURL = makeSourceURL("base.js");
await dispatch(actions.selectSourceURL(baseSourceURL));
expect(getSelectedSource(getState())).toBe(undefined);
const baseSource = await dispatch(
actions.newGeneratedSource(makeSource("base.js"))
);
const selected = getSelectedSource(getState());
expect(selected && selected.url).toBe(baseSource.url);
});
// eslint-disable-next-line
it("should not attempt to fetch original sources if it's missing a source map url", async () => {
const loadSourceMap = jest.fn();

View File

@ -8,7 +8,6 @@ import {
createStore,
createSourceObject,
makeSource,
makeSourceURL,
waitForState,
makeOriginalSource,
} from "../../../utils/test-head";
@ -176,20 +175,4 @@ describe("sources", () => {
const selected = getSelectedLocation(getState());
expect(selected && selected.line).toBe(1);
});
describe("selectSourceURL", () => {
it("should automatically select a pending source", async () => {
const { dispatch, getState } = createStore(mockCommandClient);
const baseSourceURL = makeSourceURL("base.js");
await dispatch(actions.selectSourceURL(baseSourceURL));
expect(getSelectedSource(getState())).toBe(undefined);
const baseSource = await dispatch(
actions.newGeneratedSource(makeSource("base.js"))
);
const selected = getSelectedSource(getState());
expect(selected && selected.url).toBe(baseSource.url);
});
});
});

View File

@ -12,6 +12,12 @@ export function openLink(url) {
};
}
export function openSourceMap(url, line, column) {
return async function ({ panel }) {
return panel.toolbox.viewSource(url, line, column);
};
}
export function evaluateInConsole(inputString) {
return async ({ panel }) => {
return panel.openConsoleAndEvaluate(inputString);

View File

@ -79,6 +79,29 @@ button:hover {
gap: 8px;
grid-area: notification;
display: flex;
/* center text within the notification */
align-items: center;
.info.icon {
align-self: normal;
}
.close-button {
/* set a fixed height in order to avoid having it flexed to full height */
height: 16px;
padding: 0;
/* put in top-right corner */
margin-inline-start: auto;
align-self: normal;
&::before {
display: block;
width: 16px;
height: 16px;
content: "";
background-image: url("chrome://devtools/skin/images/close.svg");
}
}
}
/* Utils */

View File

@ -4,6 +4,7 @@
import React, { Component } from "devtools/client/shared/vendor/react";
import {
button,
div,
main,
span,
@ -208,15 +209,23 @@ class App extends Component {
}
}
closeSourceMapError = () => {
this.setState({ hiddenSourceMapError: this.props.sourceMapError });
};
renderEditorNotificationBar() {
if (this.props.sourceMapError) {
if (
this.props.sourceMapError &&
this.state.hiddenSourceMapError != this.props.sourceMapError
) {
return div(
{ className: "editor-notification-footer", "aria-role": "status" },
span(
{ className: "info icon" },
React.createElement(AccessibleImage, { className: "sourcemap" })
),
`Source Map Error: ${this.props.sourceMapError}`
`Source Map Error: ${this.props.sourceMapError}`,
button({ className: "close-button", onClick: this.closeSourceMapError })
);
}
if (this.props.showOriginalVariableMappingWarning) {

View File

@ -67,6 +67,45 @@
opacity: 0.6;
}
.devtools-button.debugger-source-map-button {
display: inline-flex;
align-items: center;
margin: 0;
--menuitem-icon-image: url("chrome://devtools/content/debugger/images/sourcemap.svg");
&.not-mapped {
--icon-color: var(--theme-icon-dimmed-color);
}
&.original {
--icon-color: var(--theme-icon-checked-color);
--menuitem-icon-image: url("chrome://devtools/content/debugger/images/sourcemap-active.svg");
}
&.error {
--icon-color: var(--theme-icon-warning-color);
}
&.disabled {
--icon-color: var(--theme-icon-dimmed-color);
--menuitem-icon-image: url("chrome://devtools/content/debugger/images/sourcemap-disabled.svg");
}
&.loading {
--menuitem-icon-image: url("chrome://devtools/content/debugger/images/loader.svg");
}
&::before {
/* override default style to have similar left and right margins */
margin-inline-end: 3px;
color: var(--icon-color, currentColor);
}
&.loading::before {
animation: spin 2s linear infinite;
}
}
.source-footer .mapped-source,
.source-footer .cursor-position {
color: var(--theme-body-color);

View File

@ -7,6 +7,7 @@ import {
div,
button,
span,
hr,
} from "devtools/client/shared/vendor/react-dom-factories";
import PropTypes from "devtools/client/shared/vendor/react-prop-types";
import { connect } from "devtools/client/shared/vendor/react-redux";
@ -23,6 +24,12 @@ import {
isSourceOnSourceMapIgnoreList,
isSourceMapIgnoreListEnabled,
getSelectedMappedSource,
getSourceMapErrorForSourceActor,
areSourceMapsEnabled,
getShouldSelectOriginalLocation,
isSourceActorWithSourceMap,
getSourceMapResolvedURL,
isSelectedMappedSourceLoading,
} from "../../selectors/index";
import { isPretty, getFilename, shouldBlackbox } from "../../utils/source";
@ -31,6 +38,9 @@ import { PaneToggleButton } from "../shared/Button/index";
import AccessibleImage from "../shared/AccessibleImage";
const classnames = require("resource://devtools/client/shared/classnames.js");
const MenuButton = require("resource://devtools/client/shared/components/menu/MenuButton.js");
const MenuItem = require("resource://devtools/client/shared/components/menu/MenuItem.js");
const MenuList = require("resource://devtools/client/shared/components/menu/MenuList.js");
class SourceFooter extends PureComponent {
static get propTypes() {
@ -155,9 +165,11 @@ class SourceFooter extends PureComponent {
}
renderCommands() {
const commands = [this.blackBoxButton(), this.prettyPrintButton()].filter(
Boolean
);
const commands = [
this.blackBoxButton(),
this.prettyPrintButton(),
this.renderSourceMapButton(),
].filter(Boolean);
return commands.length
? div(
@ -169,7 +181,7 @@ class SourceFooter extends PureComponent {
: null;
}
renderSourceSummary() {
renderMappedSource() {
const { mappedSource, jumpToMappedLocation, selectedLocation } = this.props;
if (!mappedSource) {
@ -227,6 +239,148 @@ class SourceFooter extends PureComponent {
);
}
getSourceMapLabel() {
if (!this.props.selectedLocation) {
return undefined;
}
if (!this.props.areSourceMapsEnabled) {
return L10N.getStr("sourceFooter.sourceMapButton.disabled");
}
if (this.props.sourceMapError) {
return undefined;
}
if (!this.props.isSourceActorWithSourceMap) {
return L10N.getStr("sourceFooter.sourceMapButton.sourceNotMapped");
}
if (this.props.selectedLocation.source.isOriginal) {
return L10N.getStr("sourceFooter.sourceMapButton.isOriginalSource");
}
return L10N.getStr("sourceFooter.sourceMapButton.isBundleSource");
}
getSourceMapTitle() {
if (this.props.sourceMapError) {
return L10N.getFormatStr(
"sourceFooter.sourceMapButton.errorTitle",
this.props.sourceMapError
);
}
if (this.props.isSourceMapLoading) {
return L10N.getStr("sourceFooter.sourceMapButton.loadingTitle");
}
return L10N.getStr("sourceFooter.sourceMapButton.title");
}
renderSourceMapButton() {
const { toolboxDoc } = this.context;
return React.createElement(
MenuButton,
{
menuId: "debugger-source-map-button",
toolboxDoc: toolboxDoc,
className: classnames("devtools-button", "debugger-source-map-button", {
error: !!this.props.sourceMapError,
loading: this.props.isSourceMapLoading,
disabled: !this.props.areSourceMapsEnabled,
"not-mapped":
!this.props.selectedLocation?.source.isOriginal &&
!this.props.isSourceActorWithSourceMap,
original: this.props.selectedLocation?.source.isOriginal,
}),
title: this.getSourceMapTitle(),
label: this.getSourceMapLabel(),
icon: true,
},
() => this.renderSourceMapMenuItems()
);
}
renderSourceMapMenuItems() {
const items = [
React.createElement(MenuItem, {
className: "menu-item debugger-source-map-enabled",
checked: this.props.areSourceMapsEnabled,
label: L10N.getStr("sourceFooter.sourceMapButton.enable"),
onClick: this.toggleSourceMaps,
}),
hr(),
React.createElement(MenuItem, {
className: "menu-item debugger-source-map-open-original",
checked: this.props.shouldSelectOriginalLocation,
label: L10N.getStr(
"sourceFooter.sourceMapButton.showOriginalSourceByDefault"
),
onClick: this.toggleSelectOriginalByDefault,
}),
];
if (this.props.mappedSource) {
items.push(
React.createElement(MenuItem, {
className: "menu-item debugger-jump-mapped-source",
label: this.props.mappedSource.isOriginal
? L10N.getStr("sourceFooter.sourceMapButton.jumpToGeneratedSource")
: L10N.getStr("sourceFooter.sourceMapButton.jumpToOriginalSource"),
tooltip: this.props.mappedSource.url,
onClick: () =>
this.props.jumpToMappedLocation(this.props.selectedLocation),
})
);
}
if (this.props.resolvedSourceMapURL) {
items.push(
React.createElement(MenuItem, {
className: "menu-item debugger-source-map-link",
label: L10N.getStr(
"sourceFooter.sourceMapButton.openSourceMapInNewTab"
),
onClick: this.openSourceMap,
})
);
}
return React.createElement(
MenuList,
{
id: "debugger-source-map-list",
},
items
);
}
openSourceMap = () => {
let line, column;
if (
this.props.sourceMapError &&
this.props.sourceMapError.includes("JSON.parse")
) {
const match = this.props.sourceMapError.match(
/at line (\d+) column (\d+)/
);
if (match) {
line = match[1];
column = match[2];
}
}
this.props.openSourceMap(
this.props.resolvedSourceMapURL || this.props.selectedLocation.source.url,
line,
column
);
};
toggleSourceMaps = () => {
this.props.toggleSourceMapsEnabled(!this.props.areSourceMapsEnabled);
};
toggleSelectOriginalByDefault = () => {
this.props.setDefaultSelectedLocation(
!this.props.shouldSelectOriginalLocation
);
this.props.jumpToMappedSelectedLocation();
};
render() {
return div(
{
@ -242,19 +396,40 @@ class SourceFooter extends PureComponent {
{
className: "source-footer-end",
},
this.renderSourceSummary(),
this.renderMappedSource(),
this.renderCursorPosition(),
this.renderToggleButton()
)
);
}
}
SourceFooter.contextTypes = {
toolboxDoc: PropTypes.object,
};
const mapStateToProps = state => {
const selectedSource = getSelectedSource(state);
const selectedLocation = getSelectedLocation(state);
const sourceTextContent = getSelectedSourceTextContent(state);
const areSourceMapsEnabledProp = areSourceMapsEnabled(state);
const isSourceActorWithSourceMapProp = isSourceActorWithSourceMap(
state,
selectedLocation?.sourceActor.id
);
const sourceMapError = selectedLocation?.sourceActor
? getSourceMapErrorForSourceActor(state, selectedLocation.sourceActor.id)
: null;
const mappedSource = getSelectedMappedSource(state);
const isSourceMapLoading =
areSourceMapsEnabledProp &&
isSourceActorWithSourceMapProp &&
// `mappedSource` will be null while loading, we need another way to know when it is done computing
!mappedSource &&
isSelectedMappedSourceLoading(state) &&
!sourceMapError;
return {
selectedSource,
selectedLocation,
@ -265,7 +440,8 @@ const mapStateToProps = state => {
isSourceMapIgnoreListEnabled(state) &&
isSourceOnSourceMapIgnoreList(state, selectedSource),
sourceLoaded: !!sourceTextContent,
mappedSource: getSelectedMappedSource(state),
mappedSource,
isSourceMapLoading,
prettySource: getPrettySource(
state,
selectedSource ? selectedSource.id : null
@ -277,6 +453,17 @@ const mapStateToProps = state => {
prettyPrintMessage: selectedLocation
? getPrettyPrintMessage(state, selectedLocation)
: null,
sourceMapError,
resolvedSourceMapURL: selectedLocation?.sourceActor
? getSourceMapResolvedURL(state, selectedLocation.sourceActor.id)
: null,
isSourceActorWithSourceMap: isSourceActorWithSourceMapProp,
sourceMapURL: selectedLocation?.sourceActor.sourceMapURL,
areSourceMapsEnabled: areSourceMapsEnabledProp,
shouldSelectOriginalLocation: getShouldSelectOriginalLocation(state),
};
};
@ -285,4 +472,8 @@ export default connect(mapStateToProps, {
toggleBlackBox: actions.toggleBlackBox,
jumpToMappedLocation: actions.jumpToMappedLocation,
togglePaneCollapse: actions.togglePaneCollapse,
toggleSourceMapsEnabled: actions.toggleSourceMapsEnabled,
setDefaultSelectedLocation: actions.setDefaultSelectedLocation,
jumpToMappedSelectedLocation: actions.jumpToMappedSelectedLocation,
openSourceMap: actions.openSourceMap,
})(SourceFooter);

View File

@ -31,6 +31,16 @@ exports[`SourceFooter Component default case should render 1`] = `
className="prettyPrint"
/>
</button>
<MenuButton
className="devtools-button debugger-source-map-button disabled not-mapped"
icon={true}
menuId="debugger-source-map-button"
menuOffset={-5}
menuPosition="bottom"
title="Source Map status"
>
<Component />
</MenuButton>
</div>
</div>
<div
@ -77,6 +87,16 @@ exports[`SourceFooter Component move cursor should render new cursor position 1`
className="prettyPrint"
/>
</button>
<MenuButton
className="devtools-button debugger-source-map-button disabled not-mapped"
icon={true}
menuId="debugger-source-map-button"
menuOffset={-5}
menuPosition="bottom"
title="Source Map status"
>
<Component />
</MenuButton>
</div>
</div>
<div

View File

@ -174,6 +174,19 @@ function update(state = initialSourcesState(), action) {
};
}
case "SET_DEFAULT_SELECTED_LOCATION": {
if (
state.shouldSelectOriginalLocation ==
action.shouldSelectOriginalLocation
) {
return state;
}
return {
...state,
shouldSelectOriginalLocation: action.shouldSelectOriginalLocation,
};
}
case "SET_PENDING_SELECTED_LOCATION": {
const pendingSelectedLocation = {
url: action.url,

View File

@ -62,6 +62,7 @@ export const initialUIState = () => ({
},
projectSearchQuery: "",
hideIgnoredSources: prefs.hideIgnoredSources,
sourceMapsEnabled: prefs.clientSourceMapsEnabled,
sourceMapIgnoreListEnabled: prefs.sourceMapIgnoreListEnabled,
});
@ -92,7 +93,7 @@ function update(state = initialUIState(), action) {
case "TOGGLE_SOURCE_MAPS_ENABLED": {
prefs.clientSourceMapsEnabled = action.value;
return { ...state };
return { ...state, sourceMapsEnabled: action.value };
}
case "SET_ORIENTATION": {

View File

@ -163,6 +163,16 @@ export function getSelectedMappedSource(state) {
return mappedSource || null;
}
/**
* Helps knowing if we are still computing the mapped location for the currently selected source.
*/
export function isSelectedMappedSourceLoading(state) {
const { selectedOriginalLocation } = state.sources;
// This `selectedOriginalLocation` attribute is set to UNDEFINED_LOCATION when selecting a new source attribute
// and later on, when the source map is processed, it will switch to either a valid location object, or NO_LOCATION if no valid one if found.
return selectedOriginalLocation === UNDEFINED_LOCATION;
}
export const getSelectedSource = createSelector(
getSelectedLocation,
selectedLocation => {

View File

@ -111,3 +111,7 @@ export function getHideIgnoredSources(state) {
export function isSourceMapIgnoreListEnabled(state) {
return state.ui.sourceMapIgnoreListEnabled;
}
export function areSourceMapsEnabled(state) {
return state.ui.sourceMapsEnabled;
}

View File

@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`has syntax error should return the error object for the invalid expression 1`] = `"SyntaxError : Missing semicolon. (1:3)"`;

View File

@ -30,6 +30,7 @@ add_task(async function () {
);
// Note that CodeMirror is 0-based while the footer displays 1-based
getCM(dbg).setCursor({ line: 1, ch: 0 });
await waitForCursorPosition(dbg, 2);
assertCursorPosition(
dbg,
2,
@ -37,6 +38,7 @@ add_task(async function () {
"when moving the cursor, the position footer updates"
);
getCM(dbg).setCursor({ line: 2, ch: 0 });
await waitForCursorPosition(dbg, 3);
assertCursorPosition(
dbg,
3,

View File

@ -53,6 +53,21 @@ add_task(async function () {
"There is a warning about the missing source map file"
);
let footerButton = findElement(dbg, "sourceMapFooterButton");
is(
footerButton.textContent,
"Error in source map",
"The source map button's label mention an error"
);
ok(
footerButton.classList.contains("not-mapped"),
"The source map error causes the file to be reported as not mapped"
);
ok(
footerButton.classList.contains("error"),
"The source map error is displayed in the source map icon"
);
// Test a Source Map with missing original text content
await selectSource(dbg, "map-with-failed-original-request.js");
ok(
@ -87,5 +102,24 @@ add_task(async function () {
`Error while fetching an original source: request failed with status 404\nSource URL: ${EXAMPLE_URL}map-with-failed-original-request.original.js`
);
footerButton = findElement(dbg, "sourceMapFooterButton");
is(
footerButton.textContent,
"original file",
"Even if the original can't be loaded, it is reported as original in the footer"
);
ok(
!footerButton.classList.contains("loading"),
"The source map isn't loading because of the missing text content"
);
ok(
!footerButton.classList.contains("error"),
"The source map isn't reported with an error because of the missing text content"
);
ok(
footerButton.classList.contains("original"),
"The source map icon is set to original"
);
await resume(dbg);
});

View File

@ -17,7 +17,34 @@ add_task(async function () {
info("Pretty print the bundle");
await selectSource(dbg, bundleSrc);
const footerButton = findElement(dbg, "sourceMapFooterButton");
is(
footerButton.textContent,
"Source Maps disabled",
"The source map button reports the disabling"
);
ok(
footerButton.classList.contains("disabled"),
"The source map button is disabled"
);
clickElement(dbg, "prettyPrintButton");
await waitForSelectedSource(dbg, "bundle.js:formatted");
ok(true, "everything finished");
ok(true, "Pretty printed source shown");
const toggled = waitForDispatch(dbg.store, "TOGGLE_SOURCE_MAPS_ENABLED");
await clickOnSourceMapMenuItem(dbg, ".debugger-source-map-enabled");
await toggled;
ok(true, "Toggled the Source map setting");
is(
footerButton.textContent,
"original file",
"The source map button now reports the pretty printed file as original file"
);
ok(
!footerButton.classList.contains("disabled"),
"The source map button is no longer disabled"
);
});

View File

@ -98,6 +98,25 @@ add_task(async function () {
"Pending selected location is the expected one"
);
const footerButton = findElement(dbg, "sourceMapFooterButton");
is(
footerButton.textContent,
"original file",
"The source map button's label mention an original file"
);
ok(
footerButton.classList.contains("original"),
"The source map icon is original"
);
ok(
!footerButton.classList.contains("not-mapped"),
"The source map button isn't gray out"
);
ok(
!footerButton.classList.contains("loading"),
"The source map button isn't reporting in-process loading"
);
info("Click on jump to generated source link from editor's footer");
let mappedSourceLink = findElement(dbg, "mappedSourceLink");
is(
@ -117,6 +136,23 @@ add_task(async function () {
"From entry.js",
"The link to mapped source mentions the original source"
);
is(
footerButton.textContent,
"bundle file",
"When moved to the bundle, the source map button's label mention a bundle file"
);
ok(
!footerButton.classList.contains("original"),
"The source map icon isn't original"
);
ok(
!footerButton.classList.contains("not-mapped"),
"The source map button isn't gray out"
);
ok(
!footerButton.classList.contains("loading"),
"The source map button isn't reporting in-process loading"
);
info("Move the cursor within the bundle to another original source");
getCM(dbg).setCursor({ line: 70, ch: 0 });
@ -126,6 +162,18 @@ add_task(async function () {
"From times2.js",
"The link to mapped source updates to the newly selected original source within the bundle"
);
info("Move to the new original file via the source map button/menu");
await clickOnSourceMapMenuItem(dbg, ".debugger-jump-mapped-source");
await waitForSelectedSource(dbg, "times2.js");
info("Open the related source map file and wait for a new tab to be opened");
const onTabLoaded = BrowserTestUtils.waitForNewTab(
gBrowser,
`view-source:${EXAMPLE_URL}sourcemaps/bundle.js.map`
);
await clickOnSourceMapMenuItem(dbg, ".debugger-source-map-link");
await onTabLoaded;
});
function assertBreakpointExists(dbg, source, line) {

View File

@ -1772,6 +1772,7 @@ const selectors = {
prettyPrintButton: ".source-footer .prettyPrint",
mappedSourceLink: ".source-footer .mapped-source",
sourcesFooter: ".sources-panel .source-footer",
sourceMapFooterButton: ".debugger-source-map-button",
editorFooter: ".editor-pane .source-footer",
sourceNode: i => `.sources-list .tree-node:nth-child(${i}) .node`,
sourceNodes: ".sources-list .tree-node",
@ -2202,8 +2203,9 @@ async function clickAtPos(dbg, pos) {
bubbles: true,
cancelable: true,
view: dbg.win,
clientX: left,
clientY: top,
// Shift by one as we might be on the edge of the element and click on previous line/column
clientX: left + 1,
clientY: top + 1,
})
);
}
@ -2944,6 +2946,33 @@ async function toggleDebbuggerSettingsMenuItem(dbg, { className, isChecked }) {
await waitFor(() => menuButton.getAttribute("aria-expanded") === "false");
}
/**
* Click on the source map button in the editor's footer
* and wait for its context menu to be rendered before clicking
* on one menuitem of it.
*
* @param {Object} dbg
* @param {String} className
* The class name of the menuitem to click in the context menu.
*/
async function clickOnSourceMapMenuItem(dbg, className) {
const menuButton = findElement(dbg, "sourceMapFooterButton");
const { parent } = dbg.panel.panelWin;
const { document } = parent;
menuButton.click();
// Waits for the debugger settings panel to appear.
await waitFor(() => {
const menuListEl = document.querySelector("#debugger-source-map-list");
// Lets check the offsetParent property to make sure the menu list is actually visible
// by its parents display property being no longer "none".
return menuListEl && menuListEl.offsetParent !== null;
});
const menuItem = document.querySelector(className);
menuItem.click();
}
async function setLogPoint(dbg, index, value) {
rightClickElement(dbg, "gutter", index);
await waitForContextMenu(dbg);

View File

@ -4478,8 +4478,8 @@ Toolbox.prototype = {
* Opens source in plain "view-source:".
* @see devtools/client/shared/source-utils.js
*/
viewSource(sourceURL, sourceLine) {
return viewSource.viewSource(this, sourceURL, sourceLine);
viewSource(sourceURL, sourceLine, sourceColumn) {
return viewSource.viewSource(this, sourceURL, sourceLine, sourceColumn);
},
// Support for WebExtensions API (`devtools.network.*`)

View File

@ -246,6 +246,8 @@ devtools.jar:
content/debugger/images/regex-match.svg (debugger/images/regex-match.svg)
content/debugger/images/search.svg (debugger/images/search.svg)
content/debugger/images/sourcemap.svg (debugger/images/sourcemap.svg)
content/debugger/images/sourcemap-active.svg (debugger/images/sourcemap-active.svg)
content/debugger/images/sourcemap-disabled.svg (debugger/images/sourcemap-disabled.svg)
content/debugger/images/stepIn.svg (debugger/images/stepIn.svg)
content/debugger/images/stepOut.svg (debugger/images/stepOut.svg)
content/debugger/images/tab.svg (debugger/images/tab.svg)

View File

@ -745,6 +745,71 @@ sourceFooter.unignore=Unignore source
# with the ignore source button when the selected source is on the ignore list
sourceFooter.ignoreList=This source is on the ignore list. Please turn off the `Ignore Known Third-party Scripts` option to enable it.
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.disabled): Label displayed next to the
# Source Map icon displayed in editor footer.
# Displayed when Source Maps are disabled.
sourceFooter.sourceMapButton.disabled = Source Maps disabled
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.sourceNotMapped): Label displayed next to the
# Source Map icon displayed in editor footer.
# Displayed when the selected source is a regular source, without any source map.
sourceFooter.sourceMapButton.sourceNotMapped = No source map found
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.isOriginalSource): Label displayed next to the
# Source Map icon displayed in editor footer.
# Displayed when the selected source is an original source.
# i.e. a file which may not be in JavaScript and isn't being executed by Firefox.
# This file is transpiled by the web developer into a "bundle" JavaScript file, which is executed by the page.
sourceFooter.sourceMapButton.isOriginalSource = original file
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.isBundleSource): Label displayed next to the
# Source Map icon displayed in editor footer.
# Displayed when the selected source is a bundle. i.e. a file referring to a source map file,
# which will be mapped to one or many original sources.
sourceFooter.sourceMapButton.isBundleSource = bundle file
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.enable): Label displayed in the menu opened
# from the Source Map icon displayed in editor footer.
# This allows to toggle Source Map support.
sourceFooter.sourceMapButton.enable = Enable Source Maps
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.showOriginalSourceByDefault): Label displayed in the menu opened
# from the Source Map icon displayed in editor footer.
# This controls the settings which will make the debugger automatically show and open original source by default.
# This typically happens when you pause or hit a breakpoint.
sourceFooter.sourceMapButton.showOriginalSourceByDefault = Show and open original location by default
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.jumpToGeneratedSource): Label displayed in the menu opened
# from the Source Map icon displayed in editor footer.
# This allows to select the related bundle source, when we are currently selecting an original one.
sourceFooter.sourceMapButton.jumpToGeneratedSource = Jump to the related bundle source
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.jumpToOriginalSource): Label displayed in the menu opened
# from the Source Map icon displayed in editor footer.
# This allows to select the related original source, when we are currently selecting a bundle.
sourceFooter.sourceMapButton.jumpToOriginalSource = Jump to the related original source
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.openSourceMapInNewTab): Label displayed in the menu opened
# from the Source Map icon displayed in editor footer.
# When selecting a bundle with a valid source map, link to open the source map in a new tab.
sourceFooter.sourceMapButton.openSourceMapInNewTab = Open the Source Map file in a new tab
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.title): Tooltip displayed on
# the Source Map icon displayed in editor footer.
# This is the default title.
sourceFooter.sourceMapButton.title = Source Map status
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.loadingTitle): Tooltip displayed on
# the Source Map icon displayed in editor footer.
# This title is displayed when the source map is still loading.
sourceFooter.sourceMapButton.loadingTitle = Source Map is loading
# LOCALIZATION NOTE (sourceFooter.sourceMapButton.errorTitle): Tooltip displayed on
# the Source Map icon displayed in editor footer.
# This title is displayed when the source map has an error.
# %S will be the error string.
sourceFooter.sourceMapButton.errorTitle = Source Map error: %S
# LOCALIZATION NOTE (editorNotificationFooter.noOriginalScopes): The notification message displayed in the editor notification footer
# when paused in an original file and original variable mapping is turned off
# %S is text from the label for checkbox to show original scopes

View File

@ -427,9 +427,12 @@ class MenuButton extends PureComponent {
buttonProps.className = buttonProps.className
? `${buttonProps.className} ${iconClass}`
: iconClass;
buttonProps.style = {
"--menuitem-icon-image": "url(" + this.props.icon + ")",
};
// `icon` may be a boolean and the icon URL will be set in CSS.
if (typeof this.props.icon == "string") {
buttonProps.style = {
"--menuitem-icon-image": "url(" + this.props.icon + ")",
};
}
}
if (this.state.isMenuInitialized) {