src/goTest: add view for profiles

Updates golang/vscode-go#1641

Change-Id: I83233d4a64e98bc196d060cc1251a079eaeec22e
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/345470
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Hyang-Ah Hana Kim <hyangah@gmail.com>
Trust: Carlos Amedee <carlos@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
diff --git a/docs/test-explorer.md b/docs/test-explorer.md
new file mode 100644
index 0000000..a20bfdd
--- /dev/null
+++ b/docs/test-explorer.md
@@ -0,0 +1,10 @@
+# Test explorer implementation (src/goTest)
+
+## Mapping tests
+
+`TestItem`s themselves cannot be used with `Map`s. For non-primitive (object)
+keys, Map uses strict equality. Two objects are only strictly equal to each
+other if they are the exact same object. Because of this, `TestItem`s cannot be
+used as map keys, as the extension host may provide different objects for the
+same test. Therefore, if we want to use `TestItem`s as a map key, we must use
+their ID instead.
\ No newline at end of file
diff --git a/package.json b/package.json
index 491cd9b..a97ce04 100644
--- a/package.json
+++ b/package.json
@@ -100,7 +100,8 @@
     "onCommand:go.run.modinit",
     "onDebugInitialConfigurations",
     "onDebugResolve:go",
-    "onWebviewPanel:welcomeGo"
+    "onWebviewPanel:welcomeGo",
+    "onView:go.test.profile"
   ],
   "main": "./dist/goMain.js",
   "capabilities": {
@@ -2520,6 +2521,17 @@
           "group": "profile"
         }
       ]
+    },
+    "views": {
+      "test": [
+        {
+          "id": "go.test.profile",
+          "name": "Profiles",
+          "contextualTitle": "Go",
+          "icon": "$(graph)",
+          "when": "go.hasProfiles"
+        }
+      ]
     }
   }
 }
diff --git a/src/goTest/explore.ts b/src/goTest/explore.ts
index 2d9615e..778bee9 100644
--- a/src/goTest/explore.ts
+++ b/src/goTest/explore.ts
@@ -41,6 +41,7 @@
 		);
 
 		context.subscriptions.push(ctrl);
+		context.subscriptions.push(vscode.window.registerTreeDataProvider('go.test.profile', inst.profiler.view));
 
 		context.subscriptions.push(
 			vscode.commands.registerCommand('go.test.refresh', async (item) => {
@@ -219,7 +220,7 @@
 
 			const ws = this.workspace.getWorkspaceFolder(item.uri);
 			if (!ws) {
-				dispose(item);
+				dispose(this.resolver, item);
 			}
 		});
 	}
@@ -246,8 +247,8 @@
 
 		const found = find(this.ctrl.items);
 		if (found) {
-			dispose(found);
-			disposeIfEmpty(found.parent);
+			dispose(this.resolver, found);
+			disposeIfEmpty(this.resolver, found.parent);
 		}
 	}
 
@@ -255,7 +256,7 @@
 		let update = false;
 		this.ctrl.items.forEach((item) => {
 			if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) {
-				dispose(item);
+				dispose(this.resolver, item);
 				update = true;
 			}
 		});
diff --git a/src/goTest/profile.ts b/src/goTest/profile.ts
index ebdd13b..876dda7 100644
--- a/src/goTest/profile.ts
+++ b/src/goTest/profile.ts
@@ -2,7 +2,17 @@
  * Copyright 2021 The Go Authors. All rights reserved.
  * Licensed under the MIT License. See LICENSE in the project root for license information.
  *--------------------------------------------------------*/
-import { Memento, TestItem, Uri } from 'vscode';
+import {
+	EventEmitter,
+	Memento,
+	Range,
+	TestItem,
+	TextDocumentShowOptions,
+	TreeDataProvider,
+	TreeItem,
+	TreeItemCollapsibleState,
+	Uri
+} from 'vscode';
 import vscode = require('vscode');
 import { getTempFilePath } from '../util';
 import { GoTestResolver } from './resolve';
@@ -13,7 +23,10 @@
 const defaultOptions: ProfilingOptions = { kind: 'cpu' };
 
 export class GoTestProfiler {
-	private readonly lastRunFor = new Map<string, Run>();
+	public readonly view = new ProfileTreeDataProvider(this);
+
+	// Maps test IDs to profile files. See docs/test-explorer.md for details.
+	private readonly runs = new Map<string, File[]>();
 
 	constructor(private readonly resolver: GoTestResolver, private readonly workspaceState: Memento) {}
 
@@ -24,27 +37,28 @@
 		this.workspaceState.update(optionsMemento, v);
 	}
 
