testing: menu contribution points around messages (#190298)

- Implements the proposal in #190277 by adding a `contextValue` to
  TestMessages added to test runs.
- Make the `FloatingClickMenu` reusable outside the editor, and uses
  it to implement a `testing/message/content` contribution point.

With this extensions can do things like:

![](https://memes.peet.io/img/23-08-68e2f9db-abc4-4717-9da6-698b002c481c.png)
This commit is contained in:
Connor Peet 2023-08-12 08:03:01 -07:00 committed by GitHub
parent 1b8729178c
commit 2d9cc42045
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 493 additions and 249 deletions

View file

@ -40,6 +40,7 @@
"envShellEvent",
"testCoverage",
"testObserver",
"testMessageContextValue",
"textSearchProvider",
"timeline",
"tokenInformation",

View file

@ -20,5 +20,6 @@ export const enum MarshalledId {
NotebookCellActionContext,
NotebookActionContext,
TestItemContext,
Date
Date,
TestMessageMenuArgs,
}

View file

@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { $, append, clearNode } from 'vs/base/browser/dom';
import { Widget } from 'vs/base/browser/ui/widget';
import { IAction } from 'vs/base/common/actions';
import { Emitter } from 'vs/base/common/event';
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
import { IMenu, IMenuService, MenuId } from 'vs/platform/actions/common/actions';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { asCssVariable, asCssVariableWithDefault, buttonBackground, buttonForeground, contrastBorder, editorBackground, editorForeground } from 'vs/platform/theme/common/colorRegistry';
export class FloatingClickWidget extends Widget {
private readonly _onClick = this._register(new Emitter<void>());
readonly onClick = this._onClick.event;
private _domNode: HTMLElement;
constructor(private label: string) {
super();
this._domNode = $('.floating-click-widget');
this._domNode.style.padding = '6px 11px';
this._domNode.style.borderRadius = '2px';
this._domNode.style.cursor = 'pointer';
this._domNode.style.zIndex = '1';
}
getDomNode(): HTMLElement {
return this._domNode;
}
render() {
clearNode(this._domNode);
this._domNode.style.backgroundColor = asCssVariableWithDefault(buttonBackground, asCssVariable(editorBackground));
this._domNode.style.color = asCssVariableWithDefault(buttonForeground, asCssVariable(editorForeground));
this._domNode.style.border = `1px solid ${asCssVariable(contrastBorder)}`;
append(this._domNode, $('')).textContent = this.label;
this.onclick(this._domNode, () => this._onClick.fire());
}
}
export abstract class AbstractFloatingClickMenu extends Disposable {
private readonly renderEmitter = new Emitter<FloatingClickWidget>();
protected readonly onDidRender = this.renderEmitter.event;
private readonly menu: IMenu;
constructor(
menuId: MenuId,
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService
) {
super();
this.menu = this._register(menuService.createMenu(menuId, contextKeyService));
}
/** Should be called in implementation constructors after they initialized */
protected render() {
const menuDisposables = this._register(new DisposableStore());
const renderMenuAsFloatingClickBtn = () => {
menuDisposables.clear();
if (!this.isVisible()) {
return;
}
const actions: IAction[] = [];
createAndFillInActionBarActions(this.menu, { renderShortTitle: true, shouldForwardArgs: true }, actions);
if (actions.length === 0) {
return;
}
// todo@jrieken find a way to handle N actions, like showing a context menu
const [first] = actions;
const widget = this.createWidget(first, menuDisposables);
menuDisposables.add(widget);
menuDisposables.add(widget.onClick(() => first.run(this.getActionArg())));
widget.render();
};
this._register(this.menu.onDidChange(renderMenuAsFloatingClickBtn));
renderMenuAsFloatingClickBtn();
}
protected abstract createWidget(action: IAction, disposables: DisposableStore): FloatingClickWidget;
protected getActionArg(): unknown {
return undefined;
}
protected isVisible() {
return true;
}
}
export class FloatingClickMenu extends AbstractFloatingClickMenu {
constructor(
private readonly options: {
/** Element the menu should be rendered into. */
container: HTMLElement;
/** Menu to show. If no actions are present, the button is hidden. */
menuId: MenuId;
/** Argument provided to the menu action */
getActionArg: () => void;
},
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService
) {
super(options.menuId, menuService, contextKeyService);
this.render();
}
protected override createWidget(action: IAction, disposable: DisposableStore): FloatingClickWidget {
const w = this.instantiationService.createInstance(FloatingClickWidget, action.label);
const node = w.getDomNode();
this.options.container.appendChild(node);
disposable.add(toDisposable(() => this.options.container.removeChild(node)));
return w;
}
protected override getActionArg(): unknown {
return this.options.getActionArg();
}
}

View file

@ -114,6 +114,8 @@ export class MenuId {
static readonly StickyScrollContext = new MenuId('StickyScrollContext');
static readonly TestItem = new MenuId('TestItem');
static readonly TestItemGutter = new MenuId('TestItemGutter');
static readonly TestMessageContext = new MenuId('TestMessageContext');
static readonly TestMessageContent = new MenuId('TestMessageContent');
static readonly TestPeekElement = new MenuId('TestPeekElement');
static readonly TestPeekTitle = new MenuId('TestPeekTitle');
static readonly TouchBarContext = new MenuId('TouchBarContext');

View file

@ -1521,8 +1521,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
LinkedEditingRanges: extHostTypes.LinkedEditingRanges,
TestResultState: extHostTypes.TestResultState,
TestRunRequest: extHostTypes.TestRunRequest,
TestRunRequest2: extHostTypes.TestRunRequest2,
TestMessage: extHostTypes.TestMessage,
TestMessage2: extHostTypes.TestMessage,
TestTag: extHostTypes.TestTag,
TestRunProfileKind: extHostTypes.TestRunProfileKind,
TextSearchCompleteMessageType: TextSearchCompleteMessageType,

View file

@ -17,7 +17,7 @@ import { MarshalledId } from 'vs/base/common/marshallingIds';
import { deepFreeze } from 'vs/base/common/objects';
import { isDefined } from 'vs/base/common/types';
import { generateUuid } from 'vs/base/common/uuid';
import { IExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { IExtensionDescription, IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { ExtHostTestingShape, ILocationDto, MainContext, MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol';
import { ExtHostCommands } from 'vs/workbench/api/common/extHostCommands';
import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors';
@ -28,13 +28,15 @@ import { TestRunProfileKind, TestRunRequest } from 'vs/workbench/api/common/extH
import { TestCommandId } from 'vs/workbench/contrib/testing/common/constants';
import { TestId, TestIdPathParts, TestPosition } from 'vs/workbench/contrib/testing/common/testId';
import { InvalidTestItemError } from 'vs/workbench/contrib/testing/common/testItemCollection';
import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestItem, ITestItemContext, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes';
import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes';
import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions';
import type * as vscode from 'vscode';
interface ControllerInfo {
controller: vscode.TestController;
profiles: Map<number, vscode.TestRunProfile>;
collection: ExtHostTestItemCollection;
extension: Readonly<IRelaxedExtensionDescription>;
}
export class ExtHostTesting implements ExtHostTestingShape {
@ -58,14 +60,22 @@ export class ExtHostTesting implements ExtHostTestingShape {
commands.registerArgumentProcessor({
processArgument: arg => {
if (arg?.$mid !== MarshalledId.TestItemContext) {
return arg;
switch (arg?.$mid) {
case MarshalledId.TestItemContext: {
const cast = arg as ITestItemContext;
const targetTest = cast.tests[cast.tests.length - 1].item.extId;
const controller = this.controllers.get(TestId.root(targetTest));
return controller?.collection.tree.get(targetTest)?.actual ?? toItemFromContext(arg);
}
case MarshalledId.TestMessageMenuArgs: {
const { extId, message } = arg as ITestMessageMenuArgs;
return {
test: this.controllers.get(TestId.root(extId))?.collection.tree.get(extId)?.actual,
message: Convert.TestMessage.to(message as ITestErrorMessage.Serialized),
};
}
default: return arg;
}
const cast = arg as ITestItemContext;
const targetTest = cast.tests[cast.tests.length - 1].item.extId;
const controller = this.controllers.get(TestId.root(targetTest));
return controller?.collection.tree.get(targetTest)?.actual ?? toItemFromContext(arg);
}
});
@ -137,7 +147,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
return new TestItemImpl(controllerId, id, label, uri);
},
createTestRun: (request, name, persist = true) => {
return this.runTracker.createTestRun(controllerId, collection, request, name, persist);
return this.runTracker.createTestRun(extension, controllerId, collection, request, name, persist);
},
invalidateTestResults: items => {
if (items === undefined) {
@ -161,7 +171,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
proxy.$registerTestController(controllerId, label, !!refreshHandler);
disposable.add(toDisposable(() => proxy.$unregisterTestController(controllerId)));
const info: ControllerInfo = { controller, collection, profiles: profiles };
const info: ControllerInfo = { controller, collection, profiles: profiles, extension };
this.controllers.set(controllerId, info);
disposable.add(toDisposable(() => this.controllers.delete(controllerId)));
@ -310,7 +320,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
return {};
}
const { collection, profiles } = lookup;
const { collection, profiles, extension } = lookup;
const profile = profiles.get(req.profileId);
if (!profile) {
return {};
@ -341,6 +351,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
const tracker = isStartControllerTests(req) && this.runTracker.prepareForMainThreadTestRun(
publicReq,
TestRunDto.fromInternal(req, lookup.collection),
extension,
token,
);
@ -410,7 +421,12 @@ class TestRunTracker extends Disposable {
return this.dto.id;
}
constructor(private readonly dto: TestRunDto, private readonly proxy: MainThreadTestingShape, parentToken?: CancellationToken) {
constructor(
private readonly dto: TestRunDto,
private readonly proxy: MainThreadTestingShape,
private readonly extension: Readonly<IRelaxedExtensionDescription>,
parentToken?: CancellationToken,
) {
super();
this.cts = this._register(new CancellationTokenSource(parentToken));
@ -460,6 +476,10 @@ class TestRunTracker extends Disposable {
? messages.map(Convert.TestMessage.from)
: [Convert.TestMessage.from(messages)];
if (converted.some(c => c.contextValue !== undefined)) {
checkProposedApiEnabled(this.extension, 'testMessageContextValue');
}
if (test.uri && test.range) {
const defaultLocation: ILocationDto = { range: Convert.Range.from(test.range), uri: test.uri };
for (const message of converted) {
@ -606,8 +626,8 @@ export class TestRunCoordinator {
* `$startedExtensionTestRun` is not invoked. The run must eventually
* be cancelled manually.
*/
public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, token: CancellationToken) {
return this.getTracker(req, dto, token);
public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, extension: Readonly<IRelaxedExtensionDescription>, token: CancellationToken) {
return this.getTracker(req, dto, extension, token);
}
/**
@ -635,7 +655,7 @@ export class TestRunCoordinator {
/**
* Implements the public `createTestRun` API.
*/
public createTestRun(controllerId: string, collection: ExtHostTestItemCollection, request: vscode.TestRunRequest, name: string | undefined, persist: boolean): vscode.TestRun {
public createTestRun(extension: IRelaxedExtensionDescription, controllerId: string, collection: ExtHostTestItemCollection, request: vscode.TestRunRequest, name: string | undefined, persist: boolean): vscode.TestRun {
const existing = this.tracked.get(request);
if (existing) {
return existing.createRun(name);
@ -655,7 +675,7 @@ export class TestRunCoordinator {
persist
});
const tracker = this.getTracker(request, dto);
const tracker = this.getTracker(request, dto, extension);
tracker.onEnd(() => {
this.proxy.$finishedExtensionTestRun(dto.id);
tracker.dispose();
@ -664,8 +684,8 @@ export class TestRunCoordinator {
return tracker.createRun(name);
}
private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, token?: CancellationToken) {
const tracker = new TestRunTracker(dto, this.proxy, token);
private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, extension: IRelaxedExtensionDescription, token?: CancellationToken) {
const tracker = new TestRunTracker(dto, this.proxy, extension, token);
this.tracked.set(req, tracker);
tracker.onEnd(() => this.tracked.delete(req));
return tracker;

View file

@ -1799,20 +1799,22 @@ export namespace NotebookRendererScript {
}
export namespace TestMessage {
export function from(message: vscode.TestMessage): ITestErrorMessage.Serialized {
export function from(message: vscode.TestMessage2): ITestErrorMessage.Serialized {
return {
message: MarkdownString.fromStrict(message.message) || '',
type: TestMessageType.Error,
expected: message.expectedOutput,
actual: message.actualOutput,
contextValue: message.contextValue,
location: message.location && ({ range: Range.from(message.location.range), uri: message.location.uri }),
};
}
export function to(item: ITestErrorMessage.Serialized): vscode.TestMessage {
export function to(item: ITestErrorMessage.Serialized): vscode.TestMessage2 {
const message = new types.TestMessage(typeof item.message === 'string' ? item.message : MarkdownString.to(item.message));
message.actualOutput = item.actual;
message.expectedOutput = item.expected;
message.contextValue = item.contextValue;
message.location = item.location ? location.to(item.location) : undefined;
return message;
}

View file

@ -3884,15 +3884,13 @@ export class TestRunRequest implements vscode.TestRunRequest {
) { }
}
/** Back-compat for proposed API users */
@es5ClassCompat
export class TestRunRequest2 extends TestRunRequest { }
@es5ClassCompat
export class TestMessage implements vscode.TestMessage {
public expectedOutput?: string;
public actualOutput?: string;
public location?: vscode.Location;
/** proposed: */
public contextValue?: string;
public static diff(message: string | vscode.MarkdownString, expected: string, actual: string) {
const msg = new TestMessage(message);

View file

@ -11,6 +11,7 @@ import { Iterable } from 'vs/base/common/iterator';
import { URI } from 'vs/base/common/uri';
import { mockObject, MockObject } from 'vs/base/test/common/mock';
import * as editorRange from 'vs/editor/common/core/range';
import { IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol';
import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors';
import { TestRunCoordinator, TestRunDto, TestRunProfileImpl } from 'vs/workbench/api/common/extHostTesting';
@ -594,6 +595,7 @@ suite('ExtHost Testing', () => {
let req: TestRunRequest;
let dto: TestRunDto;
const ext: IRelaxedExtensionDescription = {} as any;
setup(async () => {
proxy = mockObject<MainThreadTestingShape>()();
@ -621,11 +623,11 @@ suite('ExtHost Testing', () => {
});
test('tracks a run started from a main thread request', () => {
const tracker = c.prepareForMainThreadTestRun(req, dto, cts.token);
const tracker = c.prepareForMainThreadTestRun(req, dto, ext, cts.token);
assert.strictEqual(tracker.hasRunningTasks, false);
const task1 = c.createTestRun('ctrl', single, req, 'run1', true);
const task2 = c.createTestRun('ctrl', single, req, 'run2', true);
const task1 = c.createTestRun(ext, 'ctrl', single, req, 'run1', true);
const task2 = c.createTestRun(ext, 'ctrl', single, req, 'run2', true);
assert.strictEqual(proxy.$startedExtensionTestRun.called, false);
assert.strictEqual(tracker.hasRunningTasks, true);
@ -646,8 +648,8 @@ suite('ExtHost Testing', () => {
test('run cancel force ends after a timeout', () => {
const clock = sinon.useFakeTimers();
try {
const tracker = c.prepareForMainThreadTestRun(req, dto, cts.token);
const task = c.createTestRun('ctrl', single, req, 'run1', true);
const tracker = c.prepareForMainThreadTestRun(req, dto, ext, cts.token);
const task = c.createTestRun(ext, 'ctrl', single, req, 'run1', true);
const onEnded = sinon.stub();
tracker.onEnd(onEnded);
@ -671,8 +673,8 @@ suite('ExtHost Testing', () => {
});
test('run cancel force ends on second cancellation request', () => {
const tracker = c.prepareForMainThreadTestRun(req, dto, cts.token);
const task = c.createTestRun('ctrl', single, req, 'run1', true);
const tracker = c.prepareForMainThreadTestRun(req, dto, ext, cts.token);
const task = c.createTestRun(ext, 'ctrl', single, req, 'run1', true);
const onEnded = sinon.stub();
tracker.onEnd(onEnded);
@ -690,7 +692,7 @@ suite('ExtHost Testing', () => {
});
test('tracks a run started from an extension request', () => {
const task1 = c.createTestRun('ctrl', single, req, 'hello world', false);
const task1 = c.createTestRun(ext, 'ctrl', single, req, 'hello world', false);
const tracker = Iterable.first(c.trackers)!;
assert.strictEqual(tracker.hasRunningTasks, true);
@ -706,8 +708,8 @@ suite('ExtHost Testing', () => {
}]
]);
const task2 = c.createTestRun('ctrl', single, req, 'run2', true);
const task3Detached = c.createTestRun('ctrl', single, { ...req }, 'task3Detached', true);
const task2 = c.createTestRun(ext, 'ctrl', single, req, 'run2', true);
const task3Detached = c.createTestRun(ext, 'ctrl', single, { ...req }, 'task3Detached', true);
task1.end();
assert.strictEqual(proxy.$finishedExtensionTestRun.called, false);
@ -721,7 +723,7 @@ suite('ExtHost Testing', () => {
});
test('adds tests to run smartly', () => {
const task1 = c.createTestRun('ctrlId', single, req, 'hello world', false);
const task1 = c.createTestRun(ext, 'ctrlId', single, req, 'hello world', false);
const tracker = Iterable.first(c.trackers)!;
const expectedArgs: unknown[][] = [];
assert.deepStrictEqual(proxy.$addTestsToRun.args, expectedArgs);
@ -758,7 +760,7 @@ suite('ExtHost Testing', () => {
const test2 = new TestItemImpl('ctrlId', 'id-d', 'test d', URI.file('/testd.txt'));
test1.range = test2.range = new Range(new Position(0, 0), new Position(1, 0));
single.root.children.replace([test1, test2]);
const task = c.createTestRun('ctrlId', single, req, 'hello world', false);
const task = c.createTestRun(ext, 'ctrlId', single, req, 'hello world', false);
const message1 = new TestMessage('some message');
message1.location = new Location(URI.file('/a.txt'), new Position(0, 0));
@ -773,6 +775,7 @@ suite('ExtHost Testing', () => {
message: 'some message',
type: TestMessageType.Error,
expected: undefined,
contextValue: undefined,
actual: undefined,
location: convert.location.from(message1.location)
}]
@ -787,6 +790,7 @@ suite('ExtHost Testing', () => {
[{
message: 'some message',
type: TestMessageType.Error,
contextValue: undefined,
expected: undefined,
actual: undefined,
location: convert.location.from({ uri: test2.uri!, range: test2.range! }),
@ -795,7 +799,7 @@ suite('ExtHost Testing', () => {
});
test('guards calls after runs are ended', () => {
const task = c.createTestRun('ctrl', single, req, 'hello world', false);
const task = c.createTestRun(ext, 'ctrl', single, req, 'hello world', false);
task.end();
task.failed(single.root, new TestMessage('some message'));
@ -807,7 +811,7 @@ suite('ExtHost Testing', () => {
});
test('excludes tests outside tree or explicitly excluded', () => {
const task = c.createTestRun('ctrlId', single, {
const task = c.createTestRun(ext, 'ctrlId', single, {
profile: configuration,
include: [single.root.children.get('id-a')!],
exclude: [single.root.children.get('id-a')!.children.get('id-aa')!],
@ -835,7 +839,7 @@ suite('ExtHost Testing', () => {
const childB = new TestItemImpl('ctrlId', 'id-child', 'child', undefined);
testB!.children.replace([childB]);
const task1 = c.createTestRun('ctrl', single, new TestRunRequestImpl(), 'hello world', false);
const task1 = c.createTestRun(ext, 'ctrl', single, new TestRunRequestImpl(), 'hello world', false);
const tracker = Iterable.first(c.trackers)!;
task1.passed(childA);

View file

@ -3,28 +3,25 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Widget } from 'vs/base/browser/ui/widget';
import { IOverlayWidget, ICodeEditor, IOverlayWidgetPosition, OverlayWidgetPositionPreference, isCodeEditor, isCompositeEditor } from 'vs/editor/browser/editorBrowser';
import { IAction } from 'vs/base/common/actions';
import { Emitter } from 'vs/base/common/event';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { $, append, clearNode } from 'vs/base/browser/dom';
import { buttonBackground, buttonForeground, editorBackground, editorForeground, contrastBorder, asCssVariableWithDefault, asCssVariable } from 'vs/platform/theme/common/colorRegistry';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { isEqual } from 'vs/base/common/resources';
import { URI } from 'vs/base/common/uri';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPositionPreference, isCodeEditor, isCompositeEditor } from 'vs/editor/browser/editorBrowser';
import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { IRange } from 'vs/editor/common/core/range';
import { CursorChangeReason, ICursorPositionChangedEvent } from 'vs/editor/common/cursorEvents';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { IModelDecorationsChangeAccessor, TrackedRangeStickiness } from 'vs/editor/common/model';
import { ModelDecorationOptions } from 'vs/editor/common/model/textModel';
import { TrackedRangeStickiness, IModelDecorationsChangeAccessor } from 'vs/editor/common/model';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { AbstractFloatingClickMenu, FloatingClickWidget } from 'vs/platform/actions/browser/floatingMenu';
import { IMenuService, MenuId } from 'vs/platform/actions/common/actions';
import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IAction } from 'vs/base/common/actions';
import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
export interface IRangeHighlightDecoration {
resource: URI;
@ -134,106 +131,65 @@ export class RangeHighlightDecorations extends Disposable {
}
}
export class FloatingClickWidget extends Widget implements IOverlayWidget {
private readonly _onClick = this._register(new Emitter<void>());
readonly onClick = this._onClick.event;
private _domNode: HTMLElement;
export class FloatingEditorClickWidget extends FloatingClickWidget implements IOverlayWidget {
constructor(
private editor: ICodeEditor,
private label: string,
label: string,
keyBindingAction: string | null,
@IKeybindingService keybindingService: IKeybindingService
) {
super();
this._domNode = $('.floating-click-widget');
this._domNode.style.padding = '6px 11px';
this._domNode.style.borderRadius = '2px';
this._domNode.style.cursor = 'pointer';
this._domNode.style.zIndex = '1';
if (keyBindingAction) {
const keybinding = keybindingService.lookupKeybinding(keyBindingAction);
if (keybinding) {
this.label += ` (${keybinding.getLabel()})`;
}
}
super(
keyBindingAction && keybindingService.lookupKeybinding(keyBindingAction)
? `${label} (${keybindingService.lookupKeybinding(keyBindingAction)!.getLabel()})`
: label
);
}
getId(): string {
return 'editor.overlayWidget.floatingClickWidget';
}
getDomNode(): HTMLElement {
return this._domNode;
}
getPosition(): IOverlayWidgetPosition {
return {
preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER
};
}
render() {
clearNode(this._domNode);
this._domNode.style.backgroundColor = asCssVariableWithDefault(buttonBackground, asCssVariable(editorBackground));
this._domNode.style.color = asCssVariableWithDefault(buttonForeground, asCssVariable(editorForeground));
this._domNode.style.border = `1px solid ${asCssVariable(contrastBorder)}`;
append(this._domNode, $('')).textContent = this.label;
this.onclick(this._domNode, e => this._onClick.fire());
override render() {
super.render();
this.editor.addOverlayWidget(this);
}
override dispose(): void {
this.editor.removeOverlayWidget(this);
super.dispose();
}
}
export class FloatingClickMenu extends Disposable implements IEditorContribution {
export class FloatingEditorClickMenu extends AbstractFloatingClickMenu implements IEditorContribution {
static readonly ID = 'editor.contrib.floatingClickMenu';
constructor(
editor: ICodeEditor,
@IInstantiationService instantiationService: IInstantiationService,
private readonly editor: ICodeEditor,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService
) {
super();
super(MenuId.EditorContent, menuService, contextKeyService);
this.render();
}
// DISABLED for embedded editors. In the future we can use a different MenuId for embedded editors
if (!(editor instanceof EmbeddedCodeEditorWidget)) {
const menu = menuService.createMenu(MenuId.EditorContent, contextKeyService);
const menuDisposables = new DisposableStore();
const renderMenuAsFloatingClickBtn = () => {
menuDisposables.clear();
if (!editor.hasModel() || editor.getOption(EditorOption.inDiffEditor)) {
return;
}
const actions: IAction[] = [];
createAndFillInActionBarActions(menu, { renderShortTitle: true, shouldForwardArgs: true }, actions);
if (actions.length === 0) {
return;
}
// todo@jrieken find a way to handle N actions, like showing a context menu
const [first] = actions;
const widget = instantiationService.createInstance(FloatingClickWidget, editor, first.label, first.id);
menuDisposables.add(widget);
menuDisposables.add(widget.onClick(() => first.run(editor.getModel().uri)));
widget.render();
};
this._store.add(menu);
this._store.add(menuDisposables);
this._store.add(menu.onDidChange(renderMenuAsFloatingClickBtn));
renderMenuAsFloatingClickBtn();
}
protected override createWidget(action: IAction): FloatingClickWidget {
return this.instantiationService.createInstance(FloatingEditorClickWidget, this.editor, action.label, action.id);
}
protected override isVisible() {
return !(this.editor instanceof EmbeddedCodeEditorWidget) && this.editor?.hasModel() && !this.editor.getOption(EditorOption.inDiffEditor);
}
protected override getActionArg(): unknown {
return this.editor.getModel()?.uri;
}
}

View file

@ -54,7 +54,7 @@ import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/co
import { ContextKeyExpr, ContextKeyExpression } from 'vs/platform/contextkey/common/contextkey';
import { isMacintosh } from 'vs/base/common/platform';
import { EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions';
import { FloatingClickMenu } from 'vs/workbench/browser/codeeditor';
import { FloatingEditorClickMenu } from 'vs/workbench/browser/codeeditor';
import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { EditorAutoSave } from 'vs/workbench/browser/parts/editor/editorAutoSave';
@ -131,7 +131,7 @@ Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).regi
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(UntitledTextEditorWorkingCopyEditorHandler, LifecyclePhase.Ready);
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(DynamicEditorConfigurations, LifecyclePhase.Ready);
registerEditorContribution(FloatingClickMenu.ID, FloatingClickMenu, EditorContributionInstantiation.AfterFirstRender);
registerEditorContribution(FloatingEditorClickMenu.ID, FloatingEditorClickMenu, EditorContributionInstantiation.AfterFirstRender);
//#endregion
//#region Quick Access

View file

@ -18,7 +18,7 @@ import { ContextKeyEqualsExpr, ContextKeyExpr } from 'vs/platform/contextkey/com
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
import { FloatingClickWidget } from 'vs/workbench/browser/codeeditor';
import { FloatingEditorClickWidget } from 'vs/workbench/browser/codeeditor';
import { AccessibilityVerbositySettingId } from 'vs/workbench/contrib/accessibility/browser/accessibilityConfiguration';
import { AccessibleViewType, IAccessibleViewService } from 'vs/workbench/contrib/accessibility/browser/accessibleView';
import { AccessibilityHelpAction } from 'vs/workbench/contrib/accessibility/browser/accessibleViewActions';
@ -47,7 +47,7 @@ class DiffEditorHelperContribution extends Disposable implements IDiffEditorCont
/** @description update state */
if (onlyWhiteSpaceChange.read(reader)) {
const helperWidget = store.add(this._instantiationService.createInstance(
FloatingClickWidget,
FloatingEditorClickWidget,
this._diffEditor.getModifiedEditor(),
localize('hintWhitespace', "Show Whitespace Differences"),
null

View file

@ -41,7 +41,7 @@ import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/c
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { registerColor } from 'vs/platform/theme/common/colorRegistry';
import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity';
import { FloatingClickWidget } from 'vs/workbench/browser/codeeditor';
import { FloatingEditorClickWidget } from 'vs/workbench/browser/codeeditor';
import { DebugHoverWidget, ShowDebugHoverResult } from 'vs/workbench/contrib/debug/browser/debugHover';
import { ExceptionWidget } from 'vs/workbench/contrib/debug/browser/exceptionWidget';
import { CONTEXT_EXCEPTION_WIDGET_VISIBLE, IDebugConfiguration, IDebugEditorContribution, IDebugService, IDebugSession, IExceptionInfo, IExpression, IStackFrame, State } from 'vs/workbench/contrib/debug/common/debug';
@ -219,7 +219,7 @@ export class DebugEditorContribution implements IDebugEditorContribution {
private gutterIsHovered = false;
private exceptionWidget: ExceptionWidget | undefined;
private configurationWidget: FloatingClickWidget | undefined;
private configurationWidget: FloatingEditorClickWidget | undefined;
private altListener: IDisposable | undefined;
private altPressed = false;
private oldDecorations = this.editor.createDecorationsCollection();

View file

@ -87,7 +87,7 @@ import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions';
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
import { NotebookPerfMarks } from 'vs/workbench/contrib/notebook/common/notebookPerformance';
import { BaseCellEditorOptions } from 'vs/workbench/contrib/notebook/browser/viewModel/cellEditorOptions';
import { FloatingClickMenu } from 'vs/workbench/browser/codeeditor';
import { FloatingEditorClickMenu } from 'vs/workbench/browser/codeeditor';
import { IDimension } from 'vs/editor/common/core/dimension';
import { CellFindMatchModel } from 'vs/workbench/contrib/notebook/browser/contrib/find/findModel';
import { INotebookLoggingService } from 'vs/workbench/contrib/notebook/common/notebookLoggingService';
@ -107,7 +107,7 @@ export function getDefaultNotebookCreationOptions(): INotebookEditorCreationOpti
// We inlined the id to avoid loading comment contrib in tests
const skipContributions = [
'editor.contrib.review',
FloatingClickMenu.ID,
FloatingEditorClickMenu.ID,
'editor.contrib.dirtydiff',
'editor.contrib.testingOutputPeek',
'editor.contrib.testingDecorations',

View file

@ -199,6 +199,12 @@
overflow: hidden;
}
.test-output-peek-message-container .floating-click-widget {
position: absolute;
right: 20px;
bottom: 10px;
}
.test-output-peek-message-container,
.test-output-peek-tree {
height: 100%;

View file

@ -27,6 +27,7 @@ import { Iterable } from 'vs/base/common/iterator';
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
import { Lazy } from 'vs/base/common/lazy';
import { Disposable, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { MarshalledId } from 'vs/base/common/marshallingIds';
import { count } from 'vs/base/common/strings';
import { ThemeIcon } from 'vs/base/common/themables';
import { isDefined } from 'vs/base/common/types';
@ -48,6 +49,7 @@ import { MarkdownRenderer } from 'vs/editor/contrib/markdownRenderer/browser/mar
import { IPeekViewService, PeekViewWidget, peekViewResultsBackground, peekViewTitleForeground, peekViewTitleInfoForeground } from 'vs/editor/contrib/peekView/browser/peekView';
import { localize } from 'vs/nls';
import { Categories } from 'vs/platform/action/common/actionCommonCategories';
import { FloatingClickMenu } from 'vs/platform/actions/browser/floatingMenu';
import { MenuEntryActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
import { Action2, IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions';
import { ICommandService } from 'vs/platform/commands/common/commands';
@ -88,7 +90,7 @@ import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testPro
import { ITaskRawOutput, ITestResult, ITestRunTaskResults, LiveTestResult, TestResultItemChange, TestResultItemChangeReason, maxCountPriority, resultItemParents } from 'vs/workbench/contrib/testing/common/testResult';
import { ITestResultService, ResultChangeEvent } from 'vs/workbench/contrib/testing/common/testResultService';
import { ITestService } from 'vs/workbench/contrib/testing/common/testService';
import { IRichLocation, ITestErrorMessage, ITestItem, ITestMessage, ITestRunTask, ITestTaskState, TestMessageType, TestResultItem, TestResultState, TestRunProfileBitset, getMarkId } from 'vs/workbench/contrib/testing/common/testTypes';
import { IRichLocation, ITestErrorMessage, ITestItem, ITestMessage, ITestMessageMenuArgs, ITestRunTask, ITestTaskState, TestMessageType, TestResultItem, TestResultState, TestRunProfileBitset, getMarkId } from 'vs/workbench/contrib/testing/common/testTypes';
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
import { IShowResultOptions, ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener';
import { cmpPriority, isFailedState } from 'vs/workbench/contrib/testing/common/testingStates';
@ -98,20 +100,30 @@ import { IEditorService } from 'vs/workbench/services/editor/common/editorServic
class MessageSubject {
public readonly test: ITestItem;
public readonly message: ITestMessage;
public readonly messages: ITestMessage[];
public readonly expectedUri: URI;
public readonly actualUri: URI;
public readonly messageUri: URI;
public readonly revealLocation: IRichLocation | undefined;
public get isDiffable() {
const message = this.messages[this.messageIndex];
return message.type === TestMessageType.Error && isDiffable(message);
return this.message.type === TestMessageType.Error && isDiffable(this.message);
}
public get contextValue() {
return this.message.type === TestMessageType.Error ? this.message.contextValue : undefined;
}
public get context(): ITestMessageMenuArgs {
return {
$mid: MarshalledId.TestMessageMenuArgs,
extId: this.test.extId,
message: ITestMessage.serialize(this.message),
};
}
constructor(public readonly resultId: string, test: TestResultItem, public readonly taskIndex: number, public readonly messageIndex: number) {
this.test = test.item;
this.messages = test.tasks[taskIndex].messages;
const messages = test.tasks[taskIndex].messages;
this.messageIndex = messageIndex;
const parts = { messageIndex, resultId, taskIndex, testExtId: test.item.extId };
@ -119,7 +131,7 @@ class MessageSubject {
this.actualUri = buildTestUri({ ...parts, type: TestUriType.ResultActualOutput });
this.messageUri = buildTestUri({ ...parts, type: TestUriType.ResultMessage });
const message = this.message = this.messages[this.messageIndex];
const message = this.message = messages[this.messageIndex];
this.revealLocation = message.location ?? (test.item.uri && test.item.range ? { uri: test.item.uri, range: Range.lift(test.item.range) } : undefined);
}
}
@ -148,7 +160,7 @@ type InspectSubject = MessageSubject | TaskSubject | TestOutputSubject;
const equalsSubject = (a: InspectSubject, b: InspectSubject) =>
a.resultId === b.resultId && a.taskIndex === b.taskIndex && (
(a instanceof MessageSubject && b instanceof MessageSubject && a.messageIndex === b.messageIndex) ||
(a instanceof MessageSubject && b instanceof MessageSubject && a.message === b.message) ||
(a instanceof TaskSubject && b instanceof TaskSubject) ||
(a instanceof TestOutputSubject && b instanceof TestOutputSubject && a.test === b.test)
);
@ -746,8 +758,10 @@ class TestResultsViewContent extends Disposable {
private static lastSplitWidth?: number;
private readonly didReveal = this._register(new Emitter<{ subject: InspectSubject; preserveFocus: boolean }>());
private readonly clickMenu = this._register(new MutableDisposable<FloatingClickMenu>());
private dimension?: dom.Dimension;
private splitView!: SplitView;
private messageContainer!: HTMLElement;
private contentProviders!: IPeekOutputRenderer[];
private contentProvidersUpdateLimiter = this._register(new Limiter(1));
@ -764,6 +778,7 @@ class TestResultsViewContent extends Disposable {
},
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ITextModelService protected readonly modelService: ITextModelService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
) {
super();
}
@ -774,7 +789,7 @@ class TestResultsViewContent extends Disposable {
const { historyVisible, showRevealLocationOnMessages } = this.options;
const isInPeekView = this.editor !== undefined;
const messageContainer = dom.append(containerElement, dom.$('.test-output-peek-message-container'));
const messageContainer = this.messageContainer = dom.append(containerElement, dom.$('.test-output-peek-message-container'));
this.contentProviders = [
this._register(this.instantiationService.createInstance(DiffContentProvider, this.editor, messageContainer)),
this._register(this.instantiationService.createInstance(MarkdownTestMessagePeek, messageContainer)),
@ -834,14 +849,30 @@ class TestResultsViewContent extends Disposable {
* Shows a message in-place without showing or changing the peek location.
* This is mostly used if peeking a message without a location.
*/
public async reveal(opts: { subject: InspectSubject; preserveFocus: boolean }) {
public reveal(opts: { subject: InspectSubject; preserveFocus: boolean }) {
this.didReveal.fire(opts);
if (!this.current || !equalsSubject(this.current, opts.subject)) {
this.current = opts.subject;
await this.contentProvidersUpdateLimiter.queue(() => Promise.all(
this.contentProviders.map(p => p.update(opts.subject))));
if (this.current && equalsSubject(this.current, opts.subject)) {
return Promise.resolve();
}
this.current = opts.subject;
return this.contentProvidersUpdateLimiter.queue(async () => {
await Promise.all(this.contentProviders.map(p => p.update(opts.subject)));
if (opts.subject instanceof MessageSubject) {
const contextOverlay = this.contextKeyService.createOverlay([[TestingContextKeys.testMessageContext.key, opts.subject.contextValue]]);
this.clickMenu.value = this.instantiationService
.createChild(new ServiceCollection([IContextKeyService, contextOverlay]))
.createInstance(FloatingClickMenu, {
container: this.messageContainer,
menuId: MenuId.TestMessageContent,
getActionArg: () => (opts.subject as MessageSubject).context,
});
} else {
this.clickMenu.clear();
}
});
}
public onLayoutBody(height: number, width: number) {
@ -1507,11 +1538,15 @@ class TerminalMessagePeek extends Disposable implements IPeekOutputRenderer {
}
}
const hintMessagePeekHeight = (msg: ITestMessage) =>
isDiffable(msg)
const hintMessagePeekHeight = (msg: ITestMessage) => {
const msgHeight = isDiffable(msg)
? Math.max(hintPeekStrHeight(msg.actual), hintPeekStrHeight(msg.expected))
: hintPeekStrHeight(typeof msg.message === 'string' ? msg.message : msg.message.value);
// add 8ish lines for the size of the title and decorations in the peek.
return msgHeight + 8;
};
const firstLine = (str: string) => {
const index = str.indexOf('\n');
return index === -1 ? str : str.slice(0, index);
@ -1519,8 +1554,7 @@ const firstLine = (str: string) => {
const isMultiline = (str: string | undefined) => !!str && str.includes('\n');
// add 5ish lines for the size of the title and decorations in the peek.
const hintPeekStrHeight = (str: string) => Math.min(count(str, '\n') + 5, 24);
const hintPeekStrHeight = (str: string) => Math.min(count(str, '\n'), 24);
class SimpleDiffEditorModel extends EditorModel {
public readonly original = this._original.object.textEditorModel;
@ -1671,13 +1705,23 @@ class TaskElement implements ITreeElement {
class TestMessageElement implements ITreeElement {
public readonly type = 'message';
public readonly context: URI;
public readonly id: string;
public readonly label: string;
public readonly uri: URI;
public readonly location?: IRichLocation;
public readonly description?: string;
public readonly onDidChange = Event.None;
public readonly contextValue?: string;
public readonly message: ITestMessage;
public get context(): ITestMessageMenuArgs {
return {
$mid: MarshalledId.TestMessageMenuArgs,
extId: this.test.item.extId,
message: ITestMessage.serialize(this.message),
};
}
constructor(
public readonly result: ITestResult,
@ -1685,10 +1729,11 @@ class TestMessageElement implements ITreeElement {
public readonly taskIndex: number,
public readonly messageIndex: number,
) {
const m = test.tasks[taskIndex].messages[messageIndex];
const m = this.message = test.tasks[taskIndex].messages[messageIndex];
this.location = m.location;
this.uri = this.context = buildTestUri({
this.contextValue = m.type === TestMessageType.Error ? m.contextValue : undefined;
this.uri = buildTestUri({
type: TestUriType.ResultMessage,
messageIndex,
resultId: result.id,
@ -2084,7 +2129,7 @@ class TestRunElementRenderer implements ICompressibleTreeRenderer<ITreeElement,
const actions = this.treeActions.provideActionBar(element);
templateData.actionBar.clear();
templateData.actionBar.context = element;
templateData.actionBar.context = element.context;
templateData.actionBar.push(actions.primary, { icon: true, label: false });
}
}
@ -2103,130 +2148,134 @@ class TreeActionsProvider {
public provideActionBar(element: ITreeElement) {
const test = element instanceof TestCaseElement ? element.test : undefined;
const capabilities = test ? this.testProfileService.capabilitiesForTest(test) : 0;
const contextOverlay = this.contextKeyService.createOverlay([
const contextKeys: [string, unknown][] = [
['peek', Testing.OutputPeekContributionId],
[TestingContextKeys.peekItemType.key, element.type],
...getTestItemContextOverlay(test, capabilities),
]);
const menu = this.menuService.createMenu(MenuId.TestPeekElement, contextOverlay);
];
try {
const primary: IAction[] = [];
const secondary: IAction[] = [];
let id = MenuId.TestPeekElement;
const primary: IAction[] = [];
const secondary: IAction[] = [];
if (element instanceof TaskElement) {
if (element instanceof TaskElement) {
primary.push(new Action(
'testing.outputPeek.showResultOutput',
localize('testing.showResultOutput', "Show Result Output"),
ThemeIcon.asClassName(Codicon.terminal),
undefined,
() => this.requestReveal.fire(new TaskSubject(element.results.id, element.index)),
));
}
if (element instanceof TestResultElement) {
// only show if there are no collapsed test nodes that have more specific choices
if (element.value.tasks.length === 1) {
primary.push(new Action(
'testing.outputPeek.showResultOutput',
localize('testing.showResultOutput', "Show Result Output"),
ThemeIcon.asClassName(Codicon.terminal),
undefined,
() => this.requestReveal.fire(new TaskSubject(element.results.id, element.index)),
() => this.requestReveal.fire(new TaskSubject(element.value.id, 0)),
));
}
if (element instanceof TestResultElement) {
// only show if there are no collapsed test nodes that have more specific choices
if (element.value.tasks.length === 1) {
primary.push(new Action(
'testing.outputPeek.showResultOutput',
localize('testing.showResultOutput', "Show Result Output"),
ThemeIcon.asClassName(Codicon.terminal),
undefined,
() => this.requestReveal.fire(new TaskSubject(element.value.id, 0)),
));
}
primary.push(new Action(
'testing.outputPeek.reRunLastRun',
localize('testing.reRunLastRun', "Rerun Test Run"),
ThemeIcon.asClassName(icons.testingRunIcon),
undefined,
() => this.commandService.executeCommand('testing.reRunLastRun', element.value.id),
));
if (capabilities & TestRunProfileBitset.Debug) {
primary.push(new Action(
'testing.outputPeek.reRunLastRun',
localize('testing.reRunLastRun', "Rerun Test Run"),
'testing.outputPeek.debugLastRun',
localize('testing.debugLastRun', "Debug Test Run"),
ThemeIcon.asClassName(icons.testingDebugIcon),
undefined,
() => this.commandService.executeCommand('testing.debugLastRun', element.value.id),
));
}
}
if (element instanceof TestCaseElement) {
const extId = element.test.item.extId;
contextKeys.push(...getTestItemContextOverlay(element.test, capabilities));
primary.push(new Action(
'testing.outputPeek.goToFile',
localize('testing.goToFile', "Go to Source"),
ThemeIcon.asClassName(Codicon.goToFile),
undefined,
() => this.commandService.executeCommand('vscode.revealTest', extId),
));
if (element.test.tasks[element.taskIndex].messages.some(m => m.type === TestMessageType.Output)) {
primary.push(new Action(
'testing.outputPeek.showResultOutput',
localize('testing.showResultOutput', "Show Result Output"),
ThemeIcon.asClassName(Codicon.terminal),
undefined,
() => this.requestReveal.fire(element.outputSubject),
));
}
secondary.push(new Action(
'testing.outputPeek.revealInExplorer',
localize('testing.revealInExplorer', "Reveal in Test Explorer"),
ThemeIcon.asClassName(Codicon.listTree),
undefined,
() => this.commandService.executeCommand('_revealTestInExplorer', extId),
));
if (capabilities & TestRunProfileBitset.Run) {
primary.push(new Action(
'testing.outputPeek.runTest',
localize('run test', 'Run Test'),
ThemeIcon.asClassName(icons.testingRunIcon),
undefined,
() => this.commandService.executeCommand('testing.reRunLastRun', element.value.id),
() => this.commandService.executeCommand('vscode.runTestsById', TestRunProfileBitset.Run, extId),
));
if (capabilities & TestRunProfileBitset.Debug) {
primary.push(new Action(
'testing.outputPeek.debugLastRun',
localize('testing.debugLastRun', "Debug Test Run"),
ThemeIcon.asClassName(icons.testingDebugIcon),
undefined,
() => this.commandService.executeCommand('testing.debugLastRun', element.value.id),
));
}
}
if (element instanceof TestCaseElement) {
const extId = element.test.item.extId;
if (capabilities & TestRunProfileBitset.Debug) {
primary.push(new Action(
'testing.outputPeek.goToFile',
localize('testing.goToFile', "Go to Source"),
'testing.outputPeek.debugTest',
localize('debug test', 'Debug Test'),
ThemeIcon.asClassName(icons.testingDebugIcon),
undefined,
() => this.commandService.executeCommand('vscode.runTestsById', TestRunProfileBitset.Debug, extId),
));
}
}
if (element instanceof TestMessageElement) {
id = MenuId.TestMessageContext;
contextKeys.push([TestingContextKeys.testMessageContext.key, element.contextValue]);
if (this.showRevealLocationOnMessages && element.location) {
primary.push(new Action(
'testing.outputPeek.goToError',
localize('testing.goToError', "Go to Source"),
ThemeIcon.asClassName(Codicon.goToFile),
undefined,
() => this.commandService.executeCommand('vscode.revealTest', extId),
() => this.editorService.openEditor({
resource: element.location!.uri,
options: {
selection: element.location!.range,
preserveFocus: true,
}
}),
));
if (element.test.tasks[element.taskIndex].messages.some(m => m.type === TestMessageType.Output)) {
primary.push(new Action(
'testing.outputPeek.showResultOutput',
localize('testing.showResultOutput', "Show Result Output"),
ThemeIcon.asClassName(Codicon.terminal),
undefined,
() => this.requestReveal.fire(element.outputSubject),
));
}
secondary.push(new Action(
'testing.outputPeek.revealInExplorer',
localize('testing.revealInExplorer', "Reveal in Test Explorer"),
ThemeIcon.asClassName(Codicon.listTree),
undefined,
() => this.commandService.executeCommand('_revealTestInExplorer', extId),
));
if (capabilities & TestRunProfileBitset.Run) {
primary.push(new Action(
'testing.outputPeek.runTest',
localize('run test', 'Run Test'),
ThemeIcon.asClassName(icons.testingRunIcon),
undefined,
() => this.commandService.executeCommand('vscode.runTestsById', TestRunProfileBitset.Run, extId),
));
}
if (capabilities & TestRunProfileBitset.Debug) {
primary.push(new Action(
'testing.outputPeek.debugTest',
localize('debug test', 'Debug Test'),
ThemeIcon.asClassName(icons.testingDebugIcon),
undefined,
() => this.commandService.executeCommand('vscode.runTestsById', TestRunProfileBitset.Debug, extId),
));
}
}
}
if (element instanceof TestMessageElement) {
if (this.showRevealLocationOnMessages && element.location) {
primary.push(new Action(
'testing.outputPeek.goToError',
localize('testing.goToError', "Go to Source"),
ThemeIcon.asClassName(Codicon.goToFile),
undefined,
() => this.editorService.openEditor({
resource: element.location!.uri,
options: {
selection: element.location!.range,
preserveFocus: true,
}
}),
));
}
}
const result = { primary, secondary };
createAndFillInActionBarActions(menu, {
shouldForwardArgs: true,
}, result, 'inline');
const contextOverlay = this.contextKeyService.createOverlay(contextKeys);
const result = { primary, secondary };
const menu = this.menuService.createMenu(id, contextOverlay);
try {
createAndFillInActionBarActions(menu, { arg: element.context }, result, 'inline');
return result;
} finally {
menu.dispose();

View file

@ -156,6 +156,7 @@ export interface ITestErrorMessage {
type: TestMessageType.Error;
expected: string | undefined;
actual: string | undefined;
contextValue: string | undefined;
location: IRichLocation | undefined;
}
@ -165,6 +166,7 @@ export namespace ITestErrorMessage {
type: TestMessageType.Error;
expected: string | undefined;
actual: string | undefined;
contextValue: string | undefined;
location: IRichLocation.Serialize | undefined;
}
@ -173,6 +175,7 @@ export namespace ITestErrorMessage {
type: TestMessageType.Error,
expected: message.expected,
actual: message.actual,
contextValue: message.contextValue,
location: message.location && IRichLocation.serialize(message.location),
});
@ -181,6 +184,7 @@ export namespace ITestErrorMessage {
type: TestMessageType.Error,
expected: message.expected,
actual: message.actual,
contextValue: message.contextValue,
location: message.location && IRichLocation.deserialize(message.location),
});
}
@ -632,6 +636,18 @@ export interface ITestItemContext {
tests: InternalTestItem.Serialized[];
}
/**
* Context for actions taken in the test explorer view.
*/
export interface ITestMessageMenuArgs {
/** Marshalling marker */
$mid: MarshalledId.TestMessageMenuArgs;
/** Tests ext ID */
extId: string;
/** Serialized test message */
message: ITestMessage.Serialized;
}
/**
* Request from the ext host or main thread to indicate that tests have
* changed. It's assumed that any item upserted *must* have its children

View file

@ -58,4 +58,8 @@ export namespace TestingContextKeys {
type: 'boolean',
description: localize('testing.testItemIsHidden', 'Boolean indicating whether the test item is hidden')
});
export const testMessageContext = new RawContextKey<string>('testMessage', undefined, {
type: 'boolean',
description: localize('testing.testMessage', 'Value set in `testMessage.contextValue`, available in editor/content and testing/message/context')
});
}

View file

@ -260,6 +260,16 @@ const apiMenus: IAPIMenu[] = [
id: MenuId.TestItemGutter,
description: localize('testing.item.gutter.title', "The menu for a gutter decoration for a test item"),
},
{
key: 'testing/message/context',
id: MenuId.TestMessageContext,
description: localize('testing.message.context.title', "A prominent button overlaying editor content where the message is displayed"),
},
{
key: 'testing/message/content',
id: MenuId.TestMessageContent,
description: localize('testing.message.content.title', "Context menu for the message in the results tree"),
},
{
key: 'extension/context',
id: MenuId.ExtensionContext,

View file

@ -90,6 +90,7 @@ export const allApiProposals = Object.freeze({
terminalQuickFixProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalQuickFixProvider.d.ts',
terminalSelection: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalSelection.d.ts',
testCoverage: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testCoverage.d.ts',
testMessageContextValue: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testMessageContextValue.d.ts',
testObserver: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testObserver.d.ts',
textSearchProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textSearchProvider.d.ts',
timeline: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.timeline.d.ts',

View file

@ -0,0 +1,45 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
declare module 'vscode' {
// https://github.com/microsoft/vscode/issues/190277
export class TestMessage2 extends TestMessage {
/**
* Context value of the test item. This can be used to contribute message-
* specific actions to the test peek view. The value set here can be found
* in the `testMessage` property of the following `menus` contribution points:
*
* - `testing/message/context` - context menu for the message in the results tree
* - `testing/message/content` - a prominent button overlaying editor content where
* the message is displayed.
*
* For example:
*
* ```json
* "contributes": {
* "menus": {
* "testing/message/content": [
* {
* "command": "extension.deleteCommentThread",
* "when": "testMessage == canApplyRichDiff"
* }
* ]
* }
* }
* ```
*
* The command will be called with an object containing:
* - `test`: the {@link TestItem} the message is associated with, *if* it
* is still present in the {@link TestController.items} collection.
* - `message`: the {@link TestMessage} instance.
*/
contextValue?: string;
// ...
}
}