testing: avoid large hovers in test coverage, show inline counts instead (#202944)

Closes #202600

I still have a hover to make the "toggle line coverage" action visible, not sure a better place to put that...
This commit is contained in:
Connor Peet 2024-01-20 21:38:46 -08:00 committed by GitHub
parent 442419c7a2
commit e244acbb17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 100 additions and 55 deletions

View file

@ -683,6 +683,8 @@
"--vscode-terminalStickyScroll-background",
"--vscode-terminalStickyScrollHover-background",
"--vscode-testing-coverage-lineHeight",
"--vscode-testing-coverCountBadgeBackground",
"--vscode-testing-coverCountBadgeForeground",
"--vscode-testing-coveredBackground",
"--vscode-testing-coveredBorder",
"--vscode-testing-coveredGutterBackground",

View file

@ -3,13 +3,13 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { assertNever } from 'vs/base/common/assert';
import { RunOnceScheduler } from 'vs/base/common/async';
import { Color, RGBA } from 'vs/base/common/color';
import { Emitter, Event } from 'vs/base/common/event';
import { IJSONSchema, IJSONSchemaMap } from 'vs/base/common/jsonSchema';
import { assertNever } from 'vs/base/common/assert';
import * as nls from 'vs/nls';
import { Extensions as JSONExtensions, IJSONContributionRegistry } from 'vs/platform/jsonschemas/common/jsonContributionRegistry';
import { IJSONContributionRegistry, Extensions as JSONExtensions } from 'vs/platform/jsonschemas/common/jsonContributionRegistry';
import * as platform from 'vs/platform/registry/common/platform';
import { IColorTheme } from 'vs/platform/theme/common/themeService';

View file

@ -13,14 +13,13 @@ import { Lazy } from 'vs/base/common/lazy';
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
import { autorun, derived, observableFromEvent, observableValue } from 'vs/base/common/observable';
import { ThemeIcon } from 'vs/base/common/themables';
import { isDefined } from 'vs/base/common/types';
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, MouseTargetType } from 'vs/editor/browser/editorBrowser';
import { MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { IModelDecorationOptions, ITextModel, InjectedTextCursorStops } from 'vs/editor/common/model';
import { IModelDecorationOptions, ITextModel, InjectedTextCursorStops, InjectedTextOptions } from 'vs/editor/common/model';
import { HoverOperation, HoverStartMode, IHoverComputer } from 'vs/editor/contrib/hover/browser/hoverOperation';
import { localize } from 'vs/nls';
import { Categories } from 'vs/platform/action/common/actionCommonCategories';
@ -173,12 +172,12 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
return;
}
const wasPreviouslyHovering = typeof this.hoveredSubject === 'number';
this.hoveredStore.clear();
this.hoveredSubject = lineNumber;
const todo = [{ line: lineNumber, dir: 0 }];
const toEnable = new Set<string>();
const inlineEnabled = CodeCoverageDecorations.showInline.get();
if (!CodeCoverageDecorations.showInline.get()) {
for (let i = 0; i < todo.length && i < MAX_HOVERED_LINES; i++) {
const { line, dir } = todo[i];
@ -209,7 +208,9 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
});
}
this.lineHoverWidget.value.startShowingAt(lineNumber, this.details, wasPreviouslyHovering);
if (toEnable.size || inlineEnabled) {
this.lineHoverWidget.value.startShowingAt(lineNumber);
}
this.hoveredStore.add(this.editor.onMouseLeave(() => {
this.hoveredStore.clear();
@ -240,7 +241,7 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
model.changeDecorations(e => {
for (const detailRange of details.ranges) {
const { metadata: { detail, description }, range } = detailRange;
const { metadata: { detail, description }, range, primary } = detailRange;
if (detail.type === DetailType.Branch) {
const hits = detail.detail.branches![detail.branch].count;
const cls = hits ? CLASS_HIT : CLASS_MISS;
@ -263,6 +264,9 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
};
} else {
target.className = `coverage-deco-inline ${cls}`;
if (primary) {
target.before = countBadge(hits);
}
}
};
@ -282,6 +286,9 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
const applyHoverOptions = (target: IModelDecorationOptions) => {
target.className = `coverage-deco-inline ${cls}`;
target.hoverMessage = description;
if (primary) {
target.before = countBadge(detail.count);
}
};
if (showInlineByDefault) {
@ -336,8 +343,21 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
}
}
const countBadge = (count: number): InjectedTextOptions | undefined => {
if (count === 0) {
return undefined;
}
return {
content: `${count > 99 ? '99+' : count}x`,
cursorStops: InjectedTextCursorStops.None,
inlineClassName: `coverage-deco-inline-count`,
inlineClassNameAffectsLetterSpacing: true,
};
};
type CoverageDetailsWithBranch = CoverageDetails | { type: DetailType.Branch; branch: number; detail: IStatementCoverage };
type DetailRange = { range: Range; metadata: { detail: CoverageDetailsWithBranch; description: IMarkdownString | undefined } };
type DetailRange = { range: Range; primary: boolean; metadata: { detail: CoverageDetailsWithBranch; description: IMarkdownString | undefined } };
export class CoverageDetailsModel {
public readonly ranges: DetailRange[] = [];
@ -351,6 +371,7 @@ export class CoverageDetailsModel {
// the editor without ugly overlaps.
const detailRanges: DetailRange[] = details.map(detail => ({
range: tidyLocation(detail.location),
primary: true,
metadata: { detail, description: this.describe(detail, textModel) }
}));
@ -360,6 +381,7 @@ export class CoverageDetailsModel {
const branch: CoverageDetailsWithBranch = { type: DetailType.Branch, branch: i, detail };
detailRanges.push({
range: tidyLocation(detail.branches[i].location || Range.fromPositions(range.getEndPosition())),
primary: true,
metadata: {
detail: branch,
description: this.describe(branch, textModel),
@ -404,11 +426,13 @@ export class CoverageDetailsModel {
// until after the `item.range` ends.
const prev = stack[stack.length - 1];
if (prev) {
const primary = prev.primary;
const si = prev.range.setEndPosition(start.lineNumber, start.column);
prev.range = prev.range.setStartPosition(item.range.endLineNumber, item.range.endColumn);
prev.primary = false;
// discard the previous range if it became empty, e.g. a nested statement
if (prev.range.isEmpty()) { stack.pop(); }
result.push({ range: si, metadata: prev.metadata });
result.push({ range: si, primary, metadata: prev.metadata });
}
stack.push(item);
@ -460,39 +484,20 @@ function tidyLocation(location: Range | Position): Range {
class LineHoverComputer implements IHoverComputer<IMarkdownString> {
public line = -1;
public textModel!: ITextModel;
public details!: CoverageDetailsModel;
constructor(@IKeybindingService private readonly keybindingService: IKeybindingService) { }
/** @inheritdoc */
public computeSync(): IMarkdownString[] {
const bestDetails: DetailRange[] = [];
let bestLine = -1;
for (const detail of this.details.ranges) {
if (detail.range.startLineNumber > this.line) {
break;
}
if (detail.range.endLineNumber < this.line) {
continue;
}
if (detail.range.startLineNumber !== bestLine) {
bestDetails.length = 0;
}
bestLine = detail.range.startLineNumber;
bestDetails.push(detail);
}
const strs: IMarkdownString[] = [];
const strs = bestDetails.map(d => d.metadata.detail.type === DetailType.Branch ? undefined : d.metadata.description).filter(isDefined);
if (strs.length) {
const s = new MarkdownString().appendMarkdown(`[${TOGGLE_INLINE_COMMAND_TEXT}](command:${TOGGLE_INLINE_COMMAND_ID})`);
s.isTrusted = true;
const binding = this.keybindingService.lookupKeybinding(TOGGLE_INLINE_COMMAND_ID);
if (binding) {
s.appendText(` (${binding.getLabel()})`);
}
strs.push(s);
const s = new MarkdownString().appendMarkdown(`[${TOGGLE_INLINE_COMMAND_TEXT}](command:${TOGGLE_INLINE_COMMAND_ID})`);
s.isTrusted = true;
const binding = this.keybindingService.lookupKeybinding(TOGGLE_INLINE_COMMAND_ID);
if (binding) {
s.appendText(` (${binding.getLabel()})`);
}
strs.push(s);
return strs;
}
@ -556,7 +561,7 @@ class LineHoverWidget extends Disposable implements IOverlayWidget {
}
/** Shows the hover widget at the given line */
public startShowingAt(lineNumber: number, details: CoverageDetailsModel, showImmediate: boolean) {
public startShowingAt(lineNumber: number) {
this.hide();
const textModel = this.editor.getModel();
if (!textModel) {
@ -564,9 +569,7 @@ class LineHoverWidget extends Disposable implements IOverlayWidget {
}
this.computer.line = lineNumber;
this.computer.textModel = textModel;
this.computer.details = details;
this.hoverOperation.start(showImmediate ? HoverStartMode.Immediate : HoverStartMode.Delayed);
this.hoverOperation.start(HoverStartMode.Delayed);
}
/** Hides the hover widget */

View file

@ -147,7 +147,7 @@
.test-explorer .computed-state.retired,
.testing-run-glyph.retired {
opacity: 0.7 !important;
opacity: 0.7 !important;
}
.test-explorer .test-is-hidden {
@ -171,11 +171,7 @@
flex-grow: 1;
}
.monaco-workbench
.test-explorer
.monaco-action-bar
.action-item
> .action-label {
.monaco-workbench .test-explorer .monaco-action-bar .action-item > .action-label {
padding: 1px 2px;
margin-right: 2px;
}
@ -358,6 +354,7 @@
.monaco-editor .testing-inline-message-severity-0 {
color: var(--vscode-testing-message-error-decorationForeground) !important;
}
.monaco-editor .testing-inline-message-severity-1 {
color: var(--vscode-testing-message-info-decorationForeground) !important;
}
@ -411,8 +408,10 @@
.explorer-item-with-test-coverage .explorer-item {
flex-grow: 1;
}
.explorer-item-with-test-coverage .monaco-icon-label::after {
margin-right: 12px; /* slightly reduce because the bars handle the scrollbar margin */
margin-right: 12px;
/* slightly reduce because the bars handle the scrollbar margin */
}
/** -- coverage decorations */
@ -447,14 +446,13 @@
.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%
);
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;
}
@ -497,3 +495,31 @@
font: normal normal normal calc(var(--vscode-testing-coverage-lineHeight) / 2)/1 codicon;
border: 1px solid;
}
.coverage-deco-inline-count {
position: relative;
background: var(--vscode-testing-coverCountBadgeBackground);
color: var(--vscode-testing-coverCountBadgeForeground);
font-size: 0.7em;
margin: 0 0.7em 0 0.4em;
padding: 0.2em 0 0.2em 0.2em;
/* display: inline-block; */
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
}
.coverage-deco-inline-count::after {
content: '';
display: block;
position: absolute;
left: 100%;
top: 0;
bottom: 0;
width: 0.5em;
background-image:
linear-gradient(to bottom left, transparent 50%, var(--vscode-testing-coverCountBadgeBackground) 0),
linear-gradient(to bottom right, var(--vscode-testing-coverCountBadgeBackground) 50%, transparent 0);
background-size: 100% 50%;
background-repeat: no-repeat;
background-position: top, bottom;
}

View file

@ -5,7 +5,7 @@
import { Color, RGBA } from 'vs/base/common/color';
import { localize } from 'vs/nls';
import { chartsGreen, chartsRed, contrastBorder, diffInserted, diffRemoved, editorBackground, editorErrorForeground, editorForeground, editorInfoForeground, opaque, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry';
import { badgeBackground, badgeForeground, chartsGreen, chartsRed, contrastBorder, diffInserted, diffRemoved, editorBackground, editorErrorForeground, editorForeground, editorInfoForeground, opaque, registerColor, transparent } from 'vs/platform/theme/common/colorRegistry';
import { registerThemingParticipant } from 'vs/platform/theme/common/themeService';
import { TestMessageType, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes';
@ -135,6 +135,20 @@ export const testingUncoveredGutterBackground = registerColor('testing.uncovered
hcLight: chartsRed
}, localize('testing.uncoveredGutterBackground', 'Gutter color of regions where code not covered.'));
export const testingCoverCountBadgeBackground = registerColor('testing.coverCountBadgeBackground', {
dark: badgeBackground,
light: badgeBackground,
hcDark: badgeBackground,
hcLight: badgeBackground
}, localize('testing.coverCountBadgeBackground', 'Background for the badge indicating execution count'));
export const testingCoverCountBadgeForeground = registerColor('testing.coverCountBadgeForeground', {
dark: badgeForeground,
light: badgeForeground,
hcDark: badgeForeground,
hcLight: badgeForeground
}, localize('testing.coverCountBadgeForeground', 'Foreground for the badge indicating execution count'));
export const testMessageSeverityColors: {
[K in TestMessageType]: {
decorationForeground: string;