testing: add initial editor decorations

This is the first pass at decorations in-editor. This PR doesn't
actually register the contribution, as it's not ready for selfhosting
yet. This PR creates decorations that look like this. The idea is that
coverage decorations in the glyph margin will always be visibile when
there's coverage, and users can get coverage in their code via hover or
shortcut, with the intention of making coverage unobtrusive and easy to
run all the time.

![](https://memes.peet.io/img/24-01-8e61f4db-f115-4732-affe-59dea879a335.png)

The notable thing is that there is now a third glyph margin row. I
reworked some of the editor code to handle this.

![](https://memes.peet.io/img/24-01-f400369f-650c-4303-be65-e65903f8ad17.png)

Some open questions:

- The glyph margin coverage wants doesn't need to be full-width, should
  we add a new 'leftmost' glyph lane instead that's thinner?
- Adding breakpoints in files with coverage is a little annoying since
  the breakpoint hint widget can expand the glyph margin on lines with
	coverage, and jump back over otherwise. Probably we should never
	decrease the number of lanes shown whenever the cursor is over the
	glyph margin.

		![](https://memes.peet.io/img/24-01-79b53dd9-6fca-41dd-87b5-a113f9c25efb.gif)
pull/202048/head
Connor Peet 2024-01-08 16:04:32 -08:00
parent b5d2084dfb
commit 0e743a2d91
No known key found for this signature in database
GPG Key ID: CF8FD2EA0DBC61BD
13 changed files with 261 additions and 63 deletions

View File

@ -679,6 +679,8 @@
"--vscode-terminalOverviewRuler-findMatchForeground",
"--vscode-terminalStickyScroll-background",
"--vscode-terminalStickyScrollHover-background",
"--vscode-testing-coveredBackground",
"--vscode-testing-coveredGutterBackground",
"--vscode-testing-iconErrored",
"--vscode-testing-iconFailed",
"--vscode-testing-iconPassed",
@ -692,6 +694,8 @@
"--vscode-testing-peekBorder",
"--vscode-testing-peekHeaderBackground",
"--vscode-testing-runAction",
"--vscode-testing-uncoveredBackground",
"--vscode-testing-uncoveredGutterBackground",
"--vscode-textBlockQuote-background",
"--vscode-textBlockQuote-border",
"--vscode-textCodeBlock-background",

View File

@ -250,7 +250,7 @@ export class View extends ViewEventHandler {
// Add all margin decorations
glyphs = glyphs.concat(model.getAllMarginDecorations().map((decoration) => {
const lane = decoration.options.glyphMargin?.position ?? GlyphMarginLane.Left;
const lane = decoration.options.glyphMargin?.position ?? GlyphMarginLane.Center;
return { range: decoration.range, lane };
}));
@ -263,40 +263,34 @@ export class View extends ViewEventHandler {
// Sorted by their start position
glyphs.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range));
let leftDecRange: Range | null = null;
let rightDecRange: Range | null = null;
const maxLane = GlyphMarginLane.Right;
const lanes: (Range | undefined)[] = Array.from({ length: maxLane + 1 });
let requiredLanes = 1;
for (const decoration of glyphs) {
if (decoration.lane === GlyphMarginLane.Left && (!leftDecRange || Range.compareRangesUsingEnds(leftDecRange, decoration.range) < 0)) {
// assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane
leftDecRange = decoration.range;
// assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane
if (!lanes[decoration.lane] || Range.compareRangesUsingEnds(lanes[decoration.lane]!, decoration.range) < 0) {
lanes[decoration.lane] = decoration.range;
}
if (decoration.lane === GlyphMarginLane.Right && (!rightDecRange || Range.compareRangesUsingEnds(rightDecRange, decoration.range) < 0)) {
// assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane
rightDecRange = decoration.range;
}
if (leftDecRange && rightDecRange) {
if (leftDecRange.endLineNumber < rightDecRange.startLineNumber) {
// there's no chance for `leftDecRange` to ever intersect something going further
leftDecRange = null;
let requiredLanesHere = 0;
for (let i = 1; i <= maxLane; i++) {
const lane = lanes[i];
if (!lane || lane.endLineNumber < decoration.range.startLineNumber) {
lanes[i] = undefined;
continue;
}
if (rightDecRange.endLineNumber < leftDecRange.startLineNumber) {
// there's no chance for `rightDecRange` to ever intersect something going further
rightDecRange = null;
continue;
}
requiredLanesHere++;
}
// leftDecRange and rightDecRange are intersecting or touching => we need two lanes
return 2;
requiredLanes = Math.max(requiredLanes, requiredLanesHere);
if (requiredLanes === maxLane) {
return requiredLanes;
}
}
return 1;
return requiredLanes;
}
private _createPointerHandlerHelper(): IPointerHandlerHelper {

View File

@ -39,7 +39,8 @@ export enum OverviewRulerLane {
*/
export enum GlyphMarginLane {
Left = 1,
Right = 2
Center = 2,
Right = 3,
}
/**

View File

@ -2206,7 +2206,7 @@ export class ModelDecorationGlyphMarginOptions {
readonly position: model.GlyphMarginLane;
constructor(options: model.IModelDecorationGlyphMarginOptions | null | undefined) {
this.position = options?.position ?? model.GlyphMarginLane.Left;
this.position = options?.position ?? model.GlyphMarginLane.Center;
}
}

View File

@ -360,7 +360,8 @@ export enum EndOfLineSequence {
*/
export enum GlyphMarginLane {
Left = 1,
Right = 2
Center = 2,
Right = 3
}
/**

3
src/vs/monaco.d.ts vendored
View File

@ -1580,7 +1580,8 @@ declare namespace monaco.editor {
*/
export enum GlyphMarginLane {
Left = 1,
Right = 2
Center = 2,
Right = 3
}
/**

View File

@ -475,7 +475,11 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi
if (decorations) {
for (const { options } of decorations) {
const clz = options.glyphMarginClassName;
if (clz && (!clz.includes('codicon-') || clz.includes('codicon-testing-') || clz.includes('codicon-merge-') || clz.includes('codicon-arrow-') || clz.includes('codicon-loading') || clz.includes('codicon-fold') || clz.includes('codicon-inline-chat'))) {
if (!clz) {
continue;
}
const hasSomeActionableCodicon = !(clz.includes('codicon-') || clz.startsWith('coverage-deco-')) || clz.includes('codicon-testing-') || clz.includes('codicon-merge-') || clz.includes('codicon-arrow-') || clz.includes('codicon-loading') || clz.includes('codicon-fold') || clz.includes('codicon-inline-chat');
if (hasSomeActionableCodicon) {
return false;
}
}

View File

@ -0,0 +1,123 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
import { autorun, derived, observableFromEvent } from 'vs/base/common/observable';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { Range } from 'vs/editor/common/core/range';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { GlyphMarginLane, ITextModel } from 'vs/editor/common/model';
import { localize } from 'vs/nls';
import { ILogService } from 'vs/platform/log/common/log';
import { FileCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService';
import { DetailType } from 'vs/workbench/contrib/testing/common/testTypes';
export class CodeCoverageDecorations extends Disposable implements IEditorContribution {
private loadingCancellation?: CancellationTokenSource;
private readonly displayedStore = this._register(new DisposableStore());
constructor(
editor: ICodeEditor,
@ITestCoverageService coverage: ITestCoverageService,
@ILogService private readonly log: ILogService,
) {
super();
const modelObs = observableFromEvent(editor.onDidChangeModel, () => editor.getModel());
const fileCoverage = derived(reader => {
const report = coverage.selected.read(reader);
if (!report) {
return;
}
const model = modelObs.read(reader);
if (!model) {
return;
}
return report.getUri(model.uri);
});
this._register(autorun(reader => {
const c = fileCoverage.read(reader);
if (c) {
this.apply(editor.getModel()!, c);
} else {
this.clear();
}
}));
}
private async apply(model: ITextModel, coverage: FileCoverage) {
const details = await this.loadDetails(coverage);
if (!details) {
return this.clear();
}
const decorations: string[] = [];
model.changeDecorations(e => {
for (const detail of details) {
const range = detail.location instanceof Range ? detail.location : Range.fromPositions(detail.location);
if (detail.type === DetailType.Statement) {
const cls = detail.count > 0 ? 'coverage-deco-hit' : 'coverage-deco-miss';
decorations.push(e.addDecoration(range, {
showIfCollapsed: false,
glyphMargin: { position: GlyphMarginLane.Left },
description: localize('testing.hitCount', 'Hit count: {0}', detail.count),
glyphMarginClassName: `coverage-deco-gutter ${cls}`,
className: `coverage-deco-inline ${cls}`,
}));
if (detail.branches) {
for (const branch of detail.branches) {
const location = branch.location || range.getEndPosition();
const branchRange = location instanceof Range ? location : Range.fromPositions(location);
decorations.push(e.addDecoration(branchRange, {
showIfCollapsed: false,
glyphMargin: { position: GlyphMarginLane.Left },
description: localize('testing.hitCount', 'Hit count: {0}', detail.count),
glyphMarginClassName: `coverage-deco-gutter ${cls}`,
className: `coverage-deco-inline ${cls}`,
}));
}
}
}
}
});
this.displayedStore.add(toDisposable(() => {
model.changeDecorations(e => {
for (const decoration of decorations) {
e.removeDecoration(decoration);
}
});
}));
}
private clear() {
this.loadingCancellation?.cancel();
this.loadingCancellation = undefined;
this.displayedStore.clear();
}
private async loadDetails(coverage: FileCoverage) {
const cts = this.loadingCancellation = new CancellationTokenSource();
this.displayedStore.add(this.loadingCancellation);
try {
const details = await coverage.details(this.loadingCancellation.token);
if (!cts.token.isCancellationRequested) {
return details;
}
} catch (e) {
this.log.error('Error loading coverage details', e);
}
return undefined;
}
}

View File

@ -414,3 +414,43 @@
.explorer-item-with-test-coverage .monaco-icon-label::after {
margin-right: 12px; /* slightly reduce because the bars handle the scrollbar margin */
}
/** -- coverage decorations */
.coverage-deco-gutter::before {
content: '';
position: absolute;
inset: 0;
right: 25%;
left: 25%;
}
.coverage-deco-gutter.coverage-deco-hit::before {
background: var(--vscode-testing-coveredGutterBackground);
}
.coverage-deco-gutter.coverage-deco-miss::before {
background: var(--vscode-testing-uncoveredGutterBackground);
}
.coverage-deco-gutter.coverage-deco-miss.coverage-deco-hit::before {
background-image: linear-gradient(45deg,
var(--vscode-testing-coveredGutterBackground) 25%,
var(--vscode-testing-uncoveredGutterBackground) 25%,
var(--vscode-testing-uncoveredGutterBackground) 50%,
var(--vscode-testing-coveredGutterBackground) 50%,
75%,
var(--vscode-testing-uncoveredGutterBackground) 75%,
var(--vscode-testing-uncoveredGutterBackground) 100%
);
background-size: 6px 6px;
background-color: transparent;
}
.coverage-deco-inline.coverage-deco-hit {
background: var(--vscode-testing-coveredBackground);
}
.coverage-deco-inline.coverage-deco-miss {
background: var(--vscode-testing-uncoveredBackground);
}

View File

@ -754,11 +754,14 @@ abstract class RunTestDecoration {
this.showContextMenu(e);
break;
case DefaultGutterClickAction.Debug:
(alternateAction ? this.defaultRun() : this.defaultDebug());
this.runWith(alternateAction ? TestRunProfileBitset.Run : TestRunProfileBitset.Debug);
break;
case DefaultGutterClickAction.Coverage:
this.runWith(alternateAction ? TestRunProfileBitset.Debug : TestRunProfileBitset.Coverage);
break;
case DefaultGutterClickAction.Run:
default:
(alternateAction ? this.defaultDebug() : this.defaultRun());
this.runWith(alternateAction ? TestRunProfileBitset.Debug : TestRunProfileBitset.Run);
break;
}
@ -798,17 +801,10 @@ abstract class RunTestDecoration {
*/
abstract getContextMenuActions(): IReference<IAction[]>;
protected defaultRun() {
protected runWith(profile: TestRunProfileBitset) {
return this.testService.runTests({
tests: this.tests.map(({ test }) => test),
group: TestRunProfileBitset.Run,
});
}
protected defaultDebug() {
return this.testService.runTests({
tests: this.tests.map(({ test }) => test),
group: TestRunProfileBitset.Debug,
group: profile,
});
}
@ -823,6 +819,8 @@ abstract class RunTestDecoration {
return localize('testing.gutterMsg.contextMenu', 'Click for test options');
case DefaultGutterClickAction.Debug:
return localize('testing.gutterMsg.debug', 'Click to debug tests, right click for more options');
case DefaultGutterClickAction.Coverage:
return localize('testing.gutterMsg.coverage', 'Click to run tests with coverage, right click for more options');
case DefaultGutterClickAction.Run:
default:
return localize('testing.gutterMsg.run', 'Click to run tests, right click for more options');
@ -835,19 +833,17 @@ abstract class RunTestDecoration {
protected getTestContextMenuActions(test: InternalTestItem, resultItem?: TestResultItem): IReference<IAction[]> {
const testActions: IAction[] = [];
const capabilities = this.testProfileService.capabilitiesForTest(test);
if (capabilities & TestRunProfileBitset.Run) {
testActions.push(new Action('testing.gutter.run', localize('run test', 'Run Test'), undefined, undefined, () => this.testService.runTests({
group: TestRunProfileBitset.Run,
tests: [test],
})));
}
if (capabilities & TestRunProfileBitset.Debug) {
testActions.push(new Action('testing.gutter.debug', localize('debug test', 'Debug Test'), undefined, undefined, () => this.testService.runTests({
group: TestRunProfileBitset.Debug,
tests: [test],
})));
}
[
{ bitset: TestRunProfileBitset.Run, label: localize('run test', 'Run Test') },
{ bitset: TestRunProfileBitset.Debug, label: localize('debug test', 'Debug Test') },
{ bitset: TestRunProfileBitset.Coverage, label: localize('coverage test', 'Run with Coverage') },
].forEach(({ bitset, label }) => {
if (capabilities & bitset) {
testActions.push(new Action(`testing.gutter.${bitset}`, label, undefined, undefined,
() => this.testService.runTests({ group: bitset, tests: [test] })));
}
});
if (capabilities & TestRunProfileBitset.HasNonDefaultProfile) {
testActions.push(new Action('testing.runUsing', localize('testing.runUsing', 'Execute Using Profile...'), undefined, undefined, async () => {
@ -924,17 +920,19 @@ class MultiRunTestDecoration extends RunTestDecoration implements ITestDecoratio
super(tests, visible, model, codeEditorService, testService, contextMenuService, commandService, configurationService, testProfileService, contextKeyService, menuService);
}
override getContextMenuActions() {
public override getContextMenuActions() {
const allActions: IAction[] = [];
const canRun = this.tests.some(({ test }) => this.testProfileService.capabilitiesForTest(test) & TestRunProfileBitset.Run);
if (canRun) {
allActions.push(new Action('testing.gutter.runAll', localize('run all test', 'Run All Tests'), undefined, undefined, () => this.defaultRun()));
}
const canDebug = this.tests.some(({ test }) => this.testProfileService.capabilitiesForTest(test) & TestRunProfileBitset.Debug);
if (canDebug) {
allActions.push(new Action('testing.gutter.debugAll', localize('debug all test', 'Debug All Tests'), undefined, undefined, () => this.defaultDebug()));
}
[
{ bitset: TestRunProfileBitset.Run, label: localize('run all test', 'Run All Tests') },
{ bitset: TestRunProfileBitset.Coverage, label: localize('run all test with coverage', 'Run All Tests with Coverage') },
{ bitset: TestRunProfileBitset.Debug, label: localize('debug all test', 'Debug All Tests') },
].forEach(({ bitset, label }, i) => {
const canRun = this.tests.some(({ test }) => this.testProfileService.capabilitiesForTest(test) & bitset);
if (canRun) {
allActions.push(new Action(`testing.gutter.run${i}`, label, undefined, undefined, () => this.runWith(bitset)));
}
});
const testItems = this.tests.map((testItem): IMultiRunTest => ({
currentLabel: testItem.test.item.label,

View File

@ -5,7 +5,7 @@
import { Color, RGBA } from 'vs/base/common/color';
import { localize } from 'vs/nls';
import { contrastBorder, editorErrorForeground, editorForeground, editorInfoForeground, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry';
import { contrastBorder, diffInserted, diffRemoved, editorErrorForeground, editorForeground, editorInfoForeground, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry';
import { TestMessageType, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes';
export const testingColorIconFailed = registerColor('testing.iconFailed', {
@ -85,6 +85,34 @@ export const testingPeekMessageHeaderBackground = registerColor('testing.message
hcLight: null
}, localize('testing.messagePeekHeaderBackground', 'Color of the peek view borders and arrow when peeking a logged message.'));
export const testingCoveredBackground = registerColor('testing.coveredBackground', {
dark: diffInserted,
light: diffInserted,
hcDark: null,
hcLight: null
}, localize('testing.coveredBackground', 'Background color of text that was covered.'));
export const testingCoveredGutterBackground = registerColor('testing.coveredGutterBackground', {
dark: diffInserted,
light: diffInserted,
hcDark: null,
hcLight: null
}, localize('testing.coveredGutterBackground', 'Gutter color of regions where code was covered.'));
export const testingUncoveredBackground = registerColor('testing.uncoveredBackground', {
dark: diffRemoved,
light: diffRemoved,
hcDark: null,
hcLight: null
}, localize('testing.uncoveredBackground', 'Background color of text that was not covered.'));
export const testingUncoveredGutterBackground = registerColor('testing.uncoveredGutterBackground', {
dark: diffRemoved,
light: diffRemoved,
hcDark: null,
hcLight: null
}, localize('testing.uncoveredGutterBackground', 'Gutter color of regions where code not covered.'));
export const testMessageSeverityColors: {
[K in TestMessageType]: {
decorationForeground: string;

View File

@ -41,6 +41,7 @@ export const enum AutoOpenPeekViewWhen {
export const enum DefaultGutterClickAction {
Run = 'run',
Debug = 'debug',
Coverage = 'runWithCoverage',
ContextMenu = 'contextMenu',
}
@ -119,11 +120,13 @@ export const testingConfiguration: IConfigurationNode = {
enum: [
DefaultGutterClickAction.Run,
DefaultGutterClickAction.Debug,
DefaultGutterClickAction.Coverage,
DefaultGutterClickAction.ContextMenu,
],
enumDescriptions: [
localize('testing.defaultGutterClickAction.run', 'Run the test.'),
localize('testing.defaultGutterClickAction.debug', 'Debug the test.'),
localize('testing.defaultGutterClickAction.coverage', 'Run the test with coverage.'),
localize('testing.defaultGutterClickAction.contextMenu', 'Open the context menu for more options.'),
],
default: DefaultGutterClickAction.Run,

View File

@ -13,6 +13,7 @@ export const enum Testing {
ExplorerViewId = 'workbench.view.testing',
OutputPeekContributionId = 'editor.contrib.testingOutputPeek',
DecorationsContributionId = 'editor.contrib.testingDecorations',
CoverageDecorationsContributionId = 'editor.contrib.coverageDecorations',
CoverageViewId = 'workbench.view.testCoverage',
ResultsPanelId = 'workbench.panel.testResults',