UNPKG

chrome-devtools-frontend

Version:
1,194 lines (1,090 loc) 98.4 kB
/* * 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 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 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 type * 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 SourceComponents from './components/components.js'; import {AddDebugInfoURLDialog} from './AddSourceMapURLDialog.js'; import {BreakpointEditDialog, type BreakpointEditDialogResult} from './BreakpointEditDialog.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 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 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; // Limits on inline variable view computation. const MAX_CODE_SIZE_FOR_VALUE_DECORATIONS = 10000; const MAX_PROPERTIES_IN_SCOPE_FOR_VALUE_DECORATIONS = 500; type BreakpointDescription = { position: number, breakpoint: Breakpoints.BreakpointManager.Breakpoint, }; type 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: boolean = 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[] = []; // 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 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 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.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): 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); Host.userMetrics.breakpointEditDialogRevealedFrom( Host.UserMetrics.BreakpointEditDialogRevealedFrom.KeyboardShortcut); this.#openEditDialogForLine(line); return true; }, }); } #openEditDialogForLine(line: CodeMirror.Line): 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; this.editBreakpointCondition({line, breakpoint, location: null, isLogpoint: breakpoint?.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)); this.popoverHelper.setDisableOnClick(true); this.popoverHelper.setTimeout(250, 250); this.popoverHelper.setHasPadding(true); } static override 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().isUserOrSourceMapIgnoreListedUISourceCode( 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; } 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)); if (supportsConditionalBreakpoints) { contextMenu.debugSection().appendItem(i18nString(UIStrings.addConditionalBreakpoint), () => { Host.userMetrics.breakpointEditDialogRevealedFrom( Host.UserMetrics.BreakpointEditDialogRevealedFrom.LineGutterContextMenu); this.editBreakpointCondition({line, breakpoint: null, location: null, isLogpoint: false}); }); contextMenu.debugSection().appendItem(i18nString(UIStrings.addLogpoint), () => { Host.userMetrics.breakpointEditDialogRevealedFrom( Host.UserMetrics.BreakpointEditDialogRevealedFrom.LineGutterContextMenu); this.editBreakpointCondition({line, breakpoint: null, location: null, isLogpoint: true}); }); contextMenu.debugSection().appendItem( i18nString(UIStrings.neverPauseHere), this.createNewBreakpoint.bind( this, line, NEVER_PAUSE_HERE_CONDITION, /* enabled */ true, /* isLogpoint */ false)); } } } 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), () => { Host.userMetrics.breakpointEditDialogRevealedFrom( Host.UserMetrics.BreakpointEditDialogRevealedFrom.BreakpointMarkerContextMenu); this.editBreakpointCondition({line, breakpoint: breakpoints[0], location: null}); }); } 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))); } } } 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(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().isUserIgnoreListedURL(this.uiSourceCode.url())) { 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 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): Promise<boolean> => { const resolvedText = await SourceMapScopes.NamesResolver.resolveExpression( selectedCallFrame, evaluationText, this.uiSourceCode, highlightLine.number - 1, highlightRange.from - highlightLine.from, highlightRange.to - highlightLine.from); const result = 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, }); 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(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); recordBreakpointWithConditionAdded(result); 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; // This counts new conditional breakpoints or logpoints that are added. function recordBreakpointWithConditionAdded(result: BreakpointEditDialogResult): void { const {condition: newCondition, isLogpoint} = result; const isConditionalBreakpoint = newCondition.length !== 0 && !isLogpoint; const wasLogpoint = breakpoint?.isLogpoint(); const wasConditionalBreakpoint = oldCondition && oldCondition.length !== 0 && !wasLogpoint; if (isLogpoint && !wasLogpoint) { Host.userMetrics.breakpointWithConditionAdded(Host.UserMetrics.BreakpointWithConditionAdded.Logpoint); } else if (isConditionalBreakpoint && !wasConditionalBreakpoint) { Host.userMetrics.breakpointWithConditionAdded( Host.UserMetrics.BreakpointWithConditionAdded.ConditionalBreakpoint); } } 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; } } // 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; // 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('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 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; } const variableNames = getVariableNamesByLine(this.editor.state, functionOffset, executionOffset, executionOffset); if (variableNames.length === 0) { return null; } const scopeMappings = await computeScopeMappings(callFrame, rawLocationToEditorOffset); if (scopeMappings.length === 0) { return null; } const variablesByLine = getVariableValuesByLine(scopeMappings, variableNames); if (!variablesByLine || !this.editor) { 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.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 && 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: 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.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, Breakpoints.BreakpointManager.Breakpoint[]>(); const inlineMarkersByLine = new Map<number, {breakpoint: Breakpoints.BreakpointManager.Breakpoint | null, column: number}[]>(); const possi