UNPKG

chrome-devtools-frontend

Version:
1,210 lines (1,109 loc) 104 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. */ /* 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