chrome-devtools-frontend
Version:
Chrome DevTools UI
259 lines (226 loc) • 9.78 kB
text/typescript
// Copyright 2019 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-imperative-dom-api */
import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import type * 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 SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Coverage from '../coverage/coverage.js';
import {Plugin} from './Plugin.js';
// Plugin that shows a gutter with coverage information when available.
const UIStrings = {
/**
*@description Text for Coverage Status Bar Item in Sources Panel
*/
clickToShowCoveragePanel: 'Click to show Coverage Panel',
/**
*@description Text for Coverage Status Bar Item in Sources Panel
*/
showDetails: 'Show Details',
/**
*@description Text to show in the status bar if coverage data is available
*@example {12.3} PH1
*/
coverageS: 'Coverage: {PH1}',
/**
*@description Text to be shown in the status bar if no coverage data is available
*/
coverageNa: 'Coverage: n/a',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/sources/CoveragePlugin.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class CoveragePlugin extends Plugin {
private originalSourceCode: Workspace.UISourceCode.UISourceCode;
private infoInToolbar: UI.Toolbar.ToolbarButton;
private model: Coverage.CoverageModel.CoverageModel|null|undefined;
private coverage: Coverage.CoverageModel.URLCoverageInfo|null|undefined;
readonly #transformer: SourceFrame.SourceFrame.Transformer;
constructor(uiSourceCode: Workspace.UISourceCode.UISourceCode, transformer: SourceFrame.SourceFrame.Transformer) {
super(uiSourceCode);
this.originalSourceCode = this.uiSourceCode;
this.#transformer = transformer;
this.infoInToolbar = new UI.Toolbar.ToolbarButton(
i18nString(UIStrings.clickToShowCoveragePanel), undefined, undefined, 'debugger.show-coverage');
this.infoInToolbar.setSecondary();
this.infoInToolbar.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
void UI.ViewManager.ViewManager.instance().showView('coverage');
});
const mainTarget = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (mainTarget) {
this.model = mainTarget.model(Coverage.CoverageModel.CoverageModel);
if (this.model) {
this.model.addEventListener(Coverage.CoverageModel.Events.CoverageReset, this.handleReset, this);
this.coverage = this.model.getCoverageForUrl(this.originalSourceCode.url());
if (this.coverage) {
this.coverage.addEventListener(
Coverage.CoverageModel.URLCoverageInfo.Events.SizesChanged, this.handleCoverageSizesChanged, this);
}
}
}
this.updateStats();
}
override dispose(): void {
if (this.coverage) {
this.coverage.removeEventListener(
Coverage.CoverageModel.URLCoverageInfo.Events.SizesChanged, this.handleCoverageSizesChanged, this);
}
if (this.model) {
this.model.removeEventListener(Coverage.CoverageModel.Events.CoverageReset, this.handleReset, this);
}
}
static override accepts(uiSourceCode: Workspace.UISourceCode.UISourceCode): boolean {
return uiSourceCode.contentType().isDocumentOrScriptOrStyleSheet();
}
private handleReset(): void {
this.coverage = null;
this.updateStats();
}
private handleCoverageSizesChanged(): void {
this.updateStats();
}
private updateStats(): void {
if (this.coverage) {
this.infoInToolbar.setTitle(i18nString(UIStrings.showDetails));
const formatter = new Intl.NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, {
style: 'percent',
maximumFractionDigits: 1,
});
this.infoInToolbar.setText(
i18nString(UIStrings.coverageS, {PH1: formatter.format(this.coverage.usedPercentage())}));
} else {
this.infoInToolbar.setTitle(i18nString(UIStrings.clickToShowCoveragePanel));
this.infoInToolbar.setText(i18nString(UIStrings.coverageNa));
}
}
override rightToolbarItems(): UI.Toolbar.ToolbarItem[] {
return [this.infoInToolbar];
}
override editorExtension(): CodeMirror.Extension {
return coverageCompartment.of([]);
}
private getCoverageManager(): Coverage.CoverageDecorationManager.CoverageDecorationManager|undefined {
return this.uiSourceCode.getDecorationData(SourceFrame.SourceFrame.DecoratorType.COVERAGE);
}
override editorInitialized(editor: TextEditor.TextEditor.TextEditor): void {
if (this.getCoverageManager()) {
this.startDecoUpdate(editor);
}
}
override decorationChanged(type: SourceFrame.SourceFrame.DecoratorType, editor: TextEditor.TextEditor.TextEditor):
void {
if (type === SourceFrame.SourceFrame.DecoratorType.COVERAGE) {
this.startDecoUpdate(editor);
}
}
private startDecoUpdate(editor: TextEditor.TextEditor.TextEditor): void {
const manager = this.getCoverageManager();
void (manager ? manager.usageByLine(this.uiSourceCode, this.#editorLines(editor)) : Promise.resolve([]))
.then(usageByLine => {
const enabled = Boolean(editor.state.field(coverageState, false));
if (!usageByLine.length) {
if (enabled) {
editor.dispatch({effects: coverageCompartment.reconfigure([])});
}
} else if (!enabled) {
editor.dispatch({
effects: coverageCompartment.reconfigure([
coverageState.init(state => markersFromCoverageData(usageByLine, state)),
coverageGutter(this.uiSourceCode.url()),
theme,
]),
});
} else {
editor.dispatch({effects: setCoverageState.of(usageByLine)});
}
});
}
/**
* @returns The current lines of the CodeMirror editor expressed in terms of UISourceCode.
*/
#editorLines(editor: TextEditor.TextEditor.TextEditor): TextUtils.TextRange.TextRange[] {
const result: TextUtils.TextRange.TextRange[] = [];
for (let n = 1; n <= editor.state.doc.lines; ++n) {
const line = editor.state.doc.line(n);
// CodeMirror lines are 1-based where-as the transformer expects 0-based.
const {lineNumber: startLine, columnNumber: startColumn} = this.#transformer.editorLocationToUILocation(n - 1, 0);
const {lineNumber: endLine, columnNumber: endColumn} =
this.#transformer.editorLocationToUILocation(n - 1, line.length);
result.push(new TextUtils.TextRange.TextRange(startLine, startColumn, endLine, endColumn));
}
return result;
}
}
const coveredMarker = new (class extends CodeMirror.GutterMarker {
override elementClass = 'cm-coverageUsed';
})();
const notCoveredMarker = new (class extends CodeMirror.GutterMarker {
override elementClass = 'cm-coverageUnused';
})();
function markersFromCoverageData(usageByLine: Array<boolean|undefined>, state: CodeMirror.EditorState):
CodeMirror.RangeSet<CodeMirror.GutterMarker> {
const builder = new CodeMirror.RangeSetBuilder<CodeMirror.GutterMarker>();
for (let line = 0; line < usageByLine.length; line++) {
const usage = usageByLine[line];
if (usage !== undefined && line < state.doc.lines) {
const lineStart = state.doc.line(line + 1).from;
builder.add(lineStart, lineStart, usage ? coveredMarker : notCoveredMarker);
}
}
return builder.finish();
}
const setCoverageState = CodeMirror.StateEffect.define<Array<boolean|undefined>>();
const coverageState = CodeMirror.StateField.define<CodeMirror.RangeSet<CodeMirror.GutterMarker>>({
create(): CodeMirror.RangeSet<CodeMirror.GutterMarker> {
return CodeMirror.RangeSet.empty;
},
update(markers, tr) {
return tr.effects.reduce((markers, effect) => {
return effect.is(setCoverageState) ? markersFromCoverageData(effect.value, tr.state) : markers;
}, markers.map(tr.changes));
},
});
function coverageGutter(url: Platform.DevToolsPath.UrlString): CodeMirror.Extension {
return CodeMirror.gutter({
markers: view => view.state.field(coverageState),
domEventHandlers: {
click() {
void UI.ViewManager.ViewManager.instance()
.showView('coverage')
.then(() => {
const view = UI.ViewManager.ViewManager.instance().view('coverage');
return view?.widget();
})
.then(widget => {
const matchFormattedSuffix = url.match(/(.*):formatted$/);
const urlWithoutFormattedSuffix = (matchFormattedSuffix?.[1]) || url;
(widget as Coverage.CoverageView.CoverageView).selectCoverageItemByUrl(urlWithoutFormattedSuffix);
});
return true;
},
},
class: 'cm-coverageGutter',
});
}
const coverageCompartment = new CodeMirror.Compartment();
const theme = CodeMirror.EditorView.baseTheme({
'.cm-line::selection': {
backgroundColor: 'transparent',
color: 'currentColor',
},
'.cm-coverageGutter': {
width: '5px',
marginLeft: '3px',
},
'.cm-coverageUnused': {
backgroundColor: 'var(--app-color-coverage-unused)',
},
'.cm-coverageUsed': {
backgroundColor: 'var(--app-color-coverage-used)',
},
});