| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/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 * 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 type * 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(Workspace.UISourceCode.DecoratorType.COVERAGE); |
| } |
| |
| override editorInitialized(editor: TextEditor.TextEditor.TextEditor): void { |
| if (this.getCoverageManager()) { |
| this.startDecoUpdate(editor); |
| } |
| } |
| |
| override decorationChanged(type: Workspace.UISourceCode.DecoratorType, editor: TextEditor.TextEditor.TextEditor): |
| void { |
| if (type === Workspace.UISourceCode.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)', |
| }, |
| }); |