testing: add filter box

This commit is contained in:
Connor Peet 2021-01-11 13:03:59 -08:00
parent 676bb6b100
commit e3b18fa3ef
No known key found for this signature in database
GPG key ID: CF8FD2EA0DBC61BD
9 changed files with 296 additions and 23 deletions

View file

@ -78,5 +78,6 @@
"editor.defaultFormatter": "vscode.typescript-language-features",
"editor.formatOnSave": true,
},
"typescript.format.semicolons": "insert",
"typescript.tsc.autoDetect": "off"
}

View file

@ -205,7 +205,7 @@ export class SequencerByKey<TKey> {
}
/**
* 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

View file

@ -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 = <T extends ITestTreeElement>(nodes
/**
* Helper to gather and bulk-apply tree updates.
*/
export class NodeChangeList<T extends ITestTreeElement & { children: Iterable<T>; parentItem: T | null }> {
export class NodeChangeList<T extends ITestTreeElement & { children: Iterable<T>; parentItem: T | null; }> {
private changedParents = new Set<T | null>();
private updatedNodes = new Set<T>();

View file

@ -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: () => {

View file

@ -178,10 +178,15 @@ export class StateByNameProjection extends AbstractIncrementalTestCollection<ISt
}
},
update: node => {
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);

View file

@ -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;

View file

@ -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<string>();
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<string[]> = 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<void>(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();
}
}
}

View file

@ -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<boolean>,
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<ITestTreeElement> {
private filters: [include: boolean, value: string][] | undefined;
class TestsFilter implements ITreeFilter<ITestTreeElement, FuzzyScore> {
private filterText: string | undefined;
public setFilter(filterText: string) {
this.filterText = filterText;
constructor(initialFilter: string) {
this.setFilter(initialFilter);
}
public filter(element: ITestTreeElement): TreeFilterResult<FuzzyScore> {
/**
* 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<void> {
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<ITestTreeElement> {
public compare(a: ITestTreeElement, b: ITestTreeElement): number {
if (a instanceof StateElement && b instanceof StateElement) {

View file

@ -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<T> {
deserialize(data: string): T;
serialize(data: T): string;
}
const defaultSerialization: IStoredValueSerialization<any> = {
deserialize: d => JSON.parse(d),
serialize: d => JSON.stringify(d),
};
interface IStoredValueOptions<T> {
key: string;
scope: StorageScope;
target: StorageTarget;
serialization?: IStoredValueSerialization<T>;
}
/**
* todo@connor4312: is this worthy to be in common?
*/
export class StoredValue<T> {
private readonly serialization: IStoredValueSerialization<T>;
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<T>,
@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);
}
}