diff --git a/.vscode/settings.json b/.vscode/settings.json index 0222b24c2a4..6f02065ef02 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -78,5 +78,6 @@ "editor.defaultFormatter": "vscode.typescript-language-features", "editor.formatOnSave": true, }, + "typescript.format.semicolons": "insert", "typescript.tsc.autoDetect": "off" } diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts index d17ec9468bc..8ac40d01a28 100644 --- a/src/vs/base/common/async.ts +++ b/src/vs/base/common/async.ts @@ -205,7 +205,7 @@ export class SequencerByKey { } /** - * A helper to delay execution of a task that is being requested often. + * A helper to delay (debounce) execution of a task that is being requested often. * * Following the throttler, now imagine the mail man wants to optimize the number of * trips proactively. The trip itself can be long, so he decides not to make the trip diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper.ts index 402ad544208..f65ca5c71bb 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper.ts @@ -6,8 +6,11 @@ import { ICompressedTreeElement } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; import { CompressibleObjectTree, ObjectTree } from 'vs/base/browser/ui/tree/objectTree'; import { Iterable } from 'vs/base/common/iterator'; +import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; import { ITestTreeElement } from 'vs/workbench/contrib/testing/browser/explorerProjections'; +export const isRunningState = (s: TestRunState) => s === TestRunState.Queued || s === TestRunState.Running; + /** * Removes nodes from the set whose parents don't exist in the tree. This is * useful to remove nodes that are queued to be updated or rendered, who will @@ -24,7 +27,7 @@ export const pruneNodesWithParentsNotInTree = (nodes /** * Helper to gather and bulk-apply tree updates. */ -export class NodeChangeList; parentItem: T | null }> { +export class NodeChangeList; parentItem: T | null; }> { private changedParents = new Set(); private updatedNodes = new Set(); diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/stateByLocation.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/stateByLocation.ts index ca855782405..9f22cbea700 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/stateByLocation.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/stateByLocation.ts @@ -15,7 +15,7 @@ import { Location as ModeLocation } from 'vs/editor/common/modes'; import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; import { ITestTreeElement, ITestTreeProjection } from 'vs/workbench/contrib/testing/browser/explorerProjections'; import { locationsEqual, TestLocationStore } from 'vs/workbench/contrib/testing/browser/explorerProjections/locationStore'; -import { NodeChangeList } from 'vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper'; +import { isRunningState, NodeChangeList } from 'vs/workbench/contrib/testing/browser/explorerProjections/nodeHelper'; import { StateElement } from 'vs/workbench/contrib/testing/browser/explorerProjections/stateNodes'; import { statesInOrder } from 'vs/workbench/contrib/testing/browser/testExplorerTree'; import { TestSubscriptionListener } from 'vs/workbench/contrib/testing/browser/testingCollectionService'; @@ -187,9 +187,19 @@ export class StateByLocationProjection extends AbstractIncrementalTestCollection } }, update: node => { + const isRunning = isRunningState(node.item.state.runState); if (node.item.state.runState !== node.previousState) { - this.pruneStateElements(node, node.previousState); - this.resolveNodesRecursive(node); + if (isRunning && node.treeElements.has(node.previousState)) { + node.treeElements.get(node.previousState)!.computedState = TestRunState.Running; + } else { + this.pruneStateElements(node, node.previousState); + this.resolveNodesRecursive(node); + } + } else if (!isRunning) { + const previous = node.treeElements.get(node.item.state.runState); + if (previous) { + previous.computedState = node.item.state.runState; + } } const locationChanged = !locationsEqual(node.location, node.item.location); @@ -199,7 +209,7 @@ export class StateByLocationProjection extends AbstractIncrementalTestCollection this.locations.add(node); } - const treeNode = node.treeElements.get(node.item.state.runState)!; + const treeNode = node.treeElements.get(node.previousState)!; this.changes.updated(treeNode); }, complete: () => { diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/stateByName.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/stateByName.ts index 7c08a73d038..e9aa93cd0de 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/stateByName.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/stateByName.ts @@ -178,10 +178,15 @@ export class StateByNameProjection extends AbstractIncrementalTestCollection { - if (node.item.state.runState !== node.previousState) { - this.removeNode(node); + if (node.item.state.runState !== node.previousState && node.node) { + if (node.item.state.runState === TestRunState.Running) { + node.node.computedState = node.item.state.runState; + } else { + this.removeNode(node); + } } + node.previousState = node.item.state.runState; this.resolveNodesRecursive(node); const locationChanged = !locationsEqual(node.location, node.item.location); diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index 32d89bdce01..5ff9ccc79cf 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -3,6 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.test-explorer { + display: flex; + flex-direction: column; +} + +.test-explorer > .monaco-inputbox { + flex-shrink: 0; + margin: 4px 12px; +} + +.test-explorer > .test-explorer-tree { + flex-grow: 1; + height: 0px; +} + .test-explorer .test-item { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts new file mode 100644 index 00000000000..e434d327b14 --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addStandardDisposableListener, EventType } from 'vs/base/browser/dom'; +import { HistoryInputBox } from 'vs/base/browser/ui/inputbox/inputBox'; +import { Widget } from 'vs/base/browser/ui/widget'; +import { Delayer } from 'vs/base/common/async'; +import { Emitter } from 'vs/base/common/event'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { localize } from 'vs/nls'; +import { ContextScopedHistoryInputBox } from 'vs/platform/browser/contextScopedHistoryWidget'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { attachInputBoxStyler } from 'vs/platform/theme/common/styler'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; + +export class TestingFilterState { + private readonly changeEmitter = new Emitter(); + + public readonly onDidChange = this.changeEmitter.event; + + public get value() { + return this._value; + } + + public set value(v: string) { + if (v !== this._value) { + this._value = v; + this.changeEmitter.fire(v); + } + } + + constructor(private _value = '') { } +} + +export class TestingExplorerFilter extends Widget { + private readonly input: HistoryInputBox; + private readonly history: StoredValue = this.instantiationService.createInstance(StoredValue, { + key: 'testing.filterHistory', + scope: StorageScope.WORKSPACE, + target: StorageTarget.USER + }); + + constructor( + container: HTMLElement, + private readonly state: TestingFilterState, + @IContextViewService contextViewService: IContextViewService, + @IThemeService themeService: IThemeService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + const updateDelayer = this._register(new Delayer(400)); + + const input = this.input = this._register(instantiationService.createInstance(ContextScopedHistoryInputBox, container, contextViewService, { + placeholder: localize('testExplorerFilter', "Filter (e.g. text, !exclude)"), + history: this.history.get([]), + })); + input.value = state.value; + this._register(attachInputBoxStyler(input, themeService)); + + this._register(state.onDidChange(newValue => { + input.value = newValue; + })); + + this._register(input.onDidChange(() => updateDelayer.trigger(() => { + input.addToHistory(); + this.state.value = input.value; + }))); + + this._register(addStandardDisposableListener(input.inputElement, EventType.KEY_DOWN, e => { + if (e.equals(KeyCode.Escape)) { + input.value = ''; + e.stopPropagation(); + e.preventDefault(); + } + })); + } + + + /** + * Focuses the filter input. + */ + public focus(): void { + this.input.focus(); + } + + /** + * Persists changes to the input history. + */ + public saveState() { + const history = this.input.getHistory(); + if (history.length) { + this.history.store(history); + } else { + this.history.delete(); + } + } +} diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 53bc0ce7776..fd51128785a 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -13,6 +13,7 @@ import { ITreeEvent, ITreeFilter, ITreeNode, ITreeSorter, TreeFilterResult, Tree import { throttle } from 'vs/base/common/decorators'; import { Event } from 'vs/base/common/event'; import { FuzzyScore } from 'vs/base/common/filters'; +import { splitGlobAware } from 'vs/base/common/glob'; import { Iterable } from 'vs/base/common/iterator'; import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import 'vs/css!./media/testing'; @@ -49,6 +50,7 @@ import { StateElement } from 'vs/workbench/contrib/testing/browser/explorerProje import { testingStatesToIcons } from 'vs/workbench/contrib/testing/browser/icons'; import { cmpPriority } from 'vs/workbench/contrib/testing/browser/testExplorerTree'; import { ITestingCollectionService, TestSubscriptionListener } from 'vs/workbench/contrib/testing/browser/testingCollectionService'; +import { TestingExplorerFilter, TestingFilterState } from 'vs/workbench/contrib/testing/browser/testingExplorerFilter'; import { TestExplorerViewGrouping, TestExplorerViewMode } from 'vs/workbench/contrib/testing/common/constants'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; @@ -57,8 +59,10 @@ import { DebugAction, RunAction } from './testExplorerActions'; export class TestingExplorerView extends ViewPane { public viewModel!: TestingExplorerViewModel; + private readonly filterState = new TestingFilterState(); + private filter!: TestingExplorerFilter; private currentSubscription?: TestSubscriptionListener; - private listContainer!: HTMLElement; + private container!: HTMLElement; private finishDiscovery?: () => void; constructor( @@ -93,8 +97,12 @@ export class TestingExplorerView extends ViewPane { protected renderBody(container: HTMLElement): void { super.renderBody(container); - this.listContainer = dom.append(container, dom.$('.test-explorer')); - this.viewModel = this.instantiationService.createInstance(TestingExplorerViewModel, this.listContainer, this.onDidChangeBodyVisibility, this.currentSubscription); + this.container = dom.append(container, dom.$('.test-explorer')); + this.filter = this.instantiationService.createInstance(TestingExplorerFilter, this.container, this.filterState); + this._register(this.filter); + + const listContainer = dom.append(this.container, dom.$('.test-explorer-tree')); + this.viewModel = this.instantiationService.createInstance(TestingExplorerViewModel, listContainer, this.onDidChangeBodyVisibility, this.currentSubscription, this.filterState); this._register(this.viewModel); this.updateProgressIndicator(); @@ -118,6 +126,14 @@ export class TestingExplorerView extends ViewPane { })); } + /** + * @override + */ + public saveState() { + super.saveState(); + this.filter.saveState(); + } + private updateProgressIndicator() { const busy = Iterable.some(this.testService.busyTestLocations, s => s.resource === ExtHostTestingResource.Workspace); if (!busy && this.finishDiscovery) { @@ -134,7 +150,7 @@ export class TestingExplorerView extends ViewPane { */ protected layoutBody(height: number, width: number): void { super.layoutBody(height, width); - this.listContainer.style.height = `${height}px`; + this.container.style.height = `${height}px`; this.viewModel.layout(height, width); } @@ -189,6 +205,7 @@ export class TestingExplorerViewModel extends Disposable { listContainer: HTMLElement, onDidChangeVisibility: Event, private listener: TestSubscriptionListener | undefined, + filterState: TestingFilterState, @IInstantiationService instantiationService: IInstantiationService, @IEditorService editorService: IEditorService, @ICodeEditorService codeEditorService: ICodeEditorService, @@ -202,7 +219,12 @@ export class TestingExplorerViewModel extends Disposable { const labels = this._register(instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: onDidChangeVisibility })); - this.filter = new TestsFilter(); + this.filter = new TestsFilter(filterState.value); + this._register(filterState.onDidChange(text => { + this.filter.setFilter(text); + this.tree.refilter(); + })); + this.tree = instantiationService.createInstance( WorkbenchCompressibleObjectTree, 'Test Explorer List', @@ -375,30 +397,65 @@ class CodeEditorTracker { } } +class TestsFilter implements ITreeFilter { + private filters: [include: boolean, value: string][] | undefined; -class TestsFilter implements ITreeFilter { - private filterText: string | undefined; - - public setFilter(filterText: string) { - this.filterText = filterText; + constructor(initialFilter: string) { + this.setFilter(initialFilter); } - public filter(element: ITestTreeElement): TreeFilterResult { + /** + * Parses and updates the tree filter. Supports lists of patterns that can be !negated. + */ + public setFilter(text: string) { + text = text.trim(); + + if (!text) { + this.filters = undefined; + return; + } + + this.filters = []; + for (const filter of splitGlobAware(text, ',').map(s => s.trim()).filter(s => !!s.length)) { + if (filter.startsWith('!')) { + this.filters.push([false, filter.slice(1).toLowerCase()]); + } else { + this.filters.push([true, filter.toLowerCase()]); + } + } + } + + public filter(element: ITestTreeElement): TreeFilterResult { if (element instanceof HierarchicalByNameElement && element.elementType !== ListElementType.TestLeaf && !element.isTestRoot) { return TreeVisibility.Hidden; } - if (!this.filterText) { + if (this.testFilterText(element.label)) { return TreeVisibility.Visible; } - if (element.label.includes(this.filterText)) { - return TreeVisibility.Visible; + return Iterable.isEmpty(element.getChildren()) ? TreeVisibility.Hidden : TreeVisibility.Recurse; + } + + private testFilterText(data: string) { + if (!this.filters) { + return true; } - return TreeVisibility.Recurse; + // start as included if the first glob is a negation + let included = this.filters[0][0] === false; + data = data.toLowerCase(); + + for (const [include, filter] of this.filters) { + if (data.includes(filter)) { + included = include; + } + } + + return included; } } + class TreeSorter implements ITreeSorter { public compare(a: ITestTreeElement, b: ITestTreeElement): number { if (a instanceof StateElement && b instanceof StateElement) { diff --git a/src/vs/workbench/contrib/testing/common/storedValue.ts b/src/vs/workbench/contrib/testing/common/storedValue.ts new file mode 100644 index 00000000000..6102358129c --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/storedValue.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; + +export interface IStoredValueSerialization { + deserialize(data: string): T; + serialize(data: T): string; +} + +const defaultSerialization: IStoredValueSerialization = { + deserialize: d => JSON.parse(d), + serialize: d => JSON.stringify(d), +}; + +interface IStoredValueOptions { + key: string; + scope: StorageScope; + target: StorageTarget; + serialization?: IStoredValueSerialization; +} + +/** + * todo@connor4312: is this worthy to be in common? + */ +export class StoredValue { + private readonly serialization: IStoredValueSerialization; + private readonly key: string; + private readonly scope: StorageScope; + private readonly target: StorageTarget; + + /** + * Emitted whenever the value is updated or deleted. + */ + public readonly onDidChange = Event.filter(this.storage.onDidChangeValue, e => e.key === this.key); + + constructor( + options: IStoredValueOptions, + @IStorageService private readonly storage: IStorageService, + ) { + this.key = options.key; + this.scope = options.scope; + this.target = options.target; + this.serialization = options.serialization ?? defaultSerialization; + } + + /** + * Reads the value, returning the undefined if it's not set. + */ + public get(): T | undefined; + + /** + * Reads the value, returning the default value if it's not set. + */ + public get(defaultValue: T): T; + + public get(defaultValue?: T): T | undefined { + const value = this.storage.get(this.key, this.scope); + return value === undefined ? defaultValue : this.serialization.deserialize(value); + } + + /** + * Persists changes to the value. + * @param value + */ + public store(value: T) { + this.storage.store(this.key, this.serialization.serialize(value), this.scope, this.target); + } + + /** + * Delete an element stored under the provided key from storage. + */ + public delete() { + this.storage.remove(this.key, this.scope); + } +}