-	preRun(options: ProfilingOptions, items: TestItem[]): string[] {
+	preRun(options: ProfilingOptions, item: TestItem): string[] {
 		const kind = Kind.get(options.kind);
-		if (!kind) {
-			items.forEach((x) => this.lastRunFor.delete(x.id));
-			return [];
-		}
+		if (!kind) return [];
 
 		const flags = [];
-		const run = new Run(items, kind);
-		flags.push(run.file.flag);
-		items.forEach((x) => this.lastRunFor.set(x.id, run));
+		const run = new File(kind, item);
+		flags.push(run.flag);
+		if (this.runs.has(item.id)) this.runs.get(item.id).unshift(run);
+		else this.runs.set(item.id, [run]);
 		return flags;
 	}
 
 	postRun() {
 		// Update the list of tests that have profiles.
-		vscode.commands.executeCommand('setContext', 'go.profiledTests', Array.from(this.lastRunFor.keys()));
+		vscode.commands.executeCommand('setContext', 'go.profiledTests', Array.from(this.runs.keys()));
+		vscode.commands.executeCommand('setContext', 'go.hasProfiles', this.runs.size > 0);
+
+		this.view.didRun();
 	}
 
 	hasProfileFor(id: string): boolean {
-		return this.lastRunFor.has(id);
+		return this.runs.has(id);
 	}
 
 	async configure(): Promise<ProfilingOptions | undefined> {
@@ -68,13 +82,31 @@
 			return;
 		}
 
-		const run = this.lastRunFor.get(item.id);
-		if (!run) {
-			await vscode.window.showErrorMessage(`${name} was not profiled the last time it was run`);
+		const runs = this.runs.get(item.id);
+		if (!runs || runs.length === 0) {
+			await vscode.window.showErrorMessage(`${name} has not been profiled`);
 			return;
 		}
 
-		await run.file.show();
+		await runs[0].show();
+	}
+
+	// Tests that have been profiled
+	get tests() {
+		const items = Array.from(this.runs.keys());
+		items.sort((a: string, b: string) => {
+			const aWhen = this.runs.get(a)[0].when.getTime();
+			const bWhen = this.runs.get(b)[0].when.getTime();
+			return bWhen - aWhen;
+		});
+
+		// Filter out any tests that no longer exist
+		return items.map((x) => this.resolver.all.get(x)).filter((x) => x);
+	}
+
+	// Profiles associated with the given test
+	get(item: TestItem) {
+		return this.runs.get(item.id) || [];
 	}
 }
 
@@ -103,23 +135,20 @@
 	static readonly Block = new Kind('block', 'Block', '--blockprofile');
 }
 
-class Run {
+class File {
 	private static nextID = 0;
 
+	public readonly id = File.nextID++;
 	public readonly when = new Date();
-	public readonly id = Run.nextID++;
-	public readonly file: File;
 
-	constructor(public readonly targets: TestItem[], kind: Kind) {
-		this.file = new File(this, kind);
+	constructor(public readonly kind: Kind, public readonly target: TestItem) {}
+
+	get label() {
+		return `${this.kind.label} @ ${this.when.toTimeString()}`;
 	}
-}
-
-class File {
-	constructor(public readonly run: Run, public readonly kind: Kind) {}
 
 	get name() {
-		return `profile-${this.run.id}.${this.kind.id}.prof`;
+		return `profile-${this.id}.${this.kind.id}.prof`;
 	}
 
 	get flag(): string {
@@ -134,3 +163,48 @@
 		await vscode.window.showTextDocument(this.uri);
 	}
 }
+
+type TreeElement = TestItem | File;
+
+class ProfileTreeDataProvider implements TreeDataProvider<TreeElement> {
+	private readonly didChangeTreeData = new EventEmitter<void | TreeElement>();
+	public readonly onDidChangeTreeData = this.didChangeTreeData.event;
+
+	constructor(private readonly profiler: GoTestProfiler) {}
+
+	didRun() {
+		this.didChangeTreeData.fire();
+	}
+
+	getTreeItem(element: TreeElement): TreeItem {
+		if (element instanceof File) {
+			const item = new TreeItem(element.label);
+			item.contextValue = 'file';
+			item.command = {
+				title: 'Open',
+				command: 'vscode.open',
+				arguments: [element.uri]
+			};
+			return item;
+		}
+
+		const item = new TreeItem(element.label, TreeItemCollapsibleState.Collapsed);
+		item.contextValue = 'test';
+		const options: TextDocumentShowOptions = {
+			preserveFocus: false,
+			selection: new Range(element.range.start, element.range.start)
+		};
+		item.command = {
+			title: 'Go to test',
+			command: 'vscode.open',
+			arguments: [element.uri, options]
+		};
+		return item;
+	}
+
+	getChildren(element?: TreeElement): TreeElement[] {
+		if (!element) return this.profiler.tests;
+		if (element instanceof File) return [];
+		return this.profiler.get(element);
+	}
+}
diff --git a/src/goTest/resolve.ts b/src/goTest/resolve.ts
index ead2a47..04286ef 100644
--- a/src/goTest/resolve.ts
+++ b/src/goTest/resolve.ts
@@ -37,6 +37,7 @@
 }
 
 export class GoTestResolver {
+	public readonly all = new Map<string, TestItem>();
 	public readonly isDynamicSubtest = new WeakSet<TestItem>();
 	public readonly isTestMethod = new WeakSet<TestItem>();
 	public readonly isTestSuiteFunc = new WeakSet<TestItem>();
@@ -75,7 +76,7 @@
 				}
 
 				if (this.workspace.getWorkspaceFolder(item.uri)) {
-					dispose(item);
+					dispose(this, item);
 				}
 			});
 
