diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index aed0d546ce2..5ee95741ff0 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -40,6 +40,7 @@ "envShellEvent", "testCoverage", "testObserver", + "testMessageContextValue", "textSearchProvider", "timeline", "tokenInformation", diff --git a/src/vs/base/common/marshallingIds.ts b/src/vs/base/common/marshallingIds.ts index abd7698ed92..ae02b8901fc 100644 --- a/src/vs/base/common/marshallingIds.ts +++ b/src/vs/base/common/marshallingIds.ts @@ -20,5 +20,6 @@ export const enum MarshalledId { NotebookCellActionContext, NotebookActionContext, TestItemContext, - Date + Date, + TestMessageMenuArgs, } diff --git a/src/vs/platform/actions/browser/floatingMenu.ts b/src/vs/platform/actions/browser/floatingMenu.ts new file mode 100644 index 00000000000..e6840b10e10 --- /dev/null +++ b/src/vs/platform/actions/browser/floatingMenu.ts @@ -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()); + 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(); + 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(); + } +} diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 78ad2e70c2e..d1f216f0971 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -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'); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 7aee01902ab..233f094a801 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -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, diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 4227806cb8d..94af4f6b06e 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -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; collection: ExtHostTestItemCollection; + extension: Readonly; } 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, + 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, 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; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 80aeaa6d1f3..37868738d64 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -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; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 98e43702685..723e2e0c500 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -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); diff --git a/src/vs/workbench/api/test/browser/extHostTesting.test.ts b/src/vs/workbench/api/test/browser/extHostTesting.test.ts index 92970a9a9cd..085bf16d5ab 100644 --- a/src/vs/workbench/api/test/browser/extHostTesting.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTesting.test.ts @@ -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()(); @@ -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); diff --git a/src/vs/workbench/browser/codeeditor.ts b/src/vs/workbench/browser/codeeditor.ts index 55f1173bc48..33358b10156 100644 --- a/src/vs/workbench/browser/codeeditor.ts +++ b/src/vs/workbench/browser/codeeditor.ts @@ -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()); - 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; } } diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 947557d30f4..a07e77fc9fd 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -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(WorkbenchExtensions.Workbench).regi Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(UntitledTextEditorWorkingCopyEditorHandler, LifecyclePhase.Ready); Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(DynamicEditorConfigurations, LifecyclePhase.Ready); -registerEditorContribution(FloatingClickMenu.ID, FloatingClickMenu, EditorContributionInstantiation.AfterFirstRender); +registerEditorContribution(FloatingEditorClickMenu.ID, FloatingEditorClickMenu, EditorContributionInstantiation.AfterFirstRender); //#endregion //#region Quick Access diff --git a/src/vs/workbench/contrib/codeEditor/browser/diffEditorHelper.ts b/src/vs/workbench/contrib/codeEditor/browser/diffEditorHelper.ts index 26754add9a0..44ba3928008 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/diffEditorHelper.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/diffEditorHelper.ts @@ -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 diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts index 56470d34233..06c4fa63988 100644 --- a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts @@ -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(); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 4ccc2ee5de9..c418b358192 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -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', diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index 479c6ef2e89..060e138c348 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -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%; diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index d0d76ea7e8c..5043900e175 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -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()); 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 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(); diff --git a/src/vs/workbench/contrib/testing/common/testTypes.ts b/src/vs/workbench/contrib/testing/common/testTypes.ts index 99590868fd0..0b035192993 100644 --- a/src/vs/workbench/contrib/testing/common/testTypes.ts +++ b/src/vs/workbench/contrib/testing/common/testTypes.ts @@ -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 diff --git a/src/vs/workbench/contrib/testing/common/testingContextKeys.ts b/src/vs/workbench/contrib/testing/common/testingContextKeys.ts index 05e5536590e..fd8c90d0ce6 100644 --- a/src/vs/workbench/contrib/testing/common/testingContextKeys.ts +++ b/src/vs/workbench/contrib/testing/common/testingContextKeys.ts @@ -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('testMessage', undefined, { + type: 'boolean', + description: localize('testing.testMessage', 'Value set in `testMessage.contextValue`, available in editor/content and testing/message/context') + }); } diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index a6f9d4cb606..e2fb27d6d8b 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -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, diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 5c4ca2842b0..8f6f09ae49d 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -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', diff --git a/src/vscode-dts/vscode.proposed.testMessageContextValue.d.ts b/src/vscode-dts/vscode.proposed.testMessageContextValue.d.ts new file mode 100644 index 00000000000..515a99d0e2b --- /dev/null +++ b/src/vscode-dts/vscode.proposed.testMessageContextValue.d.ts @@ -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; + + // ... + } +}