Bug 1834729 - [devtools] Provide link to the related original file in the footer. r=devtools-reviewers,nchevobbe

For now, we were only showing the related bundle file,
so that we can easily navigate from original file to bundle file.
But we could also show the related original file for a location.
This is slightly more complex because the mapped location can now be different
depending on the precise location in the selected file.
Also, there is no synchronous API to compute this mapped location.
We have to involve an asynchronous action to compute this data for bundles.

Tweak Source Map Loader in order to do a loose bundle to original mapping
in case we aren't in a cursor location that has a perfect match.
Try mapping the first column of the same line and then all first column of
the next 10 following lines.

Differential Revision: https://phabricator.services.mozilla.com/D187573
This commit is contained in:
Alexandre Poirot 2023-12-20 23:01:21 +00:00
Родитель 87151816cd
Коммит acd7b3af58
16 изменённых файлов: 269 добавлений и 59 удалений

Просмотреть файл

@ -41,7 +41,9 @@ async function updateFrameLocationAndDisplayName(frame, thunkArgs) {
return frame;
}
const location = await getOriginalLocation(frame.location, thunkArgs, true);
const location = await getOriginalLocation(frame.location, thunkArgs, {
waitForSource: true,
});
// Avoid instantiating new frame objects if the frame location isn't mapped
if (location == frame.location) {
return frame;

Просмотреть файл

@ -17,7 +17,10 @@ import { setBreakableLines } from ".";
import { prefs } from "../../utils/prefs";
import { isMinified } from "../../utils/source";
import { createLocation } from "../../utils/location";
import { getRelatedMapLocation } from "../../utils/source-maps";
import {
getRelatedMapLocation,
getOriginalLocation,
} from "../../utils/source-maps";
import {
getSource,
@ -32,6 +35,7 @@ import {
hasSource,
hasSourceActor,
hasPrettyTab,
isSourceActorWithSourceMap,
} from "../../selectors";
// This is only used by jest tests (and within this module)
@ -230,6 +234,32 @@ export function selectLocation(location, { keepContext = true } = {}) {
// /!\ we don't historicaly wait for this async action
dispatch(setInScopeLines());
// When we select a generated source which has a sourcemap,
// asynchronously fetch the related original location in order to display
// the mapped location in the editor's footer.
if (
!location.source.isOriginal &&
isSourceActorWithSourceMap(getState(), sourceActor.id)
) {
let originalLocation = await getOriginalLocation(location, thunkArgs, {
looseSearch: true,
});
// We pass a null original location when the location doesn't map
// in order to know when we are done processing the source map.
// * `getOriginalLocation` would return the exact same location if it doesn't map
// * `getOriginalLocation` may also return a distinct location object,
// but refering to the same `source` object (which is the bundle) when it doesn't
// map to any known original location.
if (originalLocation.source === location.source) {
originalLocation = null;
}
dispatch({
type: "SET_ORIGINAL_SELECTED_LOCATION",
location,
originalLocation,
});
}
};
}

Просмотреть файл

@ -6,7 +6,6 @@ import React, { PureComponent } from "react";
import { div, button, span } from "react-dom-factories";
import PropTypes from "prop-types";
import { connect } from "../../utils/connect";
import { createLocation } from "../../utils/location";
import actions from "../../actions";
import {
getSelectedSource,
@ -14,12 +13,12 @@ import {
getSelectedSourceTextContent,
getPrettySource,
getPaneCollapse,
getGeneratedSource,
isSourceBlackBoxed,
canPrettyPrintSource,
getPrettyPrintMessage,
isSourceOnSourceMapIgnoreList,
isSourceMapIgnoreListEnabled,
getSelectedMappedSource,
} from "../../selectors";
import { isPretty, getFilename, shouldBlackbox } from "../../utils/source";
@ -169,30 +168,32 @@ class SourceFooter extends PureComponent {
}
renderSourceSummary() {
const { mappedSource, jumpToMappedLocation, selectedSource } = this.props;
const { mappedSource, jumpToMappedLocation, selectedLocation } = this.props;
if (!mappedSource || !selectedSource || !selectedSource.isOriginal) {
if (!mappedSource) {
return null;
}
const filename = getFilename(mappedSource);
const tooltip = L10N.getFormatStr(
"sourceFooter.mappedSourceTooltip",
mappedSource.isOriginal
? "sourceFooter.mappedOriginalSource.tooltip"
: "sourceFooter.mappedGeneratedSource.tooltip",
mappedSource.url
);
const filename = getFilename(mappedSource);
const label = L10N.getFormatStr(
mappedSource.isOriginal
? "sourceFooter.mappedOriginalSource.title"
: "sourceFooter.mappedGeneratedSource.title",
filename
);
const title = L10N.getFormatStr("sourceFooter.mappedSource", filename);
const mappedSourceLocation = createLocation({
source: selectedSource,
line: 1,
column: 1,
});
return button(
{
className: "mapped-source",
onClick: () => jumpToMappedLocation(mappedSourceLocation),
onClick: () => jumpToMappedLocation(selectedLocation),
title: tooltip,
},
span(null, title)
span(null, label)
);
}
@ -262,7 +263,7 @@ const mapStateToProps = state => {
isSourceMapIgnoreListEnabled(state) &&
isSourceOnSourceMapIgnoreList(state, selectedSource),
sourceLoaded: !!sourceTextContent,
mappedSource: getGeneratedSource(state, selectedSource),
mappedSource: getSelectedMappedSource(state),
prettySource: getPrettySource(
state,
selectedSource ? selectedSource.id : null

Просмотреть файл

@ -445,21 +445,19 @@ class Editor extends PureComponent {
onCursorChange = event => {
const { line, ch } = event.doc.getCursor();
this.props.selectLocation(
createLocation(
{
source: this.props.selectedSource,
// CodeMirror cursor location is all 0-based.
// Whereast in DevTools frontend and backend,
// only colunm is 0-based, the line is 1 based.
line: line + 1,
column: ch,
},
{
// Reset the context, so that we don't switch to original
// while moving the cursor within a bundle
keepContext: false,
}
)
createLocation({
source: this.props.selectedSource,
// CodeMirror cursor location is all 0-based.
// Whereast in DevTools frontend and backend,
// only colunm is 0-based, the line is 1 based.
line: line + 1,
column: ch,
}),
{
// Reset the context, so that we don't switch to original
// while moving the cursor within a bundle
keepContext: false,
}
);
};

Просмотреть файл

@ -11,6 +11,9 @@ import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/
import { prefs } from "../utils/prefs";
import { createPendingSelectedLocation } from "../utils/location";
export const UNDEFINED_LOCATION = Symbol("Undefined location");
export const NO_LOCATION = Symbol("No location");
export function initialSourcesState(state) {
/* eslint sort-keys: "error" */
return {
@ -97,6 +100,15 @@ export function initialSourcesState(state) {
*/
selectedLocation: undefined,
/**
* When selectedLocation refers to a generated source mapping to an original source
* via a source-map, refers to the related original location.
*
* This is UNDEFINED_LOCATION by default and will switch to NO_LOCATION asynchronously after location
* selection if there is no valid original location to map to.
*/
selectedOriginalLocation: UNDEFINED_LOCATION,
/**
* By default, if we have a source-mapped source, we would automatically try
* to select and show the content of the original source. But, if we explicitly
@ -134,6 +146,7 @@ function update(state = initialSourcesState(), action) {
return {
...state,
selectedLocation: action.location,
selectedOriginalLocation: UNDEFINED_LOCATION,
pendingSelectedLocation,
shouldSelectOriginalLocation: action.shouldSelectOriginalLocation,
};
@ -146,10 +159,21 @@ function update(state = initialSourcesState(), action) {
return {
...state,
selectedLocation: null,
selectedOriginalLocation: UNDEFINED_LOCATION,
pendingSelectedLocation,
};
}
case "SET_ORIGINAL_SELECTED_LOCATION": {
if (action.location != state.selectedLocation) {
return state;
}
return {
...state,
selectedOriginalLocation: action.originalLocation,
};
}
case "SET_PENDING_SELECTED_LOCATION": {
const pendingSelectedLocation = {
url: action.url,
@ -295,6 +319,7 @@ function removeSourcesAndActors(state, action) {
if (newState.selectedLocation?.source == removedSource) {
newState.selectedLocation = null;
newState.selectedOriginalLocation = UNDEFINED_LOCATION;
}
}
@ -319,6 +344,7 @@ function removeSourcesAndActors(state, action) {
if (newState.selectedLocation?.sourceActor == removedActor) {
newState.selectedLocation = null;
newState.selectedOriginalLocation = UNDEFINED_LOCATION;
}
}

Просмотреть файл

@ -11,6 +11,7 @@ import { isFulfilled } from "../utils/async-value";
import { originalToGeneratedId } from "devtools/client/shared/source-map-loader/index";
import { prefs } from "../utils/prefs";
import { UNDEFINED_LOCATION, NO_LOCATION } from "../reducers/sources";
import {
hasSourceActor,
@ -110,6 +111,58 @@ export function getSelectedLocation(state) {
return state.sources.selectedLocation;
}
/**
* Return the "mapped" location for the currently selected location:
* - When selecting a location in an original source, returns
* the related location in the bundle source.
*
* - When selecting a location in a bundle source, returns
* the related location in the original source. This may return undefined
* while we are still computing this information. (we need to query the asynchronous SourceMap service)
*
* - Otherwise, when selecting a location in a source unrelated to source map
* or a pretty printed source, returns null.
*/
export function getSelectedMappedSource(state) {
const selectedLocation = getSelectedLocation(state);
if (!selectedLocation) {
return null;
}
// Don't map pretty printed to its related compressed source
if (selectedLocation.source.isPrettyPrinted) {
return null;
}
// If we are on a bundle with a functional source-map,
// the `selectLocation` action should compute the `selectedOriginalLocation` field.
if (
!selectedLocation.source.isOriginal &&
isSourceActorWithSourceMap(state, selectedLocation.sourceActor.id)
) {
const { selectedOriginalLocation } = state.sources;
// Return undefined if we are still loading the source map.
// `selectedOriginalLocation` will be set to undefined instead of null
if (
selectedOriginalLocation &&
selectedOriginalLocation != UNDEFINED_LOCATION &&
selectedOriginalLocation != NO_LOCATION
) {
return selectedOriginalLocation.source;
}
return null;
}
const mappedSource = getGeneratedSource(state, selectedLocation.source);
// getGeneratedSource will return the exact same source object on sources
// that don't map to any original source. In this case, return null
// as that's most likely a regular source, not using source maps.
if (mappedSource == selectedLocation.source) {
return null;
}
return mappedSource || null;
}
export const getSelectedSource = createSelector(
getSelectedLocation,
selectedLocation => {

Просмотреть файл

@ -44,8 +44,10 @@ export function createLocation({
export function debuggerToSourceMapLocation(location) {
return {
sourceId: location.source.id,
line: location.line,
column: location.column,
// In case of errors loading the source, we might not have a precise location.
// Defaults to first line and column.
line: location.line || 1,
column: location.column || 0,
};
}

Просмотреть файл

@ -48,10 +48,14 @@ export async function getGeneratedLocation(location, thunkArgs) {
* @param {Object} location
* @param {Object} thunkArgs
* Redux action thunk arguments
* @param {boolean} waitForSource
* @param {Object} options
* @param {boolean} options.waitForSource
* Default to false. If true is passed, this function will
* ensure waiting, possibly asynchronously for the related original source
* to be registered in the redux store.
* @param {boolean} options.looseSearch
* Default to false. If true, this won't query an exact mapping,
* but will also lookup for a loose match at the first column and next lines.
*
* @param {Object}
* The matching original location.
@ -59,14 +63,15 @@ export async function getGeneratedLocation(location, thunkArgs) {
export async function getOriginalLocation(
location,
thunkArgs,
waitForSource = false
{ waitForSource = false, looseSearch = false } = {}
) {
if (location.source.isOriginal) {
return location;
}
const { getState, sourceMapLoader } = thunkArgs;
const originalLocation = await sourceMapLoader.getOriginalLocation(
debuggerToSourceMapLocation(location)
debuggerToSourceMapLocation(location),
{ looseSearch }
);
if (!originalLocation) {
return location;

Просмотреть файл

@ -163,6 +163,7 @@ fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and
fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled
["browser_dbg-editor-select.js"]
fail-if = ["a11y_checks"] # Bug 1870062 clicked element may not be focusable and/or labeled
["browser_dbg-ember-original-variable-mapping-notifications.js"]
fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled

Просмотреть файл

@ -15,6 +15,10 @@ add_task(async function () {
clickElement(dbg, "prettyPrintButton");
await waitForSelectedSource(dbg, "math.min.js:formatted");
ok(
!findElement(dbg, "mappedSourceLink"),
"When we are on the pretty printed source, we don't show the link to the minified source"
);
const ppSrc = findSource(dbg, "math.min.js:formatted");
ok(ppSrc, "Pretty-printed source exists");
@ -43,6 +47,10 @@ add_task(async function () {
await selectSource(dbg, "math.min.js");
await waitForSelectedSource(dbg, "math.min.js");
ok(
!findElement(dbg, "mappedSourceLink"),
"When we are on the minified source, we don't show the link to the pretty printed source"
);
ok(
!findElement(dbg, "prettyPrintButton").disabled,

Просмотреть файл

@ -41,11 +41,11 @@ add_task(async function () {
assertPausedAtSourceAndLine(dbg, mainSrc.id, 4, 3);
// Tests the existence of the sourcemap link in the original source.
ok(findElement(dbg, "sourceMapLink"), "Sourcemap link in original source");
ok(findElement(dbg, "mappedSourceLink"), "Sourcemap link in original source");
await selectSource(dbg, "main.min.js");
ok(
!findElement(dbg, "sourceMapLink"),
!findElement(dbg, "mappedSourceLink"),
"No Sourcemap link exists in generated source"
);
});

Просмотреть файл

@ -99,10 +99,33 @@ add_task(async function () {
);
info("Click on jump to generated source link from editor's footer");
findElement(dbg, "sourceMapLink").click();
let mappedSourceLink = findElement(dbg, "mappedSourceLink");
is(
mappedSourceLink.textContent,
"To bundle.js",
"The link to mapped source mentions the bundle"
);
mappedSourceLink.click();
await waitForSelectedSource(dbg, bundleSrc);
assertPausedAtSourceAndLine(dbg, bundleSrc.id, 62);
// The mapped source link is computed asynchronously when we are on the bundle
mappedSourceLink = await waitFor(() => findElement(dbg, "mappedSourceLink"));
mappedSourceLink = findElement(dbg, "mappedSourceLink");
is(
mappedSourceLink.textContent,
"From entry.js",
"The link to mapped source mentions the original source"
);
info("Move the cursor within the bundle to another original source");
getCM(dbg).setCursor({ line: 70, ch: 0 });
mappedSourceLink = await waitFor(() => findElement(dbg, "mappedSourceLink"));
is(
mappedSourceLink.textContent,
"From times2.js",
"The link to mapped source updates to the newly selected original source within the bundle"
);
});
function assertBreakpointExists(dbg, source, line) {

Просмотреть файл

@ -41,11 +41,20 @@ add_task(async function () {
assertPausedAtSourceAndLine(dbg, mainSrc.id, 4);
// Tests the existence of the sourcemap link in the original source.
ok(findElement(dbg, "sourceMapLink"), "Sourcemap link in original source");
let sourceMapLink = findElement(dbg, "mappedSourceLink");
is(
sourceMapLink.textContent,
"To main.min.js",
"Sourcemap link in original source refers to the bundle"
);
await selectSource(dbg, "main.min.js");
ok(
!findElement(dbg, "sourceMapLink"),
"No Sourcemap link exists in generated source"
// The mapped source link is computed asynchronously when we are on the bundle
sourceMapLink = await waitFor(() => findElement(dbg, "mappedSourceLink"));
is(
sourceMapLink.textContent,
"From main.js",
"Sourcemap link in bundle refers to the original source"
);
});

Просмотреть файл

@ -1770,7 +1770,7 @@ const selectors = {
stepIn: ".stepIn.active",
trace: ".debugger-trace-menu-button",
prettyPrintButton: ".source-footer .prettyPrint",
sourceMapLink: ".source-footer .mapped-source",
mappedSourceLink: ".source-footer .mapped-source",
sourcesFooter: ".sources-panel .source-footer",
editorFooter: ".editor-pane .source-footer",
sourceNode: i => `.sources-list .tree-node:nth-child(${i}) .node`,

Просмотреть файл

@ -780,13 +780,21 @@ ignoreContextItem.ignoreLines.accesskey=i
ignoreContextItem.unignoreLines=Unignore lines
ignoreContextItem.unignoreLines.accesskey=u
# LOCALIZATION NOTE (sourceFooter.mappedSource): Text associated
# with a mapped source. %S is replaced by the source map origin.
sourceFooter.mappedSource=(From %S)
# LOCALIZATION NOTE (sourceFooter.mappedOriginalSource.title): Text associated
# with an original source mapped to a bundle. %S is replaced by the bundle url.
sourceFooter.mappedOriginalSource.title=From %S
# LOCALIZATION NOTE (sourceFooter.mappedSourceTooltip): Tooltip text associated
# with a mapped source. %S is replaced by the source map origin.
sourceFooter.mappedSourceTooltip=(Source mapped from %S)
# LOCALIZATION NOTE (sourceFooter.mappedOriginalSource.tooltip): Tooltip text associated
# with an original source mapped to a bundle. %S is replaced by bundle url.
sourceFooter.mappedOriginalSource.tooltip=Open related bundle (%S)
# LOCALIZATION NOTE (sourceFooter.mappedGeneratedSource.title): Text associated
# with a bundled source mapped to an original source. %S is replaced by the original source url.
sourceFooter.mappedGeneratedSource.title=To %S
# LOCALIZATION NOTE (sourceFooter.mappedGeneratedSource.tooltip): Tooltip text associated
# with a bundled source mapped to an original source. %S is replaced by the original source url.
sourceFooter.mappedGeneratedSource.tooltip=Open related original source (%S)
# LOCALIZATION NOTE (sourceFooter.mappedSuffix): Text associated
# with a mapped source. Displays next to URLs in tree and tabs.

Просмотреть файл

@ -255,17 +255,51 @@ async function getOriginalLocations(breakpointPositions, sourceId) {
return breakpointPositions;
}
function getOriginalLocationSync(map, location) {
/**
* Query the source map for a mapping from bundle location to original location.
*
* @param {SourceMapConsumer} map
* The source map for the bundle source.
* @param {Object} location
* A location within a bundle to map to an original location.
* @param {Object} options
* @param {Boolean} options.looseSearch
* Optional, if true, will do a loose search on first column and next lines
* until a mapping is found.
* @return {location}
* The mapped location in the original source.
*/
function getOriginalLocationSync(map, location, { looseSearch = false } = {}) {
// First check for an exact match
const {
source: sourceUrl,
line,
column,
} = map.originalPositionFor({
let match = map.originalPositionFor({
line: location.line,
column: location.column == null ? 0 : location.column,
});
// Then check for a loose match by sliding to first column and next lines
if (match.sourceUrl == null && looseSearch) {
let line = location.line;
// if a non-0 column was passed, we want to do the search from the beginning of the line,
// otherwise, we can start looking into next lines
let firstLineChecked = (location.column || 0) !== 0;
// Avoid looping through the whole file and limit the sliding search to the next 10 lines.
while (match.sourceUrl === null && line < location.line + 10) {
if (firstLineChecked) {
line++;
} else {
firstLineChecked = true;
}
match = map.originalPositionFor({
line,
column: 0,
bias: SourceMapConsumer.LEAST_UPPER_BOUND,
});
}
}
const { source: sourceUrl, line, column } = match;
if (sourceUrl == null) {
// No url means the location didn't map.
return null;
@ -279,7 +313,17 @@ function getOriginalLocationSync(map, location) {
};
}
async function getOriginalLocation(location) {
/**
* Map a bundle location to an original one.
*
* @param {Object} location
* Bundle location
* @param {Object} options
* See getORiginalLocationSync.
* @return {Object}
* Original location
*/
async function getOriginalLocation(location, options) {
if (!isGeneratedId(location.sourceId)) {
return null;
}
@ -289,7 +333,7 @@ async function getOriginalLocation(location) {
return null;
}
return getOriginalLocationSync(map, location);
return getOriginalLocationSync(map, location, options);
}
async function getOriginalSourceText(originalSourceId) {