UNPKG

chrome-devtools-frontend

Version:
466 lines (438 loc) • 17.8 kB
// Copyright (c) 2017 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. 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 TextUtils from '../../models/text_utils/text_utils.js'; import * as Workspace from '../../models/workspace/workspace.js'; import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js'; import * as UI from '../../ui/legacy/legacy.js'; import coverageListViewStyles from './coverageListView.css.js'; import {CoverageType, type URLCoverageInfo} from './CoverageModel.js'; const UIStrings = { /** *@description Text that appears on a button for the css resource type filter. */ css: 'CSS', /** *@description Text in Coverage List View of the Coverage tab */ jsPerFunction: 'JS (per function)', /** *@description Text in Coverage List View of the Coverage tab */ jsPerBlock: 'JS (per block)', /** *@description Text for web URLs */ url: 'URL', /** *@description Text that refers to some types */ type: 'Type', /** *@description Text in Coverage List View of the Coverage tab */ totalBytes: 'Total Bytes', /** *@description Text in Coverage List View of the Coverage tab */ unusedBytes: 'Unused Bytes', /** *@description Text in the Coverage List View of the Coverage Tab */ usageVisualization: 'Usage Visualization', /** *@description Data grid name for Coverage data grids */ codeCoverage: 'Code Coverage', /** *@description Cell title in Coverage List View of the Coverage tab. The coverage tool tells *developers which functions (logical groups of lines of code) were actually run/executed. If a *function does get run, then it is marked in the UI to indicate that it was covered. */ jsCoverageWithPerFunction: 'JS coverage with per function granularity: Once a function was executed, the whole function is marked as covered.', /** *@description Cell title in Coverage List View of the Coverage tab. The coverage tool tells *developers which blocks (logical groups of lines of code, smaller than a function) were actually *run/executed. If a block does get run, then it is marked in the UI to indicate that it was *covered. */ jsCoverageWithPerBlock: 'JS coverage with per block granularity: Once a block of JavaScript was executed, that block is marked as covered.', /** *@description Accessible text for the value in bytes in memory allocation or coverage view. */ sBytes: '{n, plural, =1 {# byte} other {# bytes}}', /** *@description Accessible text for the unused bytes column in the coverage tool that describes the total unused bytes and percentage of the file unused. *@example {88%} percentage */ sBytesS: '{n, plural, =1 {# byte, {percentage}} other {# bytes, {percentage}}}', /** *@description Tooltip text for the bar in the coverage list view of the coverage tool that illustrates the relation between used and unused bytes. *@example {1000} PH1 *@example {12.34} PH2 */ sBytesSBelongToFunctionsThatHave: '{PH1} bytes ({PH2}) belong to functions that have not (yet) been executed.', /** *@description Tooltip text for the bar in the coverage list view of the coverage tool that illustrates the relation between used and unused bytes. *@example {1000} PH1 *@example {12.34} PH2 */ sBytesSBelongToBlocksOf: '{PH1} bytes ({PH2}) belong to blocks of JavaScript that have not (yet) been executed.', /** *@description Message in Coverage View of the Coverage tab *@example {1000} PH1 *@example {12.34} PH2 */ sBytesSBelongToFunctionsThatHaveExecuted: '{PH1} bytes ({PH2}) belong to functions that have executed at least once.', /** *@description Message in Coverage View of the Coverage tab *@example {1000} PH1 *@example {12.34} PH2 */ sBytesSBelongToBlocksOfJavascript: '{PH1} bytes ({PH2}) belong to blocks of JavaScript that have executed at least once.', /** *@description Accessible text for the visualization column of coverage tool. Contains percentage of unused bytes to used bytes. *@example {12.3} PH1 *@example {12.3} PH2 */ sOfFileUnusedSOfFileUsed: '{PH1} % of file unused, {PH2} % of file used', }; const str_ = i18n.i18n.registerUIStrings('panels/coverage/CoverageListView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export function coverageTypeToString(type: CoverageType): string { const types = []; if (type & CoverageType.CSS) { types.push(i18nString(UIStrings.css)); } if (type & CoverageType.JavaScriptPerFunction) { types.push(i18nString(UIStrings.jsPerFunction)); } else if (type & CoverageType.JavaScript) { types.push(i18nString(UIStrings.jsPerBlock)); } return types.join('+'); } export class CoverageListView extends UI.Widget.VBox { private readonly nodeForCoverageInfo: Map<URLCoverageInfo, GridNode>; private readonly isVisibleFilter: (arg0: URLCoverageInfo) => boolean; private highlightRegExp: RegExp|null; private dataGrid: DataGrid.SortableDataGrid.SortableDataGrid<GridNode>; constructor(isVisibleFilter: (arg0: URLCoverageInfo) => boolean) { super(true); this.nodeForCoverageInfo = new Map(); this.isVisibleFilter = isVisibleFilter; this.highlightRegExp = null; const columns: DataGrid.DataGrid.ColumnDescriptor[] = [ {id: 'url', title: i18nString(UIStrings.url), width: '250px', weight: 3, fixedWidth: false, sortable: true}, {id: 'type', title: i18nString(UIStrings.type), width: '45px', weight: 1, fixedWidth: true, sortable: true}, { id: 'size', title: i18nString(UIStrings.totalBytes), width: '60px', fixedWidth: true, sortable: true, align: DataGrid.DataGrid.Align.Right, weight: 1, }, { id: 'unusedSize', title: i18nString(UIStrings.unusedBytes), width: '100px', fixedWidth: true, sortable: true, align: DataGrid.DataGrid.Align.Right, sort: DataGrid.DataGrid.Order.Descending, weight: 1, }, { id: 'bars', title: i18nString(UIStrings.usageVisualization), width: '250px', fixedWidth: false, sortable: true, weight: 1, }, ]; this.dataGrid = new DataGrid.SortableDataGrid.SortableDataGrid<GridNode>({ displayName: i18nString(UIStrings.codeCoverage), columns, editCallback: undefined, refreshCallback: undefined, deleteCallback: undefined, }); this.dataGrid.setResizeMethod(DataGrid.DataGrid.ResizeMethod.Last); this.dataGrid.element.classList.add('flex-auto'); this.dataGrid.element.addEventListener('keydown', this.onKeyDown.bind(this), false); this.dataGrid.addEventListener(DataGrid.DataGrid.Events.OpenedNode, this.onOpenedNode, this); this.dataGrid.addEventListener(DataGrid.DataGrid.Events.SortingChanged, this.sortingChanged, this); const dataGridWidget = this.dataGrid.asWidget(); dataGridWidget.show(this.contentElement); this.setDefaultFocusedChild(dataGridWidget); } update(coverageInfo: URLCoverageInfo[]): void { let hadUpdates = false; const maxSize = coverageInfo.reduce((acc, entry) => Math.max(acc, entry.size()), 0); const rootNode = this.dataGrid.rootNode(); for (const entry of coverageInfo) { let node = this.nodeForCoverageInfo.get(entry); if (node) { if (this.isVisibleFilter(node.coverageInfo)) { hadUpdates = node.refreshIfNeeded(maxSize) || hadUpdates; } continue; } node = new GridNode(entry, maxSize); this.nodeForCoverageInfo.set(entry, node); if (this.isVisibleFilter(node.coverageInfo)) { rootNode.appendChild(node); hadUpdates = true; } } if (hadUpdates) { this.sortingChanged(); } } reset(): void { this.nodeForCoverageInfo.clear(); this.dataGrid.rootNode().removeChildren(); } updateFilterAndHighlight(highlightRegExp: RegExp|null): void { this.highlightRegExp = highlightRegExp; let hadTreeUpdates = false; for (const node of this.nodeForCoverageInfo.values()) { const shouldBeVisible = this.isVisibleFilter(node.coverageInfo); const isVisible = Boolean(node.parent); if (shouldBeVisible) { node.setHighlight(this.highlightRegExp); } if (shouldBeVisible === isVisible) { continue; } hadTreeUpdates = true; if (!shouldBeVisible) { node.remove(); } else { this.dataGrid.rootNode().appendChild(node); } } if (hadTreeUpdates) { this.sortingChanged(); } } selectByUrl(url: string): void { for (const [info, node] of this.nodeForCoverageInfo.entries()) { if (info.url() === url) { node.revealAndSelect(); break; } } } private onOpenedNode(): void { void this.revealSourceForSelectedNode(); } private onKeyDown(event: KeyboardEvent): void { if (!(event.key === 'Enter')) { return; } event.consume(true); void this.revealSourceForSelectedNode(); } private async revealSourceForSelectedNode(): Promise<void> { const node = this.dataGrid.selectedNode; if (!node) { return; } const coverageInfo = (node as GridNode).coverageInfo; const sourceCode = Workspace.Workspace.WorkspaceImpl.instance().uiSourceCodeForURL(coverageInfo.url()); if (!sourceCode) { return; } if (this.dataGrid.selectedNode !== node) { return; } void Common.Revealer.reveal(sourceCode); } private sortingChanged(): void { const columnId = this.dataGrid.sortColumnId(); if (!columnId) { return; } const sortFunction = GridNode.sortFunctionForColumn(columnId) as ( (arg0: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>, arg1: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>) => number) | null; if (!sortFunction) { return; } this.dataGrid.sortNodes(sortFunction, !this.dataGrid.isSortOrderAscending()); } override wasShown(): void { super.wasShown(); this.registerCSSFiles([coverageListViewStyles]); } } let percentageFormatter: Intl.NumberFormat|null = null; function getPercentageFormatter(): Intl.NumberFormat { if (!percentageFormatter) { percentageFormatter = new Intl.NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, { style: 'percent', maximumFractionDigits: 1, }); } return percentageFormatter; } export class GridNode extends DataGrid.SortableDataGrid.SortableDataGridNode<GridNode> { coverageInfo: URLCoverageInfo; private lastUsedSize!: number|undefined; private url: Platform.DevToolsPath.UrlString; private maxSize: number; private highlightRegExp: RegExp|null; constructor(coverageInfo: URLCoverageInfo, maxSize: number) { super(); this.coverageInfo = coverageInfo; this.url = coverageInfo.url(); this.maxSize = maxSize; this.highlightRegExp = null; } setHighlight(highlightRegExp: RegExp|null): void { if (this.highlightRegExp === highlightRegExp) { return; } this.highlightRegExp = highlightRegExp; this.refresh(); } refreshIfNeeded(maxSize: number): boolean { if (this.lastUsedSize === this.coverageInfo.usedSize() && maxSize === this.maxSize) { return false; } this.lastUsedSize = this.coverageInfo.usedSize(); this.maxSize = maxSize; this.refresh(); return true; } override createCell(columnId: string): HTMLElement { const cell = this.createTD(columnId); switch (columnId) { case 'url': { UI.Tooltip.Tooltip.install(cell, this.url); const outer = cell.createChild('div', 'url-outer'); const prefix = outer.createChild('div', 'url-prefix'); const suffix = outer.createChild('div', 'url-suffix'); const splitURL = /^(.*)(\/[^/]*)$/.exec(this.url); prefix.textContent = splitURL ? splitURL[1] : this.url; suffix.textContent = splitURL ? splitURL[2] : ''; if (this.highlightRegExp) { this.highlight(outer, this.url); } this.setCellAccessibleName(this.url, cell, columnId); break; } case 'type': { cell.textContent = coverageTypeToString(this.coverageInfo.type()); if (this.coverageInfo.type() & CoverageType.JavaScriptPerFunction) { UI.Tooltip.Tooltip.install(cell, i18nString(UIStrings.jsCoverageWithPerFunction)); } else if (this.coverageInfo.type() & CoverageType.JavaScript) { UI.Tooltip.Tooltip.install(cell, i18nString(UIStrings.jsCoverageWithPerBlock)); } break; } case 'size': { const sizeSpan = cell.createChild('span'); sizeSpan.textContent = Platform.NumberUtilities.withThousandsSeparator(this.coverageInfo.size() || 0); const sizeAccessibleName = i18nString(UIStrings.sBytes, {n: this.coverageInfo.size() || 0}); this.setCellAccessibleName(sizeAccessibleName, cell, columnId); break; } case 'unusedSize': { const unusedSize = this.coverageInfo.unusedSize() || 0; const unusedSizeSpan = cell.createChild('span'); const unusedPercentsSpan = cell.createChild('span', 'percent-value'); unusedSizeSpan.textContent = Platform.NumberUtilities.withThousandsSeparator(unusedSize); const unusedPercentFormatted = getPercentageFormatter().format(this.coverageInfo.unusedPercentage()); unusedPercentsSpan.textContent = unusedPercentFormatted; const unusedAccessibleName = i18nString(UIStrings.sBytesS, {n: unusedSize, percentage: unusedPercentFormatted}); this.setCellAccessibleName(unusedAccessibleName, cell, columnId); break; } case 'bars': { const barContainer = cell.createChild('div', 'bar-container'); const unusedPercent = getPercentageFormatter().format(this.coverageInfo.unusedPercentage()); const usedPercent = getPercentageFormatter().format(this.coverageInfo.usedPercentage()); if (this.coverageInfo.unusedSize() > 0) { const unusedSizeBar = barContainer.createChild('div', 'bar bar-unused-size'); unusedSizeBar.style.width = ((this.coverageInfo.unusedSize() / this.maxSize) * 100 || 0) + '%'; if (this.coverageInfo.type() & CoverageType.JavaScriptPerFunction) { UI.Tooltip.Tooltip.install( unusedSizeBar, i18nString( UIStrings.sBytesSBelongToFunctionsThatHave, {PH1: this.coverageInfo.unusedSize(), PH2: unusedPercent})); } else if (this.coverageInfo.type() & CoverageType.JavaScript) { UI.Tooltip.Tooltip.install( unusedSizeBar, i18nString( UIStrings.sBytesSBelongToBlocksOf, {PH1: this.coverageInfo.unusedSize(), PH2: unusedPercent})); } } if (this.coverageInfo.usedSize() > 0) { const usedSizeBar = barContainer.createChild('div', 'bar bar-used-size'); usedSizeBar.style.width = ((this.coverageInfo.usedSize() / this.maxSize) * 100 || 0) + '%'; if (this.coverageInfo.type() & CoverageType.JavaScriptPerFunction) { UI.Tooltip.Tooltip.install( usedSizeBar, i18nString( UIStrings.sBytesSBelongToFunctionsThatHaveExecuted, {PH1: this.coverageInfo.usedSize(), PH2: usedPercent})); } else if (this.coverageInfo.type() & CoverageType.JavaScript) { UI.Tooltip.Tooltip.install( usedSizeBar, i18nString( UIStrings.sBytesSBelongToBlocksOfJavascript, {PH1: this.coverageInfo.usedSize(), PH2: usedPercent})); } } this.setCellAccessibleName( i18nString(UIStrings.sOfFileUnusedSOfFileUsed, {PH1: unusedPercent, PH2: usedPercent}), cell, columnId); } } return cell; } private highlight(element: Element, textContent: string): void { if (!this.highlightRegExp) { return; } const matches = this.highlightRegExp.exec(textContent); if (!matches || !matches.length) { return; } const range = new TextUtils.TextRange.SourceRange(matches.index, matches[0].length); UI.UIUtils.highlightRangesWithStyleClass(element, [range], 'filter-highlight'); } static sortFunctionForColumn(columnId: string): ((arg0: GridNode, arg1: GridNode) => number)|null { const compareURL = (a: GridNode, b: GridNode): number => a.url.localeCompare(b.url); switch (columnId) { case 'url': return compareURL; case 'type': return (a: GridNode, b: GridNode): number => { const typeA = coverageTypeToString(a.coverageInfo.type()); const typeB = coverageTypeToString(b.coverageInfo.type()); return typeA.localeCompare(typeB) || compareURL(a, b); }; case 'size': return (a: GridNode, b: GridNode): number => a.coverageInfo.size() - b.coverageInfo.size() || compareURL(a, b); case 'bars': case 'unusedSize': return (a: GridNode, b: GridNode): number => a.coverageInfo.unusedSize() - b.coverageInfo.unusedSize() || compareURL(a, b); default: console.assert(false, 'Unknown sort field: ' + columnId); return null; } } }