@@ -212,16 +213,16 @@
 		item.children.forEach((child) => {
 			const { name } = GoTest.parseId(child.id);
 			if (!seen.has(name)) {
-				dispose(child);
+				dispose(this, child);
 				return;
 			}
 
 			if (ranges?.some((r) => !!child.range.intersection(r))) {
-				item.children.forEach(dispose);
+				item.children.forEach((x) => dispose(this, x));
 			}
 		});
 
-		disposeIfEmpty(item);
+		disposeIfEmpty(this, item);
 	}
 
 	/* ***** Private ***** */
@@ -233,7 +234,10 @@
 
 	// Create an item.
 	private createItem(label: string, uri: Uri, kind: GoTestKind, name?: string): TestItem {
-		return this.ctrl.createTestItem(GoTest.id(uri, kind, name), label, uri.with({ query: '', fragment: '' }));
+		const id = GoTest.id(uri, kind, name);
+		const item = this.ctrl.createTestItem(id, label, uri.with({ query: '', fragment: '' }));
+		this.all.set(id, item);
+		return item;
 	}
 
 	// Retrieve an item.
diff --git a/src/goTest/run.ts b/src/goTest/run.ts
index e9c2178..a15b66d 100644
--- a/src/goTest/run.ts
+++ b/src/goTest/run.ts
@@ -200,7 +200,7 @@
 				// Remove subtests created dynamically from test output
 				item.children.forEach((child) => {
 					if (this.resolver.isDynamicSubtest.has(child)) {
-						dispose(child);
+						dispose(this.resolver, child);
 					}
 				});
 
@@ -327,15 +327,21 @@
 	}
 
 	private async runGoTest(config: RunConfig): Promise<boolean> {
-		const { run, options, pkg, functions, record, concat, flags, ...rest } = config;
+		const { run, options, pkg, functions, record, concat, ...rest } = config;
 		if (Object.keys(functions).length === 0) return true;
 
+		if (options.kind) {
+			if (Object.keys(functions).length > 1) {
+				throw new Error('Profiling more than one test at once is unsupported');
+			}
+			rest.flags.push(...this.profiler.preRun(options, Object.values(functions)[0]));
+		}
+
 		const complete = new Set<TestItem>();
 		const outputChannel = new TestRunOutput(run);
 
 		const success = await goTest({
 			...rest,
-			flags: [...flags, ...this.profiler.preRun(options, Object.values(functions))],
 			outputChannel,
 			dir: pkg.uri.fsPath,
 			functions: Object.keys(functions),
diff --git a/src/goTest/utils.ts b/src/goTest/utils.ts
index 4d81422..9cc92c4 100644
--- a/src/goTest/utils.ts
+++ b/src/goTest/utils.ts
@@ -3,6 +3,7 @@
  * Licensed under the MIT License. See LICENSE in the project root for license information.
  *--------------------------------------------------------*/
 import * as vscode from 'vscode';
+import { GoTestResolver } from './resolve';
 
 // GoTestKind indicates the Go construct represented by a test item.
 //
@@ -91,13 +92,14 @@
 	return Promise.all(promises);
 }
 
-export function dispose(item: vscode.TestItem) {
+export function dispose(resolver: GoTestResolver, item: vscode.TestItem) {
+	resolver.all.delete(item.id);
 	item.parent.children.delete(item.id);
 }
 
 // Dispose of the item if it has no children, recursively. This facilitates
 // cleaning up package/file trees that contain no tests.
-export function disposeIfEmpty(item: vscode.TestItem) {
+export function disposeIfEmpty(resolver: GoTestResolver, item: vscode.TestItem) {
 	// Don't dispose of empty top-level items
 	const { kind } = GoTest.parseId(item.id);
 	if (kind === 'module' || kind === 'workspace' || (kind === 'package' && !item.parent)) {
@@ -108,6 +110,6 @@
 		return;
 	}
 
-	dispose(item);
-	disposeIfEmpty(item.parent);
+	dispose(resolver, item);
+	disposeIfEmpty(resolver, item.parent);
 }