| // Copyright (c) 2015 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /* eslint-disable rulesdir/no_underscored_properties */ |
| |
| import * as Common from '../../core/common/common.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 Bindings from '../../models/bindings/bindings.js'; |
| import * as TextUtils from '../../models/text_utils/text_utils.js'; |
| import * as Workspace from '../../models/workspace/workspace.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| |
| const UIStrings = { |
| /** |
| *@description Text to indicate there are no breakpoints |
| */ |
| noBreakpoints: 'No breakpoints', |
| /** |
| *@description Text exposed to screen readers on checked items. |
| */ |
| checked: 'checked', |
| /** |
| *@description Accessible text exposed to screen readers when the screen reader encounters an unchecked checkbox. |
| */ |
| unchecked: 'unchecked', |
| /** |
| *@description Accessible text for a breakpoint collection with a combination of checked states. |
| */ |
| mixed: 'mixed', |
| /** |
| *@description Accessibility label for hit breakpoints in the Sources panel. |
| *@example {checked} PH1 |
| */ |
| sBreakpointHit: '{PH1} breakpoint hit', |
| /** |
| *@description Text in Debugger Plugin of the Sources panel |
| */ |
| removeAllBreakpointsInLine: 'Remove all breakpoints in line', |
| /** |
| *@description Text to remove a breakpoint |
| */ |
| removeBreakpoint: 'Remove breakpoint', |
| /** |
| *@description Context menu item that reveals the source code location of a breakpoint in the Sources panel. |
| */ |
| revealLocation: 'Reveal location', |
| /** |
| *@description Text in Java Script Breakpoints Sidebar Pane of the Sources panel |
| */ |
| deactivateBreakpoints: 'Deactivate breakpoints', |
| /** |
| *@description Text in Java Script Breakpoints Sidebar Pane of the Sources panel |
| */ |
| activateBreakpoints: 'Activate breakpoints', |
| /** |
| *@description Text in Java Script Breakpoints Sidebar Pane of the Sources panel |
| */ |
| enableAllBreakpoints: 'Enable all breakpoints', |
| /** |
| *@description Text in Java Script Breakpoints Sidebar Pane of the Sources panel |
| */ |
| enableBreakpointsInFile: 'Enable breakpoints in file', |
| /** |
| *@description Text in Java Script Breakpoints Sidebar Pane of the Sources panel |
| */ |
| disableAllBreakpoints: 'Disable all breakpoints', |
| /** |
| *@description Text in Java Script Breakpoints Sidebar Pane of the Sources panel |
| */ |
| disableBreakpointsInFile: 'Disable breakpoints in file', |
| /** |
| *@description Text to remove all breakpoints |
| */ |
| removeAllBreakpoints: 'Remove all breakpoints', |
| /** |
| *@description Text in Java Script Breakpoints Sidebar Pane of the Sources panel |
| */ |
| removeOtherBreakpoints: 'Remove other breakpoints', |
| }; |
| const str_ = i18n.i18n.registerUIStrings('panels/sources/JavaScriptBreakpointsSidebarPane.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| let javaScriptBreakpointsSidebarPaneInstance: JavaScriptBreakpointsSidebarPane; |
| |
| export class JavaScriptBreakpointsSidebarPane extends UI.ThrottledWidget.ThrottledWidget implements |
| UI.ContextFlavorListener.ContextFlavorListener, UI.ListControl.ListDelegate<BreakpointItem> { |
| _breakpointManager: Bindings.BreakpointManager.BreakpointManager; |
| _breakpoints: UI.ListModel.ListModel<BreakpointItem>; |
| _list: UI.ListControl.ListControl<BreakpointItem>; |
| _emptyElement: HTMLElement; |
| |
| private constructor() { |
| super(true); |
| this.registerRequiredCSS('panels/sources/javaScriptBreakpointsSidebarPane.css', {enableLegacyPatching: false}); |
| |
| this._breakpointManager = Bindings.BreakpointManager.BreakpointManager.instance(); |
| this._breakpointManager.addEventListener(Bindings.BreakpointManager.Events.BreakpointAdded, this.update, this); |
| this._breakpointManager.addEventListener(Bindings.BreakpointManager.Events.BreakpointRemoved, this.update, this); |
| Common.Settings.Settings.instance().moduleSetting('breakpointsActive').addChangeListener(this.update, this); |
| |
| this._breakpoints = new UI.ListModel.ListModel(); |
| this._list = new UI.ListControl.ListControl(this._breakpoints, this, UI.ListControl.ListMode.NonViewport); |
| UI.ARIAUtils.markAsList(this._list.element); |
| this.contentElement.appendChild(this._list.element); |
| |
| this._emptyElement = this.contentElement.createChild('div', 'gray-info-message'); |
| this._emptyElement.textContent = i18nString(UIStrings.noBreakpoints); |
| this._emptyElement.tabIndex = -1; |
| |
| this.update(); |
| } |
| |
| static instance(): JavaScriptBreakpointsSidebarPane { |
| if (!javaScriptBreakpointsSidebarPaneInstance) { |
| javaScriptBreakpointsSidebarPaneInstance = new JavaScriptBreakpointsSidebarPane(); |
| } |
| return javaScriptBreakpointsSidebarPaneInstance; |
| } |
| |
| _getBreakpointLocations(): BreakpointLocation[] { |
| const locations = this._breakpointManager.allBreakpointLocations().filter( |
| breakpointLocation => |
| breakpointLocation.uiLocation.uiSourceCode.project().type() !== Workspace.Workspace.projectTypes.Debugger); |
| |
| locations.sort((item1, item2) => item1.uiLocation.compareTo(item2.uiLocation)); |
| |
| const result = []; |
| let lastBreakpoint: Bindings.BreakpointManager.Breakpoint|null = null; |
| let lastLocation: Workspace.UISourceCode.UILocation|null = null; |
| for (const location of locations) { |
| if (location.breakpoint !== lastBreakpoint || (lastLocation && location.uiLocation.compareTo(lastLocation))) { |
| result.push(location); |
| lastBreakpoint = location.breakpoint; |
| lastLocation = location.uiLocation; |
| } |
| } |
| return result; |
| } |
| |
| _hideList(): void { |
| this._list.element.classList.add('hidden'); |
| this._emptyElement.classList.remove('hidden'); |
| } |
| |
| _ensureListShown(): void { |
| this._list.element.classList.remove('hidden'); |
| this._emptyElement.classList.add('hidden'); |
| } |
| |
| _groupBreakpointLocationsById(breakpointLocations: BreakpointLocation[]): BreakpointLocation[][] { |
| const map = new Platform.MapUtilities.Multimap<string, BreakpointLocation>(); |
| for (const breakpointLocation of breakpointLocations) { |
| const uiLocation = breakpointLocation.uiLocation; |
| map.set(uiLocation.id(), breakpointLocation); |
| } |
| const arr: BreakpointLocation[][] = []; |
| for (const id of map.keysArray()) { |
| const locations = Array.from(map.get(id)); |
| if (locations.length) { |
| arr.push(locations); |
| } |
| } |
| return arr; |
| } |
| |
| _getLocationIdsByLineId(breakpointLocations: BreakpointLocation[]): Platform.MapUtilities.Multimap<string, string> { |
| const result = new Platform.MapUtilities.Multimap<string, string>(); |
| |
| for (const breakpointLocation of breakpointLocations) { |
| const uiLocation = breakpointLocation.uiLocation; |
| result.set(uiLocation.lineId(), uiLocation.id()); |
| } |
| |
| return result; |
| } |
| |
| async _getSelectedUILocation(): Promise<Workspace.UISourceCode.UILocation|null> { |
| const details = UI.Context.Context.instance().flavor(SDK.DebuggerModel.DebuggerPausedDetails); |
| if (details && details.callFrames.length) { |
| return await Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance().rawLocationToUILocation( |
| details.callFrames[0].location()); |
| } |
| return null; |
| } |
| |
| _getContent(locations: BreakpointLocation[][]): Promise<TextUtils.Text.Text[]> { |
| // Use a cache to share the Text objects between all breakpoints. This way |
| // we share the cached line ending information that Text calculates. This |
| // was very slow to calculate with a lot of breakpoints in the same very |
| // large source file. |
| const contentToTextMap = new Map<string, TextUtils.Text.Text>(); |
| |
| return Promise.all(locations.map(async ([{uiLocation: {uiSourceCode}}]) => { |
| if (uiSourceCode.mimeType() === 'application/wasm') { |
| // We could mirror the logic from `SourceFrame._ensureContentLoaded()` here |
| // (and if so, ideally share that code somewhere), but that's quite heavy |
| // logic just to display a single Wasm instruction. Also not really clear |
| // how much value this would add. So let's keep it simple for now and don't |
| // display anything additional for Wasm breakpoints, and if there's demand |
| // to display some text preview, we could look into selectively disassemb- |
| // ling the part of the text that we need here. |
| // Relevant crbug: https://crbug.com/1090256 |
| return new TextUtils.Text.Text(''); |
| } |
| const {content} = await uiSourceCode.requestContent(); |
| const contentText = content || ''; |
| if (contentToTextMap.has(contentText)) { |
| return contentToTextMap.get(contentText) as TextUtils.Text.Text; |
| } |
| const text = new TextUtils.Text.Text(contentText); |
| contentToTextMap.set(contentText, text); |
| return text; |
| })); |
| } |
| |
| async doUpdate(): Promise<void> { |
| const hadFocus = this.hasFocus(); |
| const breakpointLocations = this._getBreakpointLocations(); |
| if (!breakpointLocations.length) { |
| this._hideList(); |
| this._setBreakpointItems([]); |
| return this._didUpdateForTest(); |
| } |
| this._ensureListShown(); |
| |
| const locationsGroupedById = this._groupBreakpointLocationsById(breakpointLocations); |
| const locationIdsByLineId = this._getLocationIdsByLineId(breakpointLocations); |
| const content = await this._getContent(locationsGroupedById); |
| const selectedUILocation = await this._getSelectedUILocation(); |
| const breakpoints = []; |
| for (let idx = 0; idx < locationsGroupedById.length; idx++) { |
| const locations = locationsGroupedById[idx]; |
| const breakpointLocation = locations[0]; |
| const uiLocation = breakpointLocation.uiLocation; |
| const isSelected = selectedUILocation !== null && |
| locations.some(location => location.uiLocation.id() === selectedUILocation.id()); |
| // Wasm disassembly bytecode offsets are stored as column numbers, |
| // so this showColumn setting doesn't make sense for WebAssembly. |
| const showColumn = uiLocation.uiSourceCode.mimeType() !== 'application/wasm' && |
| locationIdsByLineId.get(uiLocation.lineId()).size > 1; |
| const text = (content[idx] as TextUtils.Text.Text); |
| breakpoints.push(new BreakpointItem(locations, text, isSelected, showColumn)); |
| } |
| |
| if (breakpoints.some(breakpoint => breakpoint.isSelected)) { |
| UI.ViewManager.ViewManager.instance().showView('sources.jsBreakpoints'); |
| } |
| |
| this._list.element.classList.toggle( |
| 'breakpoints-list-deactivated', !Common.Settings.Settings.instance().moduleSetting('breakpointsActive').get()); |
| |
| this._setBreakpointItems(breakpoints); |
| |
| if (hadFocus) { |
| this.focus(); |
| } |
| |
| return this._didUpdateForTest(); |
| } |
| |
| /** |
| * If the number of breakpoint items is the same, |
| * we expect only minor changes and it implies that only |
| * few items should be updated |
| */ |
| _setBreakpointItems(breakpointItems: BreakpointItem[]): void { |
| if (this._breakpoints.length === breakpointItems.length) { |
| for (let i = 0; i < this._breakpoints.length; i++) { |
| if (!this._breakpoints.at(i).isSimilar(breakpointItems[i])) { |
| this._breakpoints.replace(i, breakpointItems[i], /** keepSelectedIndex= */ true); |
| } |
| } |
| } else { |
| this._breakpoints.replaceAll(breakpointItems); |
| } |
| if (!this._list.selectedItem() && this._breakpoints.at(0)) { |
| this._list.selectItem(this._breakpoints.at(0)); |
| } |
| } |
| |
| createElementForItem(item: BreakpointItem): Element { |
| const element = document.createElement('div'); |
| element.classList.add('breakpoint-entry'); |
| UI.ARIAUtils.markAsListitem(element); |
| element.tabIndex = this._list.selectedItem() === item ? 0 : -1; |
| element.addEventListener('contextmenu', this._breakpointContextMenu.bind(this), true); |
| element.addEventListener('click', this._revealLocation.bind(this, element), false); |
| const checkboxLabel = UI.UIUtils.CheckboxLabel.create(''); |
| |
| const uiLocation = item.locations[0].uiLocation; |
| const hasEnabled = item.locations.some(location => location.breakpoint.enabled()); |
| const hasDisabled = item.locations.some(location => !location.breakpoint.enabled()); |
| checkboxLabel.textElement.textContent = uiLocation.linkText() + |
| (item.showColumn && typeof uiLocation.columnNumber === 'number' ? ':' + (uiLocation.columnNumber + 1) : ''); |
| checkboxLabel.checkboxElement.checked = hasEnabled; |
| checkboxLabel.checkboxElement.indeterminate = hasEnabled && hasDisabled; |
| checkboxLabel.checkboxElement.tabIndex = -1; |
| checkboxLabel.addEventListener('click', this._breakpointCheckboxClicked.bind(this), false); |
| element.appendChild(checkboxLabel); |
| let checkedDescription: Common.UIString.LocalizedString = |
| hasEnabled ? i18nString(UIStrings.checked) : i18nString(UIStrings.unchecked); |
| if (hasEnabled && hasDisabled) { |
| checkedDescription = i18nString(UIStrings.mixed); |
| } |
| if (item.isSelected) { |
| UI.ARIAUtils.setDescription(element, i18nString(UIStrings.sBreakpointHit, {PH1: checkedDescription})); |
| element.classList.add('breakpoint-hit'); |
| this.setDefaultFocusedElement(element); |
| } else { |
| UI.ARIAUtils.setDescription(element, checkedDescription); |
| } |
| |
| element.addEventListener('keydown', event => { |
| if (event.key === ' ') { |
| checkboxLabel.checkboxElement.click(); |
| event.consume(true); |
| } |
| }); |
| |
| const snippetElement = element.createChild('div', 'source-text monospace'); |
| const lineNumber = uiLocation.lineNumber; |
| |
| if (item.text && lineNumber < item.text.lineCount()) { |
| const lineText = item.text.lineAt(lineNumber); |
| const maxSnippetLength = 200; |
| snippetElement.textContent = Platform.StringUtilities.trimEndWithMaxLength( |
| lineText.substring(item.showColumn ? (uiLocation.columnNumber || 0) : 0), maxSnippetLength); |
| } |
| |
| elementToBreakpointMap.set(element, item.locations); |
| elementToUILocationMap.set(element, uiLocation); |
| return element; |
| } |
| |
| heightForItem(_item: BreakpointItem): number { |
| return 0; |
| } |
| |
| isItemSelectable(_item: BreakpointItem): boolean { |
| return true; |
| } |
| |
| selectedItemChanged( |
| _from: BreakpointItem|null, _to: BreakpointItem|null, fromElement: HTMLElement|null, |
| toElement: HTMLElement|null): void { |
| if (fromElement) { |
| fromElement.tabIndex = -1; |
| } |
| if (toElement) { |
| toElement.tabIndex = 0; |
| this.setDefaultFocusedElement(toElement); |
| if (this.hasFocus()) { |
| toElement.focus(); |
| } |
| } |
| } |
| |
| updateSelectedItemARIA(_fromElement: Element|null, _toElement: Element|null): boolean { |
| return true; |
| } |
| |
| _breakpointLocations(event: Event): Bindings.BreakpointManager.BreakpointLocation[] { |
| if (event.target instanceof Element) { |
| return this._breakpointLocationsForElement(event.target); |
| } |
| return []; |
| } |
| |
| _breakpointLocationsForElement(element: Element): Bindings.BreakpointManager.BreakpointLocation[] { |
| const node = element.enclosingNodeOrSelfWithClass('breakpoint-entry'); |
| if (!node) { |
| return []; |
| } |
| return elementToBreakpointMap.get(node) || []; |
| } |
| |
| _breakpointCheckboxClicked(event: Event): void { |
| const hadFocus = this.hasFocus(); |
| const breakpoints = this._breakpointLocations(event).map(breakpointLocation => breakpointLocation.breakpoint); |
| const newState = (event.target as UI.UIUtils.CheckboxLabel).checkboxElement.checked; |
| for (const breakpoint of breakpoints) { |
| breakpoint.setEnabled(newState); |
| const item = |
| this._breakpoints.find(breakpointItem => breakpointItem.locations.some(loc => loc.breakpoint === breakpoint)); |
| if (item) { |
| this._list.refreshItem(item); |
| } |
| } |
| if (hadFocus) { |
| this.focus(); |
| } |
| event.consume(); |
| } |
| |
| _revealLocation(element: Element): void { |
| const uiLocations = |
| this._breakpointLocationsForElement(element).map(breakpointLocation => breakpointLocation.uiLocation); |
| let uiLocation: Workspace.UISourceCode.UILocation|null = null; |
| for (const uiLocationCandidate of uiLocations) { |
| if (!uiLocation || uiLocationCandidate.compareTo(uiLocation) < 0) { |
| uiLocation = uiLocationCandidate; |
| } |
| } |
| if (uiLocation) { |
| Common.Revealer.reveal(uiLocation); |
| } |
| } |
| |
| _breakpointContextMenu(event: Event): void { |
| const breakpoints = this._breakpointLocations(event).map(breakpointLocation => breakpointLocation.breakpoint); |
| |
| const contextMenu = new UI.ContextMenu.ContextMenu(event); |
| const removeEntryTitle = breakpoints.length > 1 ? i18nString(UIStrings.removeAllBreakpointsInLine) : |
| i18nString(UIStrings.removeBreakpoint); |
| contextMenu.defaultSection().appendItem( |
| removeEntryTitle, () => breakpoints.map(breakpoint => breakpoint.remove(false /* keepInStorage */))); |
| if (event.target instanceof Element) { |
| contextMenu.defaultSection().appendItem( |
| i18nString(UIStrings.revealLocation), this._revealLocation.bind(this, event.target)); |
| } |
| |
| const breakpointActive = Common.Settings.Settings.instance().moduleSetting('breakpointsActive').get(); |
| const breakpointActiveTitle = |
| breakpointActive ? i18nString(UIStrings.deactivateBreakpoints) : i18nString(UIStrings.activateBreakpoints); |
| contextMenu.defaultSection().appendItem( |
| breakpointActiveTitle, |
| () => Common.Settings.Settings.instance().moduleSetting('breakpointsActive').set(!breakpointActive)); |
| |
| if (breakpoints.some(breakpoint => !breakpoint.enabled())) { |
| const enableTitle = i18nString(UIStrings.enableAllBreakpoints); |
| contextMenu.defaultSection().appendItem(enableTitle, this._toggleAllBreakpoints.bind(this, true)); |
| if (event.target instanceof Element) { |
| const enableInFileTitle = i18nString(UIStrings.enableBreakpointsInFile); |
| contextMenu.defaultSection().appendItem( |
| enableInFileTitle, this._toggleAllBreakpointsInFile.bind(this, event.target, true)); |
| } |
| } |
| if (breakpoints.some(breakpoint => breakpoint.enabled())) { |
| const disableTitle = i18nString(UIStrings.disableAllBreakpoints); |
| contextMenu.defaultSection().appendItem(disableTitle, this._toggleAllBreakpoints.bind(this, false)); |
| if (event.target instanceof Element) { |
| const disableInFileTitle = i18nString(UIStrings.disableBreakpointsInFile); |
| contextMenu.defaultSection().appendItem( |
| disableInFileTitle, this._toggleAllBreakpointsInFile.bind(this, event.target, false)); |
| } |
| } |
| |
| const removeAllTitle = i18nString(UIStrings.removeAllBreakpoints); |
| contextMenu.defaultSection().appendItem(removeAllTitle, this._removeAllBreakpoints.bind(this)); |
| const removeOtherTitle = i18nString(UIStrings.removeOtherBreakpoints); |
| contextMenu.defaultSection().appendItem( |
| removeOtherTitle, this._removeOtherBreakpoints.bind(this, new Set(breakpoints))); |
| contextMenu.show(); |
| } |
| |
| _toggleAllBreakpointsInFile(element: Element, toggleState: boolean): void { |
| const breakpointLocations = this._getBreakpointLocations(); |
| const selectedBreakpointLocations = this._breakpointLocationsForElement(element); |
| breakpointLocations.forEach(breakpointLocation => { |
| const matchesLocation = selectedBreakpointLocations.some( |
| selectedBreakpointLocation => |
| selectedBreakpointLocation.breakpoint.url() === breakpointLocation.breakpoint.url()); |
| if (matchesLocation) { |
| breakpointLocation.breakpoint.setEnabled(toggleState); |
| } |
| }); |
| } |
| |
| _toggleAllBreakpoints(toggleState: boolean): void { |
| for (const breakpointLocation of this._breakpointManager.allBreakpointLocations()) { |
| breakpointLocation.breakpoint.setEnabled(toggleState); |
| } |
| } |
| |
| _removeAllBreakpoints(): void { |
| for (const breakpointLocation of this._breakpointManager.allBreakpointLocations()) { |
| breakpointLocation.breakpoint.remove(false /* keepInStorage */); |
| } |
| } |
| |
| _removeOtherBreakpoints(selectedBreakpoints: Set<Bindings.BreakpointManager.Breakpoint>): void { |
| for (const breakpointLocation of this._breakpointManager.allBreakpointLocations()) { |
| if (!selectedBreakpoints.has(breakpointLocation.breakpoint)) { |
| breakpointLocation.breakpoint.remove(false /* keepInStorage */); |
| } |
| } |
| } |
| |
| flavorChanged(_object: Object|null): void { |
| this.update(); |
| } |
| |
| _didUpdateForTest(): void { |
| } |
| } |
| |
| class BreakpointItem { |
| locations: BreakpointLocation[]; |
| text: TextUtils.Text.Text|null; |
| isSelected: boolean; |
| showColumn: boolean; |
| |
| constructor( |
| locations: BreakpointLocation[], text: TextUtils.Text.Text|null, isSelected: boolean, showColumn: boolean) { |
| this.locations = locations; |
| this.text = text; |
| this.isSelected = isSelected; |
| this.showColumn = showColumn; |
| } |
| |
| /** |
| * Checks if this item has not changed compared with the other |
| * Used to cache model items between re-renders |
| */ |
| isSimilar(other: BreakpointItem): boolean|null { |
| return this.locations.length === other.locations.length && |
| this.locations.every((l, idx) => l.uiLocation === other.locations[idx].uiLocation) && |
| this.locations.every((l, idx) => l.breakpoint === other.locations[idx].breakpoint) && |
| ((this.text === other.text) || (this.text && other.text && this.text.value() === other.text.value())) && |
| this.isSelected === other.isSelected && this.showColumn === other.showColumn; |
| } |
| } |
| |
| const elementToUILocationMap = new WeakMap<Element, Workspace.UISourceCode.UILocation>(); |
| |
| export function retrieveLocationForElement(element: Element): Workspace.UISourceCode.UILocation|undefined { |
| return elementToUILocationMap.get(element); |
| } |
| |
| const elementToBreakpointMap = new WeakMap<Element, BreakpointLocation[]>(); |
| |
| export interface BreakpointLocation { |
| breakpoint: Bindings.BreakpointManager.Breakpoint; |
| uiLocation: Workspace.UISourceCode.UILocation; |
| } |