chrome-devtools-frontend
Version:
Chrome DevTools UI
1,199 lines (1,103 loc) • 87.3 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.
*/
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 Root from '../../core/root/root.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 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 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 type * as TextEditor from '../../ui/components/text_editor/text_editor.js';
import {AddDebugInfoURLDialog} from './AddSourceMapURLDialog.js';
import {BreakpointEditDialog, LogpointPrefix} from './BreakpointEditDialog.js';
import {Plugin} from './Plugin.js';
import {ScriptFormatterEditorAction} from './ScriptFormatterEditorAction.js';
import {SourcesPanel} from './SourcesPanel.js';
import {getRegisteredEditorActions} from './SourcesView.js';
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 in Debugger Plugin of the Sources panel
*/
sourceMapFoundButIgnoredForFile: 'Source map found, but ignored for file on ignore list.',
/**
*@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
*/
sourceMapDetected: 'Source map detected.',
/**
*@description Text in Debugger Plugin of the Sources panel
*/
prettyprintThisMinifiedFile: 'Pretty-print this minified file?',
/**
*@description Label of a button in the Sources panel to pretty-print the current file
*/
prettyprint: 'Pretty-print',
/**
*@description Text in Debugger Plugin pretty-print details message of the Sources panel
*@example {Debug} PH1
*/
prettyprintingWillFormatThisFile:
'Pretty-printing will format this file in a new tab where you can continue debugging. You can also pretty-print this file by clicking the {PH1} button on the bottom status bar.',
/**
*@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 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}.',
};
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;
type BreakpointDescription = {
position: number,
breakpoint: Bindings.BreakpointManager.Breakpoint,
};
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: boolean = false;
private controlTimeout: number|undefined = undefined;
private sourceMapInfobar: UI.Infobar.Infobar|null = null;
private readonly scriptsPanel: SourcesPanel;
private readonly breakpointManager: Bindings.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[] = [];
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private continueToLocations: {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 prettyPrintInfobar!: UI.Infobar.Infobar|null;
private refreshBreakpointsTimeout: undefined|number = undefined;
private activeBreakpointDialog: BreakpointEditDialog|null = null;
private missingDebugInfoBar: UI.Infobar.Infobar|null = null;
constructor(
uiSourceCode: Workspace.UISourceCode.UISourceCode,
private readonly transformer: SourceFrame.SourceFrame.Transformer) {
super(uiSourceCode);
this.scriptsPanel = SourcesPanel.instance();
this.breakpointManager = Bindings.BreakpointManager.BreakpointManager.instance();
this.breakpointManager.addEventListener(
Bindings.BreakpointManager.Events.BreakpointAdded, this.breakpointChange, this);
this.breakpointManager.addEventListener(
Bindings.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();
Common.Settings.Settings.instance()
.moduleSetting('skipStackFramesPattern')
.addChangeListener(this.showIgnoreListInfobarIfNeeded, this);
Common.Settings.Settings.instance()
.moduleSetting('skipContentScripts')
.addChangeListener(this.showIgnoreListInfobarIfNeeded, this);
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();
}
if (!Root.Runtime.experiments.isEnabled('sourcesPrettyPrint')) {
this.prettyPrintInfobar = null;
void this.detectMinified();
}
}
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): boolean => {
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: {
mousedown: (view, block, event) =>
this.handleGutterClick(view.state.doc.lineAt(block.from), event as MouseEvent),
},
}),
infobarState,
breakpointMarkers,
CodeMirror.Prec.highest(executionLine.field),
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(): Promise<boolean> => {
if (this.muted || !this.editor) {
return false;
}
await this.toggleBreakpoint(selectionLine(this.editor), false);
return true;
},
'debugger.toggle-breakpoint-enabled': async(): Promise<boolean> => {
if (this.muted || !this.editor) {
return false;
}
await this.toggleBreakpoint(selectionLine(this.editor), true);
return true;
},
'debugger.breakpoint-input-window': async(): Promise<boolean> => {
if (this.muted || !this.editor) {
return false;
}
const line = selectionLine(this.editor);
const breakpoint =
this.breakpoints.find(b => b.position >= line.from && b.position <= line.to)?.breakpoint || null;
const isLogpoint = breakpoint ? breakpoint.condition().includes(LogpointPrefix) : false;
this.editBreakpointCondition(line, breakpoint, null, isLogpoint);
return true;
},
});
}
editorInitialized(editor: TextEditor.TextEditor.TextEditor): void {
// Start asynchronous actions that require access to the editor
// instance
this.editor = editor;
computeNonBreakableLines(editor.state, this.uiSourceCode).then(linePositions => {
if (linePositions.length) {
editor.dispatch({effects: SourceFrame.SourceFrame.addNonBreakableLines.of(linePositions)});
}
}, console.error);
if (this.missingDebugInfoBar) {
this.attachInfobar(this.missingDebugInfoBar);
}
if (!this.muted) {
void this.refreshBreakpoints();
}
void this.callFrameChanged();
this.popoverHelper?.dispose();
this.popoverHelper = new UI.PopoverHelper.PopoverHelper(editor, this.getPopoverRequest.bind(this));
this.popoverHelper.setDisableOnClick(true);
this.popoverHelper.setTimeout(250, 250);
this.popoverHelper.setHasPadding(true);
}
static accepts(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
return uiSourceCode.contentType().hasScripts();
}
private showIgnoreListInfobarIfNeeded(): void {
const uiSourceCode = this.uiSourceCode;
if (!uiSourceCode.contentType().hasScripts()) {
return;
}
const projectType = uiSourceCode.project().type();
if (!Bindings.IgnoreListManager.IgnoreListManager.instance().isIgnoreListedUISourceCode(uiSourceCode)) {
this.hideIgnoreListInfobar();
return;
}
if (this.ignoreListInfobar) {
this.ignoreListInfobar.dispose();
}
function unIgnoreList(): void {
Bindings.IgnoreListManager.IgnoreListManager.instance().unIgnoreListUISourceCode(uiSourceCode);
if (projectType === Workspace.Workspace.projectTypes.ContentScripts) {
Bindings.IgnoreListManager.IgnoreListManager.instance().unIgnoreListContentScripts();
}
}
const infobar =
new UI.Infobar.Infobar(UI.Infobar.Type.Warning, i18nString(UIStrings.thisScriptIsOnTheDebuggersIgnore), [
{text: i18nString(UIStrings.removeFromIgnoreList), highlight: false, delegate: unIgnoreList, dismiss: true},
{
text: i18nString(UIStrings.configure),
highlight: false,
delegate:
UI.ViewManager.ViewManager.instance().showView.bind(UI.ViewManager.ViewManager.instance(), 'blackbox'),
dismiss: false,
},
]);
this.ignoreListInfobar = infobar;
infobar.setCloseCallback(() => this.removeInfobar(this.ignoreListInfobar));
infobar.createDetailsRowMessage(i18nString(UIStrings.theDebuggerWillSkipStepping));
const scriptFile =
this.scriptFileForDebuggerModel.size ? this.scriptFileForDebuggerModel.values().next().value : null;
if (scriptFile && scriptFile.hasSourceMapURL()) {
infobar.createDetailsRowMessage(i18nString(UIStrings.sourceMapFoundButIgnoredForFile));
}
this.attachInfobar(this.ignoreListInfobar);
}
attachInfobar(bar: UI.Infobar.Infobar): void {
if (this.editor) {
this.editor.dispatch({effects: addInfobar.of(bar)});
}
}
removeInfobar(bar: UI.Infobar.Infobar|null): void {
if (this.editor && bar) {
this.editor.dispatch({effects: removeInfobar.of(bar)});
}
}
private hideIgnoreListInfobar(): void {
if (!this.ignoreListInfobar) {
return;
}
this.ignoreListInfobar.dispose();
this.ignoreListInfobar = null;
}
willHide(): void {
this.popoverHelper?.hidePopover();
}
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, '', true));
if (supportsConditionalBreakpoints) {
contextMenu.debugSection().appendItem(
i18nString(UIStrings.addConditionalBreakpoint),
this.editBreakpointCondition.bind(this, line, null, null, false /* preferLogpoint */));
contextMenu.debugSection().appendItem(
i18nString(UIStrings.addLogpoint),
this.editBreakpointCondition.bind(this, line, null, null, true /* preferLogpoint */));
contextMenu.debugSection().appendItem(
i18nString(UIStrings.neverPauseHere), this.createNewBreakpoint.bind(this, line, 'false', true));
}
}
} else {
const removeTitle = i18nString(UIStrings.removeBreakpoint, {n: breakpoints.length});
contextMenu.debugSection().appendItem(
removeTitle, () => breakpoints.forEach(breakpoint => void breakpoint.remove(false)));
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.bind(this, line, breakpoints[0], null, false /* preferLogpoint */));
}
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)));
}
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)));
}
}
}
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(scriptFile: Bindings.ResourceScriptMapping.ResourceScriptFile): void {
const dialog =
AddDebugInfoURLDialog.createAddDWARFSymbolsURLDialog(addDebugInfoURLDialogCallback.bind(null, scriptFile));
dialog.show();
}
function addDebugInfoURLDialogCallback(
scriptFile: Bindings.ResourceScriptMapping.ResourceScriptFile, url: Platform.DevToolsPath.UrlString): void {
if (!url) {
return;
}
scriptFile.addDebugInfoURL(url);
}
if (this.uiSourceCode.project().type() === Workspace.Workspace.projectTypes.Network &&
Common.Settings.Settings.instance().moduleSetting('jsSourceMapsEnabled').get() &&
!Bindings.IgnoreListManager.IgnoreListManager.instance().isIgnoreListedUISourceCode(this.uiSourceCode)) {
if (this.scriptFileForDebuggerModel.size) {
const scriptFile: Bindings.ResourceScriptMapping.ResourceScriptFile =
this.scriptFileForDebuggerModel.values().next().value;
const addSourceMapURLLabel = i18nString(UIStrings.addSourceMap);
contextMenu.debugSection().appendItem(addSourceMapURLLabel, addSourceMapURL.bind(null, scriptFile));
if (scriptFile.script?.isWasm() &&
!Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().pluginManager?.hasPluginForScript(
scriptFile.script)) {
contextMenu.debugSection().appendItem(
i18nString(UIStrings.addWasmDebugInfo), addDebugInfoURL.bind(null, scriptFile));
}
}
}
}
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 isVariableIdentifier(tokenType: string): boolean {
return tokenType === 'VariableName' || tokenType === 'VariableDefinition';
}
private isIdentifier(tokenType: string): boolean {
return tokenType === 'VariableName' || tokenType === 'VariableDefinition' || tokenType === 'PropertyName' ||
tokenType === 'PropertyDefinition';
}
private getPopoverRequest(event: MouseEvent): UI.PopoverHelper.PopoverRequest|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 textSelection = editor.state.selection.main;
let highlightRange: {from: number, to: number};
if (!textSelection.empty) {
if (textPosition < textSelection.from || textPosition > textSelection.to) {
return null;
}
highlightRange = textSelection;
} else if (this.uiSourceCode.mimeType() === 'application/wasm') {
const node = CodeMirror.syntaxTree(editor.state).resolveInner(textPosition, 1);
if (node.name !== 'Identifier') {
return null;
}
// For $label identifiers we can't show a meaningful preview (https://crbug.com/1155548),
// so we suppress them for now. Label identifiers can only appear as operands to control
// instructions[1].
//
// [1]: https://webassembly.github.io/spec/core/text/instructions.html#control-instructions
const controlInstructions = ['block', 'loop', 'if', 'else', 'end', 'br', 'br_if', 'br_table'];
for (let parent: CodeMirror.SyntaxNode|null = node.parent; parent; parent = parent.parent) {
if (parent.name === 'App') {
const firstChild = parent.firstChild;
const opName = firstChild?.name === 'Keyword' && editor.state.sliceDoc(firstChild.from, firstChild.to);
if (opName && controlInstructions.includes(opName)) {
return null;
}
}
}
highlightRange = node;
} else if (/^text\/(javascript|typescript|jsx)/.test(this.uiSourceCode.mimeType())) {
let node: CodeMirror.SyntaxNode|null = CodeMirror.syntaxTree(editor.state).resolveInner(textPosition, 1);
// Only do something if the cursor is over a leaf node.
if (node?.firstChild) {
return null;
}
while (
node && node.name !== 'this' && node.name !== 'VariableDefinition' && node.name !== 'VariableName' &&
node.name !== 'MemberExpression' &&
!(node.name === 'PropertyName' && node.parent?.name === 'PatternProperty' &&
node.nextSibling?.name !== ':') &&
!(node.name === 'PropertyDefinition' && node.parent?.name === 'Property' && node.nextSibling?.name !== ':')) {
node = node.parent;
}
if (!node) {
return null;
}
highlightRange = node;
} else {
// In other languages, just assume a token consisting entirely
// of identifier-like characters is an identifier.
const node: CodeMirror.SyntaxNode = CodeMirror.syntaxTree(editor.state).resolveInner(textPosition, 1);
if (node.to - node.from > 50 || /[^\w_\-$]/.test(editor.state.sliceDoc(node.from, node.to))) {
return null;
}
highlightRange = node;
}
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;
async function evaluate(uiSourceCode: Workspace.UISourceCode.UISourceCode, evaluationText: string): Promise<{
object: SDK.RemoteObject.RemoteObject,
exceptionDetails?: Protocol.Runtime.ExceptionDetails,
}|{
error: string,
}|null> {
const resolvedText = await SourceMapScopes.NamesResolver.resolveExpression(
selectedCallFrame, evaluationText, uiSourceCode, highlightLine.number - 1,
highlightRange.from - highlightLine.from, highlightRange.to - highlightLine.from);
return await selectedCallFrame.evaluate({
expression: resolvedText || evaluationText,
objectGroup: 'popover',
includeCommandLineAPI: false,
silent: true,
returnByValue: false,
generatePreview: false,
throwOnSideEffect: undefined,
timeout: undefined,
disableBreaks: undefined,
replMode: undefined,
allowUnsafeEvalBlockedByCSP: undefined,
});
}
return {
box,
show: async(popover: UI.GlassPane.GlassPane): Promise<boolean> => {
const result = await evaluate(this.uiSourceCode, evaluationText);
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: (): void => {
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 && 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(
line: CodeMirror.Line, breakpoint: Bindings.BreakpointManager.Breakpoint|null, location: {
lineNumber: number,
columnNumber: number,
}|null,
preferLogpoint?: boolean): void {
const editor = this.editor as TextEditor.TextEditor.TextEditor;
const oldCondition = breakpoint ? breakpoint.condition() : '';
const decorationElement = document.createElement('div');
const compartment = new CodeMirror.Compartment();
const dialog = new BreakpointEditDialog(line.number - 1, oldCondition, Boolean(preferLogpoint), async result => {
this.activeBreakpointDialog = null;
dialog.detach();
editor.dispatch({effects: compartment.reconfigure([])});
if (!result.committed) {
return;
}
if (breakpoint) {
breakpoint.setCondition(result.condition);
} else if (location) {
await this.setBreakpoint(location.lineNumber, location.columnNumber, result.condition, true);
} else {
await this.createNewBreakpoint(line, result.condition, true);
}
});
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.from)])))),
});
dialog.markAsExternallyManaged();
dialog.show(decorationElement);
dialog.focusEditor();
this.activeBreakpointDialog = dialog;
}
// Create decorations to indicate the current debugging position
private computeExecutionDecorations(editorState: CodeMirror.EditorState, lineNumber: number, columnNumber: number):
CodeMirror.DecorationSet {
const {doc} = editorState;
if (lineNumber >= doc.lines) {
return CodeMirror.Decoration.none;
}
const line = doc.line(lineNumber + 1);
const decorations: CodeMirror.Range<CodeMirror.Decoration>[] = [executionLineDeco.range(line.from)];
const position = Math.min(line.to, line.from + columnNumber);
let syntaxNode = CodeMirror.syntaxTree(editorState).resolveInner(position, 1);
if (syntaxNode.to === syntaxNode.from - 1 && /[(.]/.test(doc.sliceString(syntaxNode.from, syntaxNode.to))) {
syntaxNode = syntaxNode.resolve(syntaxNode.to, 1);
}
const tokenEnd = Math.min(line.to, syntaxNode.to);
if (tokenEnd > position) {
decorations.push(executionTokenDeco.range(position, tokenEnd));
}
return CodeMirror.Decoration.set(decorations);
}
// 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;
if (decorations || this.editor.state.field(valueDecorations.field).size) {
this.editor.dispatch({effects: valueDecorations.update.of(decorations || CodeMirror.Decoration.none)});
}
}
private async computeValueDecorations(): Promise<CodeMirror.DecorationSet|null> {
if (!this.editor) {
return null;
}
if (!Common.Settings.Settings.instance().moduleSetting('inlineVariableValues').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 localScope = callFrame.localScope();
if (!localScope || !callFrame.functionLocation()) {
return null;
}
const {properties} =
await SourceMapScopes.NamesResolver.resolveScopeInObject(localScope).getAllProperties(false, false);
if (!properties || !properties.length || properties.length > 500) {
return null;
}
const functionUILocationPromise =
Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().rawLocationToUILocation(
(callFrame.functionLocation() as SDK.DebuggerModel.Location));
const executionUILocationPromise =
Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().rawLocationToUILocation(
callFrame.location());
const [functionUILocation, executionUILocation] =
await Promise.all([functionUILocationPromise, executionUILocationPromise]);
if (!functionUILocation || !executionUILocation ||
functionUILocation.uiSourceCode.url() !== this.uiSourceCode.url() ||
executionUILocation.uiSourceCode.url() !== this.uiSourceCode.url()) {
return null;
}
const functionLocation =
this.transformer.uiLocationToEditorLocation(functionUILocation.lineNumber, functionUILocation.columnNumber);
const executionLocation =
this.transformer.uiLocationToEditorLocation(executionUILocation.lineNumber, executionUILocation.columnNumber);
if (functionLocation.lineNumber >= executionLocation.lineNumber ||
executionLocation.lineNumber - functionLocation.lineNumber > 500 || functionLocation.lineNumber < 0 ||
executionLocation.lineNumber >= this.editor.state.doc.lines) {
return null;
}
const variableMap = new Map<string, SDK.RemoteObject.RemoteObject>(
properties.map(p => [p.name, p.value] as [string, SDK.RemoteObject.RemoteObject]));
const variablesByLine =
this.getVariablesByLine(this.editor.state, variableMap, functionLocation, executionLocation);
if (!variablesByLine) {
return null;
}
const decorations: 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.has(n)) : Array.from(names);
if (!newNames.length) {
continue;
}
if (newNames.length > 10) {
newNames = newNames.slice(0, 10);
}
const pairs = newNames.map(name => [name, variableMap.get(name)] as [string, SDK.RemoteObject.RemoteObject]);
decorations.push(CodeMirror.Decoration.widget({widget: new ValueDecoration(pairs), side: 1})
.range(this.editor.state.doc.line(line + 1).to));
}
return CodeMirror.Decoration.set(decorations, true);
}
getVariablesByLine(
editorState: CodeMirror.EditorState, variableMap: Map<string, unknown>,
fromLoc: {lineNumber: number, columnNumber: number},
toLoc: {lineNumber: number, columnNumber: number}): Map<number, Set<string>>|null {
const fromLine = editorState.doc.line(fromLoc.lineNumber + 1);
const fromPos = Math.min(fromLine.to, fromLine.from + fromLoc.columnNumber);
const toPos = editorState.doc.line(toLoc.lineNumber + 1).from;
const tree = CodeMirror.ensureSyntaxTree(editorState, toPos, 100);
if (!tree) {
return null;
}
const namesPerLine = new Map<number, Set<string>>();
let curLine = fromLine;
tree.iterate({
from: fromPos,
to: toPos,
enter: node => {
const varName = this.isVariableIdentifier(node.name) && editorState.sliceDoc(node.from, node.to);
if (varName && variableMap.has(varName)) {
if (node.from > curLine.to) {
curLine = editorState.doc.lineAt(node.from);
}
let names = namesPerLine.get(curLine.number - 1);
if (!names) {
names = new Set();
namesPerLine.set(curLine.number - 1, names);
}
names.add(varName);
}
},
});
return namesPerLine;
}
// 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 && 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(): {
position: number,
breakpoint: Bindings.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 Bindings.BreakpointManager.Breakpoint[] {
return this.breakpoints.filter(b => b.position >= line.from && b.position <= line.to).map(b => b.breakpoint);
}
// Compute the decorations for existing breakpoints (both on the
// gutter and inline in the code)
private async computeBreakpointDecoration(state: CodeMirror.EditorState, breakpoints: BreakpointDescription[]):
Promise<BreakpointDecoration> {
const decorations: CodeMirror.Range<CodeMirror.Decoration>[] = [];
const gutterMarkers: CodeMirror.Range<CodeMirror.GutterMarker>[] = [];
const breakpointsByLine = new Map<number, Bindings.BreakpointManager.Breakpoint[]>();
const inlineMarkersByLine =
new Map<number, {breakpoint: Bindings.BreakpointManager.Breakpoint | null, column: number}[]>();
const possibleBreakpointRequests: Promise<void>[] = [];
const inlineMarkerPositions = new Set<number>();
const addInlineMarker =
(linePos: number, columnNumber: number, breakpoint: Bindings.BreakpointManager.Breakpoint|null): void => {
let inlineMarkers = inlineMarkersByLine.get(linePos);
if (!inlineMarkers) {
inlineMarkers = [];
inlineMarkersByLine.set(linePos, inlineMarkers);
}
inlineMarkers.push({breakpoint, column: columnNumber});
};
for (const {position, breakpoint} of breakpoints) {
const line = state.doc.lineAt(position);
let forThisLine = breakpointsByLine.get(line.from);
if (!forThisLine) {
forThisLine = [];
breakpointsByLine.set(line.from, forThisLine);
}
if (breakpoint.enabled() && forThisLine.every(b => !b.enabled())) {
// Start a request for possible breakp