diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index 70cb13387c5..6b039d517e6 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -46,6 +46,13 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh this.testService.unregisterTestController(id); } + /** + * @inheritdoc + */ + $updateDiscoveringCount(resource: ExtHostTestingResource, uriComponents: UriComponents, delta: number): void { + this.testService.updateDiscoveringCount(resource, URI.revive(uriComponents), delta); + } + /** * @inheritdoc */ diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 106324b08ad..99518bee8ab 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1800,6 +1800,7 @@ export interface MainThreadTestingShape { $unsubscribeFromDiffs(resource: ExtHostTestingResource, uri: UriComponents): void; $publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void; $runTests(req: RunTestsRequest, token: CancellationToken): Promise; + $updateDiscoveringCount(resource: ExtHostTestingResource, uri: UriComponents, delta: number): void; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index ceb1044b9c3..f03587bf5d1 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { mapFind } from 'vs/base/common/arrays'; -import { disposableTimeout } from 'vs/base/common/async'; +import { disposableTimeout, RunOnceScheduler } from 'vs/base/common/async'; import { CancellationToken } from 'vs/base/common/cancellation'; import { throttle } from 'vs/base/common/decorators'; import { Emitter } from 'vs/base/common/event'; @@ -125,6 +125,19 @@ export class ExtHostTesting implements ExtHostTestingShape { return; } + let delta = 0; + const updateCountScheduler = new RunOnceScheduler(() => { + if (delta !== 0) { + this.proxy.$updateDiscoveringCount(resource, uri, delta); + delta = 0; + } + }, 5); + + const updateDelta = (amount: number) => { + delta += amount; + updateCountScheduler.schedule(); + }; + const subscribeFn = (id: string, provider: vscode.TestProvider) => { try { const hierarchy = method!(provider); @@ -132,8 +145,10 @@ export class ExtHostTesting implements ExtHostTestingShape { return; } + updateDelta(1); disposable.add(hierarchy); collection.addRoot(hierarchy.root, id); + hierarchy.onDidDiscoverInitialTests(() => updateDelta(-1)); hierarchy.onDidChangeTest(e => collection.onItemChange(e, id)); } catch (e) { console.error(e); @@ -269,6 +284,13 @@ export class SingleUseTestCollection implements IDisposable { protected diff: TestsDiff = []; private disposed = false; + /** + * Debouncer for sending diffs. We use both a throttle and a debounce here, + * so that tests that all change state simultenously are effected together, + * but so we don't send hundreds of test updates per second to the main thread. + */ + private readonly debounceSendDiff = new RunOnceScheduler(() => this.throttleSendDiff(), 2); + constructor(private readonly testIdToInternal: Map, private readonly publishDiff: (diff: TestsDiff) => void) { } /** @@ -276,7 +298,7 @@ export class SingleUseTestCollection implements IDisposable { */ public addRoot(item: vscode.TestItem, providerId: string) { this.addItem(item, providerId, null); - this.throttleSendDiff(); + this.debounceSendDiff.schedule(); } /** @@ -300,7 +322,7 @@ export class SingleUseTestCollection implements IDisposable { } this.addItem(item, providerId, existing.parent); - this.throttleSendDiff(); + this.debounceSendDiff.schedule(); } /** diff --git a/src/vs/workbench/contrib/testing/browser/icons.ts b/src/vs/workbench/contrib/testing/browser/icons.ts index bb16931126a..b99b8a68340 100644 --- a/src/vs/workbench/contrib/testing/browser/icons.ts +++ b/src/vs/workbench/contrib/testing/browser/icons.ts @@ -23,7 +23,7 @@ export const testingStatesToIcons = new Map([ [TestRunState.Failed, registerIcon('testing-failed-icon', Codicon.close, localize('testingFailedIcon', 'Icon shown for tests that failed.'))], [TestRunState.Passed, registerIcon('testing-passed-icon', Codicon.pass, localize('testingPassedIcon', 'Icon shown for tests that passed.'))], [TestRunState.Queued, registerIcon('testing-queued-icon', Codicon.watch, localize('testingQueuedIcon', 'Icon shown for tests that are queued.'))], - [TestRunState.Running, registerIcon('testing-loading-icon', Codicon.loading, localize('testingLoadingIcon', 'Icon shown for tests that are loading.'))], + [TestRunState.Running, Codicon.loading], [TestRunState.Skipped, registerIcon('testing-skipped-icon', Codicon.debugStepOver, localize('testingSkippedIcon', 'Icon shown for tests that are skipped.'))], [TestRunState.Unset, registerIcon('testing-unset-icon', Codicon.circleOutline, localize('testingUnsetIcon', 'Icon shown for tests that are in an unset state.'))], ]); diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index a8fe0e3797a..32d89bdce01 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -40,3 +40,8 @@ line-height: 22px; margin-right: 8px; } + +.codicon-testing-loading-icon::before { + /* Use steps to throttle FPS to reduce CPU usage */ + animation: codicon-spin 1.25s steps(30) infinite; +} diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index ce9104081a3..ec6e3217212 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -28,10 +28,12 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { WorkbenchObjectTree } from 'vs/platform/list/browser/listService'; import { IOpenerService } from 'vs/platform/opener/common/opener'; +import { IProgressService } from 'vs/platform/progress/common/progress'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IThemeService, ThemeIcon } from 'vs/platform/theme/common/themeService'; import { IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; +import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; import { IResourceLabel, IResourceLabelOptions, IResourceLabelProps, ResourceLabels } from 'vs/workbench/browser/labels'; import { ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; @@ -51,11 +53,13 @@ export class TestingExplorerView extends ViewPane { public viewModel!: TestingExplorerViewModel; private currentSubscription?: TestSubscriptionListener; private listContainer!: HTMLElement; + private finishDiscovery?: () => void; constructor( options: IViewletViewOptions, @ITestingCollectionService private readonly testCollection: ITestingCollectionService, @ITestService private readonly testService: ITestService, + @IProgressService private readonly progress: IProgressService, @IContextMenuService contextMenuService: IContextMenuService, @IKeybindingService keybindingService: IKeybindingService, @IConfigurationService configurationService: IConfigurationService, @@ -87,6 +91,15 @@ export class TestingExplorerView extends ViewPane { this.viewModel = this.instantiationService.createInstance(TestingExplorerViewModel, this.listContainer, this.onDidChangeBodyVisibility, this.currentSubscription); this._register(this.viewModel); + this.updateProgressIndicator(); + this._register(this.testService.onBusyStateChange(t => { + if (t.resource === ExtHostTestingResource.Workspace && t.busy !== (!!this.finishDiscovery)) { + this.updateProgressIndicator(); + } + })); + + this.getProgressIndicator().show(true); + this._register(this.onDidChangeBodyVisibility(visible => { if (!visible && this.currentSubscription) { this.currentSubscription.dispose(); @@ -99,6 +112,16 @@ export class TestingExplorerView extends ViewPane { })); } + private updateProgressIndicator() { + const busy = Iterable.some(this.testService.busyTestLocations, s => s.resource === ExtHostTestingResource.Workspace); + if (!busy && this.finishDiscovery) { + this.finishDiscovery(); + this.finishDiscovery = undefined; + } else if (busy && !this.finishDiscovery) { + const promise = new Promise(resolve => { this.finishDiscovery = resolve; }); + this.progress.withProgress({ location: this.getProgressLocation() }, () => promise); + } + } /** * @override @@ -401,8 +424,12 @@ class TestsRenderer implements ITreeRenderer; readonly onTestRunCompleted: Event<{ req: RunTestsRequest, result: RunTestsResult }>; + /** + * List of resources where tests are actively being discovered. + */ + readonly busyTestLocations: Iterable<{ resource: ExtHostTestingResource, uri: URI }>; + + /** + * Fires when the busy state of a resource changes. + */ + readonly onBusyStateChange: Event<{ resource: ExtHostTestingResource, uri: URI, busy: boolean }>; + registerTestController(id: string, controller: MainTestController): void; unregisterTestController(id: string): void; runTests(req: RunTestsRequest, token?: CancellationToken): Promise; cancelTestRun(req: RunTestsRequest): void; publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void; subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff: TestDiffListener): IDisposable; + + /** + * Updates the number of test providers still discovering tests for the given resource. + */ + updateDiscoveringCount(resource: ExtHostTestingResource, uri: URI, delta: number): void; } diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 510e90eca95..d2c76cc5e9d 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -6,6 +6,7 @@ import { groupBy } from 'vs/base/common/arrays'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; +import { Iterable } from 'vs/base/common/iterator'; import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { isDefined } from 'vs/base/common/types'; import { URI, UriComponents } from 'vs/base/common/uri'; @@ -17,18 +18,22 @@ import { AbstractIncrementalTestCollection, collectTestResults, EMPTY_TEST_RESUL import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestService, MainTestController, TestDiffListener } from 'vs/workbench/contrib/testing/common/testService'; +type TestLocationIdent = { resource: ExtHostTestingResource, uri: URI }; + export class TestService extends Disposable implements ITestService { declare readonly _serviceBrand: undefined; private testControllers = new Map(); private readonly testSubscriptions = new Map; listeners: number; }>(); - private readonly subscribeEmitter = new Emitter<{ resource: ExtHostTestingResource, uri: URI }>(); - private readonly unsubscribeEmitter = new Emitter<{ resource: ExtHostTestingResource, uri: URI }>(); + private readonly subscribeEmitter = new Emitter(); + private readonly unsubscribeEmitter = new Emitter(); + private readonly busyStateChangeEmitter = new Emitter(); private readonly changeProvidersEmitter = new Emitter<{ delta: number }>(); private readonly providerCount: IContextKey; private readonly isRunning: IContextKey; @@ -74,6 +79,11 @@ export class TestService extends Disposable implements ITestService { */ public readonly onDidChangeProviders = this.changeProvidersEmitter.event; + /** + * @inheritdoc + */ + public readonly onBusyStateChange = this.busyStateChangeEmitter.event; + /** * @inheritdoc */ @@ -88,6 +98,13 @@ export class TestService extends Disposable implements ITestService { this.runningTests.get(req)?.cancel(); } + /** + * @inheritdoc + */ + public get busyTestLocations() { + return Iterable.map(Iterable.filter(this.testSubscriptions.values(), s => s.stillDiscovering > 0), s => s.ident); + } + /** * @inheritdoc @@ -117,6 +134,24 @@ export class TestService extends Disposable implements ITestService { return result; } + /** + * @inheritdoc + */ + public updateDiscoveringCount(resource: ExtHostTestingResource, uri: URI, delta: number) { + const subscriptionKey = getTestSubscriptionKey(resource, uri); + const subscription = this.testSubscriptions.get(subscriptionKey); + if (!subscription) { + return; + } + + const wasBusy = !!subscription.stillDiscovering; + subscription.stillDiscovering += delta; + const isBusy = !!subscription.stillDiscovering; + if (wasBusy !== isBusy) { + this.busyStateChangeEmitter.fire({ resource, uri, busy: isBusy }); + } + } + /** * @inheritdoc */ @@ -124,7 +159,13 @@ export class TestService extends Disposable implements ITestService { const subscriptionKey = getTestSubscriptionKey(resource, uri); let subscription = this.testSubscriptions.get(subscriptionKey); if (!subscription) { - subscription = { ident: { resource, uri }, collection: new MainThreadTestCollection(), listeners: 0, onDiff: new Emitter() }; + subscription = { + ident: { resource, uri }, + collection: new MainThreadTestCollection(), + listeners: 0, + onDiff: new Emitter(), + stillDiscovering: 0, + }; this.subscribeEmitter.fire({ resource, uri }); this.testSubscriptions.set(subscriptionKey, subscription); }