chrome-devtools-frontend
Version:
Chrome DevTools UI
1,210 lines (1,109 loc) • 104 kB
text/typescript
/*
* Copyright (C) 2011 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/* eslint-disable rulesdir/no-imperative-dom-api */
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as Breakpoints from '../../models/breakpoints/breakpoints.js';
import * as Formatter from '../../models/formatter/formatter.js';
import * as SourceMapScopes from '../../models/source_map_scopes/source_map_scopes.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as Workspace from '../../models/workspace/workspace.js';
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
import * as ObjectUI from '../../ui/legacy/components/object_ui/object_ui.js';
import * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {AddDebugInfoURLDialog} from './AddSourceMapURLDialog.js';
import {BreakpointEditDialog} from './BreakpointEditDialog.js';
import * as SourceComponents from './components/components.js';
import {Plugin} from './Plugin.js';
import {SourcesPanel} from './SourcesPanel.js';
const {EMPTY_BREAKPOINT_CONDITION, NEVER_PAUSE_HERE_CONDITION} = Breakpoints.BreakpointManager;
const UIStrings = {
/**
*@description Text in Debugger Plugin of the Sources panel
*/
thisScriptIsOnTheDebuggersIgnore: 'This script is on the debugger\'s ignore list',
/**
*@description Text to stop preventing the debugger from stepping into library code
*/
removeFromIgnoreList: 'Remove from ignore list',
/**
*@description Text of a button in the Sources panel Debugger Plugin to configure ignore listing in Settings
*/
configure: 'Configure',
/**
*@description Text to add a breakpoint
*/
addBreakpoint: 'Add breakpoint',
/**
*@description A context menu item in the Debugger Plugin of the Sources panel
*/
addConditionalBreakpoint: 'Add conditional breakpoint…',
/**
*@description A context menu item in the Debugger Plugin of the Sources panel
*/
addLogpoint: 'Add logpoint…',
/**
*@description A context menu item in the Debugger Plugin of the Sources panel
*/
neverPauseHere: 'Never pause here',
/**
*@description Context menu command to delete/remove a breakpoint that the user
*has set. One line of code can have multiple breakpoints. Always >= 1 breakpoint.
*/
removeBreakpoint: '{n, plural, =1 {Remove breakpoint} other {Remove all breakpoints in line}}',
/**
*@description A context menu item in the Debugger Plugin of the Sources panel
*/
editBreakpoint: 'Edit breakpoint…',
/**
*@description Context menu command to disable (but not delete) a breakpoint
*that the user has set. One line of code can have multiple breakpoints. Always
*>= 1 breakpoint.
*/
disableBreakpoint: '{n, plural, =1 {Disable breakpoint} other {Disable all breakpoints in line}}',
/**
*@description Context menu command to enable a breakpoint that the user has
*set. One line of code can have multiple breakpoints. Always >= 1 breakpoint.
*/
enableBreakpoint: '{n, plural, =1 {Enable breakpoint} other {Enable all breakpoints in line}}',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
addSourceMap: 'Add source map…',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
addWasmDebugInfo: 'Add DWARF debug info…',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
sourceMapLoaded: 'Source map loaded',
/**
*@description Title of the Filtered List WidgetProvider of Quick Open
*@example {Ctrl+P Ctrl+O} PH1
*/
associatedFilesAreAvailable: 'Associated files are available via file tree or {PH1}.',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
associatedFilesShouldBeAdded:
'Associated files should be added to the file tree. You can debug these resolved source files as regular JavaScript files.',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
theDebuggerWillSkipStepping: 'The debugger will skip stepping through this script, and will not stop on exceptions.',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
sourceMapSkipped: 'Source map skipped for this file',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
sourceMapFailed: 'Source map failed to load',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
debuggingPowerReduced: 'DevTools can\'t show authored sources, but you can debug the deployed code.',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
reloadForSourceMap: 'To enable again, make sure the file isn\'t on the ignore list and reload.',
/**
*@description Text in Debugger Plugin of the Sources panel
*@example {http://site.com/lib.js.map} PH1
*@example {HTTP error: status code 404, net::ERR_UNKNOWN_URL_SCHEME} PH2
*/
errorLoading: 'Error loading url {PH1}: {PH2}',
/**
*@description Error message that is displayed in UI when a file needed for debugging information for a call frame is missing
*@example {src/myapp.debug.wasm.dwp} PH1
*/
debugFileNotFound: 'Failed to load debug file "{PH1}".',
/**
*@description Error message that is displayed when no debug info could be loaded
*@example {app.wasm} PH1
*/
debugInfoNotFound: 'Failed to load any debug info for {PH1}',
/**
*@description Text of a button to open up details on a request when no debug info could be loaded
*/
showRequest: 'Show request',
/**
*@description Tooltip text that shows on hovering over a button to see more details on a request
*/
openDeveloperResources: 'Opens the request in the Developer resource panel',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/sources/DebuggerPlugin.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
// Note: Line numbers are passed around as zero-based numbers (though
// CodeMirror numbers them from 1).
// Don't scan for possible breakpoints on a line beyond this position;
const MAX_POSSIBLE_BREAKPOINT_LINE = 2500;
// Limits on inline variable view computation.
const MAX_CODE_SIZE_FOR_VALUE_DECORATIONS = 10000;
const MAX_PROPERTIES_IN_SCOPE_FOR_VALUE_DECORATIONS = 500;
interface BreakpointDescription {
position: number;
breakpoint: Breakpoints.BreakpointManager.Breakpoint;
}
interface BreakpointEditRequest {
line: CodeMirror.Line;
breakpoint: Breakpoints.BreakpointManager.Breakpoint|null;
location: {lineNumber: number, columnNumber: number}|null;
isLogpoint?: boolean;
}
const debuggerPluginForUISourceCode = new Map<Workspace.UISourceCode.UISourceCode, DebuggerPlugin>();
export class DebuggerPlugin extends Plugin {
private editor: TextEditor.TextEditor.TextEditor|undefined = undefined;
// Set if the debugger is stopped on a breakpoint in this file
private executionLocation: Workspace.UISourceCode.UILocation|null = null;
// Track state of the control key because holding it makes debugger
// target locations show up in the editor
private controlDown = false;
private controlTimeout: number|undefined = undefined;
private sourceMapInfobar: UI.Infobar.Infobar|null = null;
private readonly scriptsPanel: SourcesPanel;
private readonly breakpointManager: Breakpoints.BreakpointManager.BreakpointManager;
// Manages pop-overs shown when the debugger is active and the user
// hovers over an expression
private popoverHelper: UI.PopoverHelper.PopoverHelper|null = null;
private scriptFileForDebuggerModel:
Map<SDK.DebuggerModel.DebuggerModel, Bindings.ResourceScriptMapping.ResourceScriptFile>;
// The current set of breakpoints for this file. The locations in
// here are kept in sync with their editor position. When a file's
// content is edited and later saved, these are used as a source of
// truth for re-creating the breakpoints.
private breakpoints: BreakpointDescription[] = [];
private continueToLocations: Array<{from: number, to: number, async: boolean, click: () => void}>|null = null;
private readonly liveLocationPool: Bindings.LiveLocation.LiveLocationPool;
// When the editor content is changed by the user, this becomes
// true. When the plugin is muted, breakpoints show up as disabled
// and can't be manipulated. It is cleared again when the content is
// saved.
private muted: boolean;
// If the plugin is initialized in muted state, we cannot correlated
// breakpoint position in the breakpoint manager with editor
// locations, so breakpoint manipulation is permanently disabled.
private initializedMuted: boolean;
private ignoreListInfobar: UI.Infobar.Infobar|null;
private refreshBreakpointsTimeout: undefined|number = undefined;
private activeBreakpointDialog: BreakpointEditDialog|null = null;
#activeBreakpointEditRequest?: BreakpointEditRequest = undefined;
#scheduledFinishingActiveDialog = false;
private missingDebugInfoBar: UI.Infobar.Infobar|null = null;
#sourcesPanelDebuggedMetricsRecorded = false;
private readonly loader: SDK.PageResourceLoader.PageResourceLoader;
private readonly ignoreListCallback: () => void;
constructor(
uiSourceCode: Workspace.UISourceCode.UISourceCode,
private readonly transformer: SourceFrame.SourceFrame.Transformer) {
super(uiSourceCode);
debuggerPluginForUISourceCode.set(uiSourceCode, this);
this.scriptsPanel = SourcesPanel.instance();
this.breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance();
this.breakpointManager.addEventListener(
Breakpoints.BreakpointManager.Events.BreakpointAdded, this.breakpointChange, this);
this.breakpointManager.addEventListener(
Breakpoints.BreakpointManager.Events.BreakpointRemoved, this.breakpointChange, this);
this.uiSourceCode.addEventListener(Workspace.UISourceCode.Events.WorkingCopyChanged, this.workingCopyChanged, this);
this.uiSourceCode.addEventListener(
Workspace.UISourceCode.Events.WorkingCopyCommitted, this.workingCopyCommitted, this);
this.scriptFileForDebuggerModel = new Map();
this.loader = SDK.PageResourceLoader.PageResourceLoader.instance();
this.loader.addEventListener(
SDK.PageResourceLoader.Events.UPDATE, this.showSourceMapInfobarIfNeeded.bind(this), this);
this.ignoreListCallback = this.showIgnoreListInfobarIfNeeded.bind(this);
Bindings.IgnoreListManager.IgnoreListManager.instance().addChangeListener(this.ignoreListCallback);
UI.Context.Context.instance().addFlavorChangeListener(SDK.DebuggerModel.CallFrame, this.callFrameChanged, this);
this.liveLocationPool = new Bindings.LiveLocation.LiveLocationPool();
this.updateScriptFiles();
this.muted = this.uiSourceCode.isDirty();
this.initializedMuted = this.muted;
this.ignoreListInfobar = null;
this.showIgnoreListInfobarIfNeeded();
for (const scriptFile of this.scriptFileForDebuggerModel.values()) {
scriptFile.checkMapping();
}
}
override editorExtension(): CodeMirror.Extension {
// Kludge to hook editor keyboard events into the ShortcutRegistry
// system.
const handlers = this.shortcutHandlers();
return [
CodeMirror.EditorView.updateListener.of(update => this.onEditorUpdate(update)),
CodeMirror.EditorView.domEventHandlers({
keydown: event => {
if (this.onKeyDown(event)) {
return true;
}
handlers(event);
return event.defaultPrevented;
},
keyup: event => this.onKeyUp(event),
mousemove: event => this.onMouseMove(event),
mousedown: event => this.onMouseDown(event),
focusout: event => this.onBlur(event),
wheel: event => this.onWheel(event),
}),
CodeMirror.lineNumbers({
domEventHandlers: {
click: (view, block, event) => this.handleGutterClick(view.state.doc.lineAt(block.from), event as MouseEvent),
},
}),
breakpointMarkers,
TextEditor.ExecutionPositionHighlighter.positionHighlighter('cm-executionLine', 'cm-executionToken'),
CodeMirror.Prec.lowest(continueToMarkers.field),
markIfContinueTo,
valueDecorations.field,
CodeMirror.Prec.lowest(evalExpression.field),
theme,
this.uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Debugger ?
CodeMirror.EditorView.editorAttributes.of({class: 'source-frame-debugger-script'}) :
[],
];
}
private shortcutHandlers(): (event: KeyboardEvent) => void {
const selectionLine = (editor: TextEditor.TextEditor.TextEditor): CodeMirror.Line => {
return editor.state.doc.lineAt(editor.state.selection.main.head);
};
return UI.ShortcutRegistry.ShortcutRegistry.instance().getShortcutListener({
'debugger.toggle-breakpoint': async () => {
if (this.muted || !this.editor) {
return false;
}
await this.toggleBreakpoint(selectionLine(this.editor), false);
return true;
},
'debugger.toggle-breakpoint-enabled': async () => {
if (this.muted || !this.editor) {
return false;
}
await this.toggleBreakpoint(selectionLine(this.editor), true);
return true;
},
'debugger.breakpoint-input-window': async () => {
if (this.muted || !this.editor) {
return false;
}
const line = selectionLine(this.editor);
this.#openEditDialogForLine(line);
return true;
},
});
}
#openEditDialogForLine(line: CodeMirror.Line, isLogpoint?: boolean): void {
if (this.muted) {
return;
}
if (this.activeBreakpointDialog) {
this.activeBreakpointDialog.finishEditing(false, '');
}
const breakpoint = this.breakpoints.find(b => b.position >= line.from && b.position <= line.to)?.breakpoint || null;
if (isLogpoint === undefined && breakpoint !== null) {
isLogpoint = breakpoint.isLogpoint();
}
this.editBreakpointCondition({line, breakpoint, location: null, isLogpoint});
}
override editorInitialized(editor: TextEditor.TextEditor.TextEditor): void {
// Start asynchronous actions that require access to the editor
// instance
this.editor = editor;
computeNonBreakableLines(editor.state, this.transformer, this.uiSourceCode).then(linePositions => {
if (linePositions.length) {
editor.dispatch({effects: SourceFrame.SourceFrame.addNonBreakableLines.of(linePositions)});
}
}, console.error);
if (this.ignoreListInfobar) {
this.attachInfobar(this.ignoreListInfobar);
}
if (this.missingDebugInfoBar) {
this.attachInfobar(this.missingDebugInfoBar);
}
if (this.sourceMapInfobar) {
this.attachInfobar(this.sourceMapInfobar);
}
if (!this.muted) {
void this.refreshBreakpoints();
}
void this.callFrameChanged();
this.popoverHelper?.dispose();
this.popoverHelper =
new UI.PopoverHelper.PopoverHelper(editor, this.getPopoverRequest.bind(this), 'sources.object-properties');
this.popoverHelper.setDisableOnClick(true);
this.popoverHelper.setTimeout(250, 250);
}
static override accepts(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
return uiSourceCode.contentType().hasScripts();
}
private showIgnoreListInfobarIfNeeded(): void {
const uiSourceCode = this.uiSourceCode;
if (!uiSourceCode.contentType().hasScripts()) {
return;
}
if (!Bindings.IgnoreListManager.IgnoreListManager.instance().isUserOrSourceMapIgnoreListedUISourceCode(
uiSourceCode)) {
this.hideIgnoreListInfobar();
return;
}
if (this.ignoreListInfobar) {
this.ignoreListInfobar.dispose();
}
function unIgnoreList(): void {
Bindings.IgnoreListManager.IgnoreListManager.instance().unIgnoreListUISourceCode(uiSourceCode);
}
const infobar = new UI.Infobar.Infobar(
UI.Infobar.Type.WARNING, i18nString(UIStrings.thisScriptIsOnTheDebuggersIgnore),
[
{
text: i18nString(UIStrings.configure),
delegate:
UI.ViewManager.ViewManager.instance().showView.bind(UI.ViewManager.ViewManager.instance(), 'blackbox'),
dismiss: false,
jslogContext: 'configure',
},
{
text: i18nString(UIStrings.removeFromIgnoreList),
delegate: unIgnoreList,
buttonVariant: Buttons.Button.Variant.TONAL,
dismiss: true,
jslogContext: 'remove-from-ignore-list',
}
],
undefined, 'script-on-ignore-list');
this.ignoreListInfobar = infobar;
infobar.setCloseCallback(() => this.removeInfobar(this.ignoreListInfobar));
infobar.createDetailsRowMessage(i18nString(UIStrings.theDebuggerWillSkipStepping));
this.attachInfobar(this.ignoreListInfobar);
}
attachInfobar(bar: UI.Infobar.Infobar): void {
if (this.editor) {
this.editor.dispatch({effects: SourceFrame.SourceFrame.addInfobar.of(bar)});
}
}
removeInfobar(bar: UI.Infobar.Infobar|null): void {
if (this.editor && bar) {
this.editor.dispatch({effects: SourceFrame.SourceFrame.removeInfobar.of(bar)});
}
}
private hideIgnoreListInfobar(): void {
if (!this.ignoreListInfobar) {
return;
}
this.ignoreListInfobar.dispose();
this.ignoreListInfobar = null;
}
override willHide(): void {
this.popoverHelper?.hidePopover();
}
editBreakpointLocation({breakpoint, uiLocation}: Breakpoints.BreakpointManager.BreakpointLocation): void {
const {lineNumber} = this.transformer.uiLocationToEditorLocation(uiLocation.lineNumber, uiLocation.columnNumber);
const line = this.editor?.state.doc.line(lineNumber + 1);
if (!line) {
return;
}
this.editBreakpointCondition({line, breakpoint, location: null, isLogpoint: breakpoint.isLogpoint()});
}
override populateLineGutterContextMenu(contextMenu: UI.ContextMenu.ContextMenu, editorLineNumber: number): void {
const uiLocation = new Workspace.UISourceCode.UILocation(this.uiSourceCode, editorLineNumber, 0);
this.scriptsPanel.appendUILocationItems(contextMenu, uiLocation);
if (this.muted || !this.editor) {
return;
}
const line = this.editor.state.doc.line(editorLineNumber + 1);
const breakpoints = this.lineBreakpoints(line);
const supportsConditionalBreakpoints =
Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().supportsConditionalBreakpoints(
this.uiSourceCode);
if (!breakpoints.length) {
if (this.editor && SourceFrame.SourceFrame.isBreakableLine(this.editor.state, line)) {
contextMenu.debugSection().appendItem(
i18nString(UIStrings.addBreakpoint),
this.createNewBreakpoint.bind(
this, line, EMPTY_BREAKPOINT_CONDITION, /* enabled */ true, /* isLogpoint */ false),
{jslogContext: 'add-breakpoint'});
if (supportsConditionalBreakpoints) {
contextMenu.debugSection().appendItem(i18nString(UIStrings.addConditionalBreakpoint), () => {
this.editBreakpointCondition({line, breakpoint: null, location: null, isLogpoint: false});
}, {jslogContext: 'add-cnd-breakpoint'});
contextMenu.debugSection().appendItem(i18nString(UIStrings.addLogpoint), () => {
this.editBreakpointCondition({line, breakpoint: null, location: null, isLogpoint: true});
}, {jslogContext: 'add-logpoint'});
contextMenu.debugSection().appendItem(
i18nString(UIStrings.neverPauseHere),
this.createNewBreakpoint.bind(
this, line, NEVER_PAUSE_HERE_CONDITION, /* enabled */ true, /* isLogpoint */ false),
{jslogContext: 'never-pause-here'});
}
}
} else {
const removeTitle = i18nString(UIStrings.removeBreakpoint, {n: breakpoints.length});
contextMenu.debugSection().appendItem(
removeTitle, () => breakpoints.forEach(breakpoint => {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.BreakpointRemovedFromGutterContextMenu);
void breakpoint.remove(false);
}),
{jslogContext: 'remove-breakpoint'});
if (breakpoints.length === 1 && supportsConditionalBreakpoints) {
// Editing breakpoints only make sense for conditional breakpoints
// and logpoints and both are currently only available for JavaScript
// debugging.
contextMenu.debugSection().appendItem(i18nString(UIStrings.editBreakpoint), () => {
this.editBreakpointCondition({line, breakpoint: breakpoints[0], location: null});
}, {jslogContext: 'edit-breakpoint'});
}
const hasEnabled = breakpoints.some(breakpoint => breakpoint.enabled());
if (hasEnabled) {
const title = i18nString(UIStrings.disableBreakpoint, {n: breakpoints.length});
contextMenu.debugSection().appendItem(
title, () => breakpoints.forEach(breakpoint => breakpoint.setEnabled(false)),
{jslogContext: 'enable-breakpoint'});
}
const hasDisabled = breakpoints.some(breakpoint => !breakpoint.enabled());
if (hasDisabled) {
const title = i18nString(UIStrings.enableBreakpoint, {n: breakpoints.length});
contextMenu.debugSection().appendItem(
title, () => breakpoints.forEach(breakpoint => breakpoint.setEnabled(true)),
{jslogContext: 'disable-breakpoint'});
}
}
}
override populateTextAreaContextMenu(contextMenu: UI.ContextMenu.ContextMenu): void {
function addSourceMapURL(scriptFile: Bindings.ResourceScriptMapping.ResourceScriptFile): void {
const dialog =
AddDebugInfoURLDialog.createAddSourceMapURLDialog(addSourceMapURLDialogCallback.bind(null, scriptFile));
dialog.show();
}
function addSourceMapURLDialogCallback(
scriptFile: Bindings.ResourceScriptMapping.ResourceScriptFile, url: Platform.DevToolsPath.UrlString): void {
if (!url) {
return;
}
scriptFile.addSourceMapURL(url);
}
function addDebugInfoURL(
this: DebuggerPlugin, scriptFile: Bindings.ResourceScriptMapping.ResourceScriptFile): void {
const dialog =
AddDebugInfoURLDialog.createAddDWARFSymbolsURLDialog(addDebugInfoURLDialogCallback.bind(this, scriptFile));
dialog.show();
}
function addDebugInfoURLDialogCallback(
this: DebuggerPlugin, scriptFile: Bindings.ResourceScriptMapping.ResourceScriptFile,
url: Platform.DevToolsPath.UrlString): void {
if (!url) {
return;
}
scriptFile.addDebugInfoURL(url);
if (scriptFile.script?.debuggerModel) {
this.updateScriptFile(scriptFile.script?.debuggerModel);
}
}
if (this.uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network &&
Common.Settings.Settings.instance().moduleSetting('js-source-maps-enabled').get() &&
!Bindings.IgnoreListManager.IgnoreListManager.instance().isUserIgnoreListedURL(this.uiSourceCode.url())) {
if (this.scriptFileForDebuggerModel.size) {
const scriptFile: Bindings.ResourceScriptMapping.ResourceScriptFile =
this.scriptFileForDebuggerModel.values().next().value as Bindings.ResourceScriptMapping.ResourceScriptFile;
const addSourceMapURLLabel = i18nString(UIStrings.addSourceMap);
contextMenu.debugSection().appendItem(
addSourceMapURLLabel, addSourceMapURL.bind(null, scriptFile), {jslogContext: 'add-source-map'});
if (scriptFile.script?.isWasm() &&
!Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().pluginManager.hasPluginForScript(
scriptFile.script)) {
contextMenu.debugSection().appendItem(
i18nString(UIStrings.addWasmDebugInfo), addDebugInfoURL.bind(this, scriptFile),
{jslogContext: 'add-wasm-debug-info'});
}
}
}
}
private workingCopyChanged(): void {
if (!this.scriptFileForDebuggerModel.size) {
this.setMuted(this.uiSourceCode.isDirty());
}
}
private workingCopyCommitted(): void {
this.scriptsPanel.updateLastModificationTime();
if (!this.scriptFileForDebuggerModel.size) {
this.setMuted(false);
}
}
private didMergeToVM(): void {
if (this.consistentScripts()) {
this.setMuted(false);
}
}
private didDivergeFromVM(): void {
this.setMuted(true);
}
private setMuted(value: boolean): void {
if (this.initializedMuted) {
return;
}
if (value !== this.muted) {
this.muted = value;
if (!value) {
void this.restoreBreakpointsAfterEditing();
} else if (this.editor) {
this.editor.dispatch({effects: muteBreakpoints.of(null)});
}
}
}
private consistentScripts(): boolean {
for (const scriptFile of this.scriptFileForDebuggerModel.values()) {
if (scriptFile.hasDivergedFromVM() || scriptFile.isMergingToVM()) {
return false;
}
}
return true;
}
private isIdentifier(tokenType: string): boolean {
return tokenType === 'VariableName' || tokenType === 'VariableDefinition' || tokenType === 'PropertyName' ||
tokenType === 'PropertyDefinition';
}
private getPopoverRequest(event: MouseEvent|KeyboardEvent): UI.PopoverHelper.PopoverRequest|null {
if (event instanceof KeyboardEvent) {
return null;
}
if (UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event)) {
return null;
}
const target = UI.Context.Context.instance().flavor(SDK.Target.Target);
const debuggerModel = target ? target.model(SDK.DebuggerModel.DebuggerModel) : null;
const {editor} = this;
if (!debuggerModel || !debuggerModel.isPaused() || !editor) {
return null;
}
const selectedCallFrame =
(UI.Context.Context.instance().flavor(SDK.DebuggerModel.CallFrame) as SDK.DebuggerModel.CallFrame);
if (!selectedCallFrame) {
return null;
}
let textPosition = editor.editor.posAtCoords(event);
if (!textPosition) {
return null;
}
const positionCoords = editor.editor.coordsAtPos(textPosition);
if (!positionCoords || event.clientY < positionCoords.top || event.clientY > positionCoords.bottom ||
event.clientX < positionCoords.left - 30 || event.clientX > positionCoords.right + 30) {
return null;
}
if (event.clientX < positionCoords.left && textPosition > editor.state.doc.lineAt(textPosition).from) {
textPosition -= 1;
}
const highlightRange = computePopoverHighlightRange(editor.state, this.uiSourceCode.mimeType(), textPosition);
if (!highlightRange) {
return null;
}
const highlightLine = editor.state.doc.lineAt(highlightRange.from);
if (highlightRange.to > highlightLine.to) {
return null;
}
const leftCorner = editor.editor.coordsAtPos(highlightRange.from);
const rightCorner = editor.editor.coordsAtPos(highlightRange.to);
if (!leftCorner || !rightCorner) {
return null;
}
const box = new AnchorBox(
leftCorner.left, leftCorner.top - 2, rightCorner.right - leftCorner.left, rightCorner.bottom - leftCorner.top);
const evaluationText = editor.state.sliceDoc(highlightRange.from, highlightRange.to);
let objectPopoverHelper: ObjectUI.ObjectPopoverHelper.ObjectPopoverHelper|null = null;
return {
box,
show: async (popover: UI.GlassPane.GlassPane) => {
let resolvedText = '';
if (selectedCallFrame.script.isJavaScript()) {
const nameMap = await SourceMapScopes.NamesResolver.allVariablesInCallFrame(selectedCallFrame);
try {
resolvedText =
await Formatter.FormatterWorkerPool.formatterWorkerPool().javaScriptSubstitute(evaluationText, nameMap);
} catch {
}
}
// We use side-effect free debug-evaluate when the highlighted expression contains a
// function/method call. Otherwise we allow side-effects. The motiviation here are
// frameworks like Vue, that heavily use proxies for caching:
//
// * We deem a simple property access of a proxy as deterministic so it should be
// successful even if V8 thinks its side-effecting.
// * Explicit function calls on the other hand must be side-effect free. The canonical
// example is hovering over {Math.random()} which would result in a different value
// each time the user hovers over it.
const throwOnSideEffect = highlightRange.containsSideEffects;
const result = await selectedCallFrame.evaluate({
expression: resolvedText || evaluationText,
objectGroup: 'popover',
includeCommandLineAPI: false,
silent: true,
returnByValue: false,
generatePreview: false,
throwOnSideEffect,
timeout: undefined,
disableBreaks: undefined,
replMode: undefined,
allowUnsafeEvalBlockedByCSP: undefined,
});
if (!result || 'error' in result || !result.object ||
(result.object.type === 'object' && result.object.subtype === 'error')) {
return false;
}
objectPopoverHelper =
await ObjectUI.ObjectPopoverHelper.ObjectPopoverHelper.buildObjectPopover(result.object, popover);
const potentiallyUpdatedCallFrame = UI.Context.Context.instance().flavor(SDK.DebuggerModel.CallFrame);
if (!objectPopoverHelper || selectedCallFrame !== potentiallyUpdatedCallFrame) {
debuggerModel.runtimeModel().releaseObjectGroup('popover');
if (objectPopoverHelper) {
objectPopoverHelper.dispose();
}
return false;
}
const decoration = CodeMirror.Decoration.set(evalExpressionMark.range(highlightRange.from, highlightRange.to));
editor.dispatch({effects: evalExpression.update.of(decoration)});
return true;
},
hide: () => {
if (objectPopoverHelper) {
objectPopoverHelper.dispose();
}
debuggerModel.runtimeModel().releaseObjectGroup('popover');
editor.dispatch({effects: evalExpression.update.of(CodeMirror.Decoration.none)});
},
};
}
private onEditorUpdate(update: CodeMirror.ViewUpdate): void {
if (!update.changes.empty) {
// If the document changed, adjust known breakpoint positions
// for that change
for (const breakpointDesc of this.breakpoints) {
breakpointDesc.position = update.changes.mapPos(breakpointDesc.position);
}
}
}
private onWheel(event: WheelEvent): void {
if (this.executionLocation && UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event)) {
event.preventDefault();
}
}
private onKeyDown(event: KeyboardEvent): boolean {
const ctrlDown = UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event);
if (!ctrlDown) {
this.setControlDown(false);
}
if (event.key === Platform.KeyboardUtilities.ESCAPE_KEY) {
if (this.popoverHelper?.isPopoverVisible()) {
this.popoverHelper.hidePopover();
event.consume();
return true;
}
}
if (ctrlDown && this.executionLocation) {
this.setControlDown(true);
}
return false;
}
private onMouseMove(event: MouseEvent): void {
if (this.executionLocation && this.controlDown &&
UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event)) {
if (!this.continueToLocations) {
void this.showContinueToLocations();
}
}
}
private onMouseDown(event: MouseEvent): void {
if (!this.executionLocation || !UI.KeyboardShortcut.KeyboardShortcut.eventHasCtrlEquivalentKey(event)) {
return;
}
if (!this.continueToLocations || !this.editor) {
return;
}
event.consume();
const textPosition = this.editor.editor.posAtCoords(event);
if (textPosition === null) {
return;
}
for (const {from, to, click} of this.continueToLocations) {
if (from <= textPosition && to >= textPosition) {
click();
break;
}
}
}
private onBlur(_event: Event): void {
this.setControlDown(false);
}
private onKeyUp(_event: KeyboardEvent): void {
this.setControlDown(false);
}
private setControlDown(state: boolean): void {
if (state !== this.controlDown) {
this.controlDown = state;
clearTimeout(this.controlTimeout);
this.controlTimeout = undefined;
if (state && this.executionLocation) {
this.controlTimeout = window.setTimeout(() => {
if (this.executionLocation && this.controlDown) {
void this.showContinueToLocations();
}
}, 150);
} else {
this.clearContinueToLocations();
}
}
}
private editBreakpointCondition(breakpointEditRequest: BreakpointEditRequest): void {
const {line, breakpoint, location, isLogpoint} = breakpointEditRequest;
if (breakpoint?.isRemoved) {
// This method can get called for stale breakpoints, e.g. via the revealer.
// In that case we don't show the edit dialog as to not resurrect the breakpoint
// unintentionally.
return;
}
this.#scheduledFinishingActiveDialog = false;
const isRepeatedEditRequest = this.#activeBreakpointEditRequest &&
isSameEditRequest(this.#activeBreakpointEditRequest, breakpointEditRequest);
if (isRepeatedEditRequest) {
// Do not re-show the same edit dialog, instead use the already open one.
return;
}
if (this.activeBreakpointDialog) {
// If this a request to edit a different dialog, make sure to close the current active one
// to avoid showing two dialogs at the same time.
this.activeBreakpointDialog.saveAndFinish();
}
const editor = this.editor as TextEditor.TextEditor.TextEditor;
const oldCondition = breakpoint ? breakpoint.condition() : '';
const isLogpointForDialog = breakpoint?.isLogpoint() ?? Boolean(isLogpoint);
const decorationElement = document.createElement('div');
const compartment = new CodeMirror.Compartment();
const dialog = new BreakpointEditDialog(line.number - 1, oldCondition, isLogpointForDialog, async result => {
this.activeBreakpointDialog = null;
this.#activeBreakpointEditRequest = undefined;
dialog.detach();
editor.dispatch({effects: compartment.reconfigure([])});
if (!result.committed) {
SourceComponents.BreakpointsView.BreakpointsSidebarController.instance().breakpointEditFinished(
breakpoint, false);
return;
}
SourceComponents.BreakpointsView.BreakpointsSidebarController.instance().breakpointEditFinished(
breakpoint, oldCondition !== result.condition);
if (breakpoint) {
breakpoint.setCondition(result.condition, result.isLogpoint);
} else if (location) {
await this.setBreakpoint(
location.lineNumber, location.columnNumber, result.condition, /* enabled */ true, result.isLogpoint);
} else {
await this.createNewBreakpoint(line, result.condition, /* enabled */ true, result.isLogpoint);
}
});
editor.dispatch({
effects: CodeMirror.StateEffect.appendConfig.of(compartment.of(CodeMirror.EditorView.decorations.of(
CodeMirror.Decoration.set([CodeMirror.Decoration
.widget({
block: true, widget: new class extends CodeMirror.WidgetType {
toDOM(): HTMLElement {
return decorationElement;
}
}(),
side: 1,
})
.range(line.to)])))),
});
dialog.element.addEventListener('blur', async event => {
if (!event.relatedTarget ||
(event.relatedTarget && !(event.relatedTarget as Node).isSelfOrDescendant(dialog.element))) {
this.#scheduledFinishingActiveDialog = true;
// Debounce repeated clicks on opening the edit dialog. Wait for a short amount of time
// in order to see whether we get a request to open the exact same dialog again.
setTimeout(() => {
if (this.activeBreakpointDialog === dialog) {
if (this.#scheduledFinishingActiveDialog) {
dialog.saveAndFinish();
this.#scheduledFinishingActiveDialog = false;
} else {
dialog.focusEditor();
}
}
}, 200);
}
}, true);
dialog.markAsExternallyManaged();
dialog.show(decorationElement);
dialog.focusEditor();
this.activeBreakpointDialog = dialog;
this.#activeBreakpointEditRequest = breakpointEditRequest;
function isSameEditRequest(editA: BreakpointEditRequest, editB: BreakpointEditRequest): boolean {
if (editA.line.number !== editB.line.number) {
return false;
}
if (editA.line.from !== editB.line.from) {
return false;
}
if (editA.line.text !== editB.line.text) {
return false;
}
if (editA.breakpoint !== editB.breakpoint) {
return false;
}
if (editA.location !== editB.location) {
return false;
}
return editA.isLogpoint === editB.isLogpoint;
}
}
// Show widgets with variable's values after lines that mention the
// variables, if the debugger is paused in this file.
private async updateValueDecorations(): Promise<void> {
if (!this.editor) {
return;
}
const decorations = this.executionLocation ? await this.computeValueDecorations() : null;
// After the `await` the DebuggerPlugin could have been disposed. Re-check `this.editor`.
if (!this.editor) {
return;
}
if (decorations || this.editor.state.field(valueDecorations.field).size) {
this.editor.dispatch({effects: valueDecorations.update.of(decorations || CodeMirror.Decoration.none)});
}
}
async #rawLocationToEditorOffset(location: SDK.DebuggerModel.Location|null, url: Platform.DevToolsPath.UrlString):
Promise<number|null> {
const uiLocation = location &&
await Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().rawLocationToUILocation(location);
if (!uiLocation || uiLocation.uiSourceCode.url() !== url) {
return null;
}
const offset = this.editor?.toOffset(
this.transformer.uiLocationToEditorLocation(uiLocation.lineNumber, uiLocation.columnNumber));
return offset ?? null;
}
private async computeValueDecorations(): Promise<CodeMirror.DecorationSet|null> {
if (!this.editor) {
return null;
}
if (!Common.Settings.Settings.instance().moduleSetting('inline-variable-values').get()) {
return null;
}
const executionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext);
if (!executionContext) {
return null;
}
const callFrame = UI.Context.Context.instance().flavor(SDK.DebuggerModel.CallFrame);
if (!callFrame) {
return null;
}
const url = this.uiSourceCode.url();
const rawLocationToEditorOffset: (location: SDK.DebuggerModel.Location|null) => Promise<number|null> = location =>
this.#rawLocationToEditorOffset(location, url);
const functionOffsetPromise = this.#rawLocationToEditorOffset(callFrame.functionLocation(), url);
const executionOffsetPromise = this.#rawLocationToEditorOffset(callFrame.location(), url);
const [functionOffset, executionOffset] = await Promise.all([functionOffsetPromise, executionOffsetPromise]);
if (!functionOffset || !executionOffset || !this.editor) {
return null;
}
if (functionOffset >= executionOffset || executionOffset - functionOffset > MAX_CODE_SIZE_FOR_VALUE_DECORATIONS) {
return null;
}
while (CodeMirror.syntaxParserRunning(this.editor.editor)) {
await new Promise(resolve => window.requestIdleCallback(resolve));
// After the `await` the DebuggerPlugin could have been disposed. Re-check `this.editor`.
if (!this.editor) {
return null;
}
CodeMirror.ensureSyntaxTree(this.editor.state, executionOffset, 16);
}
const variableNames = getVariableNamesByLine(this.editor.state, functionOffset, executionOffset, executionOffset);
if (variableNames.length === 0) {
return null;
}
const scopeMappings = await computeScopeMappings(callFrame, rawLocationToEditorOffset);
// After the `await` the DebuggerPlugin could have been disposed. Re-check `this.editor`.
if (!this.editor || scopeMappings.length === 0) {
return null;
}
const variablesByLine = getVariableValuesByLine(scopeMappings, variableNames);
if (!variablesByLine || !this.editor) {
return null;
}
const decorations: Array<CodeMirror.Range<CodeMirror.Decoration>> = [];
for (const [line, names] of variablesByLine) {
const prevLine = variablesByLine.get(line - 1);
let newNames = prevLine ? Array.from(names).filter(n => prevLine.get(n[0]) !== n[1]) : Array.from(names);
if (!newNames.length) {
continue;
}
if (newNames.length > 10) {
newNames = newNames.slice(0, 10);
}
decorations.push(CodeMirror.Decoration.widget({widget: new ValueDecoration(newNames), side: 1})
.range(this.editor.state.doc.line(line + 1).to));
}
return CodeMirror.Decoration.set(decorations, true);
}
// Highlight the locations the debugger can continue to (when
// Control is held)
private async showContinueToLocations(): Promise<void> {
this.popoverHelper?.hidePopover();
const executionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext);
if (!executionContext || !this.editor) {
return;
}
const callFrame = UI.Context.Context.instance().flavor(SDK.DebuggerModel.CallFrame);
if (!callFrame) {
return;
}
const start = callFrame.functionLocation() || callFrame.location();
const debuggerModel = callFrame.debuggerModel;
const {state} = this.editor;
const locations = await debuggerModel.getPossibleBreakpoints(start, null, true);
this.continueToLocations = [];
let previousCallLine = -1;
for (const location of locations.reverse()) {
const editorLocation = this.transformer.uiLocationToEditorLocation(location.lineNumber, location.columnNumber);
if (previousCallLine === editorLocation.lineNumber &&
location.type !== Protocol.Debugger.BreakLocationType.Call ||
editorLocation.lineNumber >= state.doc.lines) {
continue;
}
const line = state.doc.line(editorLocation.lineNumber + 1);
const position = Math.min(line.to, line.from + editorLocation.columnNumber);
let syntaxNode = CodeMirror.syntaxTree(state).resolveInner(position, 1);
if (syntaxNode.firstChild || syntaxNode.from < line.from ||
syntaxNode.to > line.to) { // Only use leaf nodes within the line
continue;
}
if (syntaxNode.name === '.') {
const nextNode = syntaxNode.resolve(syntaxNode.to, 1);
if (nextNode.firstChild || nextNode.from < line.from || nextNode.to > line.to) {
continue;
}
syntaxNode = nextNode;
}
const syntaxType = syntaxNode.name;
const validKeyword = syntaxType === 'this' || syntaxType === 'return' || syntaxType === 'new' ||
syntaxType === 'break' || syntaxType === 'continue';
if (!validKeyword && !this.isIdentifier(syntaxType)) {
continue;
}
this.continueToLocations.push(
{from: syntaxNode.from, to: syntaxNode.to, async: false, click: () => location.continueToLocation()});
if (location.type === Protocol.Debugger.BreakLocationType.Call) {
previousCallLine = editorLocation.lineNumber;
}
const identifierName =
validKeyword ? '' : line.text.slice(syntaxNode.from - line.from, syntaxNode.to - line.from);
let asyncCall: CodeMirror.SyntaxNode|null = null;
if (identifierName === 'then' && syntaxNode.parent?.name === 'MemberExpression') {
asyncCall = syntaxNode.parent.parent;
} else if (
identifierName === 'setTimeout' || identifierName === 'setInterval' || identifierName === 'postMessage') {
asyncCall = syntaxNode.parent;
}
if (syntaxType === 'new') {
const callee = syntaxNode.parent?.getChild('Expression');
if (callee && callee.name === 'VariableName' && state.sliceDoc(callee.from, callee.to) === 'Worker') {
asyncCall = syntaxNode.parent;
}
}
if (asyncCall && (asyncCall.name === 'CallExpression' || asyncCall.name === 'NewExpression') &&
location.type === Protocol.Debugger.BreakLocationType.Call) {
const firstArg = asyncCall.getChild('ArgList')?.firstChild?.nextSibling;
let highlightNode;
if (firstArg?.name === 'VariableName') {
highlightNode = firstArg;
} else if (firstArg?.name === 'ArrowFunction' || firstArg?.name === 'FunctionExpression') {
highlightNode = firstArg.firstChild;
if (highlightNode?.name === 'async') {
highlightNode = highlightNode.nextSibling;
}
}
if (highlightNode) {
const isCurrentPosition = this.executionLocation &&
location.lineNumber === this.executionLocation.lineNumber &&
location.columnNumber === this.executionLocation.columnNumber;
this.continueToLocations.push({
from: highlightNode.from,
to: highlightNode.to,
async: true,
click: () => this.asyncStepIn(location, Boolean(isCurrentPosition)),
});
}
}
}
const decorations = CodeMirror.Decoration.set(
this.continueToLocations.map(loc => {
return (loc.async ? asyncContinueToMark : continueToMark).range(loc.from, loc.to);
}),
true);
this.editor.dispatch({effects: continueToMarkers.update.of(decorations)});
}
private clearContinueToLocations(): void {
if (this.editor?.state.field(continueToMarkers.field).size) {
this.editor.dispatch({effects: continueToMarkers.update.of(CodeMirror.Decoration.none)});
}
}
private asyncStepIn(location: SDK.DebuggerModel.BreakLocation, isCurrentPosition: boolean): void {
if (!isCurrentPosition) {
location.continueToLocation(asyncStepIn);
} else {
asyncStepIn();
}
function asyncStepIn(): void {
location.debuggerModel.scheduleStepIntoAsync();
}
}
private fetchBreakpoints(): Array<{
position: number,
breakpoint: Breakpoints.BreakpointManager.Breakpoint,
}> {
if (!this.editor) {
return [];
}
const {editor} = this;
const breakpointLocations = this.breakpointManager.breakpointLocationsForUISourceCode(this.uiSourceCode);
return breakpointLocations.map(({uiLocation, breakpoint}) => {
const editorLocation =
this.transformer.uiLocationToEditorLocation(uiLocation.lineNumber, uiLocation.columnNumber);
return {
position: editor.toOffset(editorLocation),
breakpoint,
};
});
}
private lineBreakpoints(line: CodeMirror.Line): readonly Breakpoints.BreakpointManager.Breakpoint[] {
return this.breakpoints.filter(b => b.position >= line.from && b.posi