src/goEnvironmentStatus.ts: use the new EnvironmentVariableCollection API

vscode.ExtensionContext.EnvironmentVariableCollection is available since
1.46 (May 2020 release https://code.visualstudio.com/updates/v1_46#_environment-variable-collection)
This API is specifically designed to help easy mutation of terminal
environment, so we are trying to use it now.

I hope this helps to address a couple of bugs related to the shell
detection and the correct command and path escaping discovered
during testing on Windows.

Unfortunately, this API does not work well on Mac
https://github.com/microsoft/vscode/issues/99878 due to the peculiarity
described in
https://code.visualstudio.com/docs/editor/integrated-terminal#_why-are-there-duplicate-paths-in-the-terminals-path-environment-variable-andor-why-are-they-reversed

So, we use the new API only for non-darwin OSes for now. On Mac,
we send the command as done before unless users explicitly
chose to use `-l` or `--login` parameter.

And moved addGoRuntimeBaseToPATH to goEnvironmentStatus.ts
because I think this is a better place.

Updates golang/vscode-go#378

Change-Id: I56ada362fb422bca9c789eae30c6239cac5b4711
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/245127
Reviewed-by: Brayden Cloud <bcloud@google.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/src/goEnvironmentStatus.ts b/src/goEnvironmentStatus.ts
index 1b78d6e..1dd8865 100644
--- a/src/goEnvironmentStatus.ts
+++ b/src/goEnvironmentStatus.ts
@@ -36,31 +36,18 @@
 // statusbar item for switching the Go environment
 let goEnvStatusbarItem: vscode.StatusBarItem;
 let terminalCreationListener: vscode.Disposable;
-let terminalPATH: string;
 
 /**
  * Initialize the status bar item with current Go binary
  */
-export async function initGoStatusBar(cachePath: string) {
+export async function initGoStatusBar() {
 	if (!goEnvStatusbarItem) {
 		goEnvStatusbarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50);
 	}
-	// cache the PATH on first initialization
-	// this will be the path that new integrated terminals have
-	if (!terminalPATH) {
-		terminalPATH = cachePath;
-	}
-
 	// set Go version and command
 	const version = await getGoVersion();
 	const goOption = new GoEnvironmentOption(version.binaryPath, formatGoVersion(version.format()));
 
-	// ensure terminals use the correct Go version
-	if (!terminalCreationListener) {
-		updateIntegratedTerminal(vscode.window.activeTerminal);
-		terminalCreationListener = vscode.window.onDidOpenTerminal(updateIntegratedTerminal);
-	}
-
 	hideGoStatusBar();
 	goEnvStatusbarItem.text = goOption.label;
 	goEnvStatusbarItem.command = 'go.environment.choose';
@@ -97,6 +84,11 @@
 	}
 }
 
+let environmentVariableCollection: vscode.EnvironmentVariableCollection;
+export function setEnvironmentVariableCollection(env: vscode.EnvironmentVariableCollection) {
+	environmentVariableCollection = env;
+}
+
 /**
  * Present a command palette menu to the user to select their go binary
  */
@@ -149,8 +141,10 @@
 
 	// update currently selected go
 	try {
-		await setSelectedGo(GoEnvironmentOption.fromQuickPickItem(selection));
-		vscode.window.showInformationMessage(`Switched to ${selection.label}`);
+		const changed = await setSelectedGo(GoEnvironmentOption.fromQuickPickItem(selection));
+		if (changed) {
+			vscode.window.showInformationMessage(`Switched to ${selection.label}`);
+		}
 	} catch (e) {
 		vscode.window.showErrorMessage(e.message);
 	}
@@ -159,123 +153,183 @@
 /**
  * update the selected go path and label in the workspace state
  */
-export async function setSelectedGo(goOption: GoEnvironmentOption, promptReload = true) {
+export async function setSelectedGo(goOption: GoEnvironmentOption, promptReload = true): Promise<boolean> {
 	if (!goOption) {
-		return;
+		return false;
 	}
-	const execFile = promisify(cp.execFile);
+
 	// if the selected go version is not installed, install it
 	if (goOption.binpath?.startsWith('go get')) {
 		// start a loading indicator
-		await vscode.window.withProgress({
-			title: `Downloading ${goOption.label}`,
-			location: vscode.ProgressLocation.Notification,
-		}, async () => {
-			outputChannel.show();
-			outputChannel.clear();
-
-			outputChannel.appendLine('Finding Go executable for downloading');
-			const goExecutable = getBinPath('go');
-			if (!goExecutable) {
-				outputChannel.appendLine('Could not find Go executable.');
-				throw new Error('Could not find Go tool.');
-			}
-
-			// TODO(bcloud) dedup repeated logic below which comes from
-			// https://github.com/golang/vscode-go/blob/bc23fa854192d04200c8e4f74dca18d2c3021b46/src/goInstallTools.ts#L184
-
-			// Install tools in a temporary directory, to avoid altering go.mod files.
-			const mkdtemp = promisify(fs.mkdtemp);
-			const toolsTmpDir = await mkdtemp(getTempFilePath('go-tools-'));
-			let tmpGoModFile: string;
-
-			// Write a temporary go.mod file to avoid version conflicts.
-			tmpGoModFile = path.join(toolsTmpDir, 'go.mod');
-			const writeFile = promisify(fs.writeFile);
-			await writeFile(tmpGoModFile, 'module tools');
-
-			// use the current go executable to download the new version
-			const env = {
-				...toolInstallationEnvironment(),
-				GO111MODULE: 'on',
-			};
-			const [, ...args] = goOption.binpath.split(' ');
-			outputChannel.appendLine(`Running ${goExecutable} ${args.join(' ')}`);
-			try {
-				await execFile(goExecutable, args, {
-					env,
-					cwd: toolsTmpDir,
-				});
-			} catch (getErr) {
-				outputChannel.appendLine(`Error finding Go: ${getErr}`);
-				throw new Error('Could not find Go version.');
-			}
-
-			// run `goX.X download`
-			const newExecutableName = args[1].split('/')[2];
-			const goXExecutable = getBinPath(newExecutableName);
-			outputChannel.appendLine(`Running: ${goXExecutable} download`);
-			try {
-				await execFile(goXExecutable, ['download'], { env });
-			} catch (downloadErr) {
-				outputChannel.appendLine(`Error finishing installation: ${downloadErr}`);
-				throw new Error('Could not download Go version.');
-			}
-
-			outputChannel.appendLine('Finding newly downloaded Go');
-			const sdkPath = path.join(os.homedir(), 'sdk');
-			if (!await pathExists(sdkPath)) {
-				outputChannel.appendLine(`SDK path does not exist: ${sdkPath}`);
-				throw new Error(`SDK path does not exist: ${sdkPath}`);
-			}
-
-			const readdir = promisify(fs.readdir);
-			const subdirs = await readdir(sdkPath);
-			const dir = subdirs.find((subdir) => subdir === newExecutableName);
-			if (!dir) {
-				outputChannel.appendLine('Could not find newly downloaded Go');
-				throw new Error('Could not install Go version.');
-			}
-
-			const binpath = path.join(sdkPath, dir, 'bin', correctBinname('go'));
-			const newOption = new GoEnvironmentOption(binpath, goOption.label);
-			await updateWorkspaceState('selectedGo', newOption);
-			goEnvStatusbarItem.text = goOption.label;
-
-			// remove tmp directories
-			outputChannel.appendLine('Cleaning up...');
-			rmdirRecursive(toolsTmpDir);
-			outputChannel.appendLine('Success!');
-		});
+		await downloadGo(goOption);
 	} else if (goOption.label === 'Clear selection') {
-		updateWorkspaceState('selectedGo', undefined);
+		if (!getSelectedGo()) {
+			return false;  // do nothing.
+		}
+		await updateWorkspaceState('selectedGo', undefined);
+		// TODO: goEnvStatusbarItem?
 	} else {
 		// check that the given binary is not already at the beginning of the PATH
 		const go = await getGoVersion();
-		if (go.binaryPath === goOption.binpath) {
-			return;
+		if (!!go && (go.binaryPath === goOption.binpath || 'Go ' + go.format() === goOption.label)) {
+			return false;
 		}
-
 		await updateWorkspaceState('selectedGo', goOption);
-		goEnvStatusbarItem.text = goOption.label;
 	}
-	// prompt the user to reload the window
-	// promptReload defaults to true and should only be false for tests
+	// prompt the user to reload the window.
+	// promptReload defaults to true and should only be false for tests.
 	if (promptReload) {
-		const choice = await vscode.window.showInformationMessage('Please reload the window to finish applying changes.', 'Reload Window');
+		const choice = await vscode.window.showInformationMessage('Please reload the window to finish applying Go version changes.', 'Reload Window');
 		if (choice === 'Reload Window') {
 			await vscode.commands.executeCommand('workbench.action.reloadWindow');
 		}
 	}
+	goEnvStatusbarItem.text = 'Go: reload required';
+	goEnvStatusbarItem.command = 'workbench.action.reloadWindow';
+
+	return true;
+}
+
+// downloadGo downloads the specified go version available in dl.golang.org.
+async function downloadGo(goOption: GoEnvironmentOption) {
+	const execFile = promisify(cp.execFile);
+	await vscode.window.withProgress({
+		title: `Downloading ${goOption.label}`,
+		location: vscode.ProgressLocation.Notification,
+	}, async () => {
+		outputChannel.show();
+		outputChannel.clear();
+
+		outputChannel.appendLine('Finding Go executable for downloading');
+		const goExecutable = getBinPath('go');
+		if (!goExecutable) {
+			outputChannel.appendLine('Could not find Go executable.');
+			throw new Error('Could not find Go tool.');
+		}
+
+		// TODO(bcloud) dedup repeated logic below which comes from
+		// https://github.com/golang/vscode-go/blob/bc23fa854192d04200c8e4f74dca18d2c3021b46/src/goInstallTools.ts#L184
+		// Install tools in a temporary directory, to avoid altering go.mod files.
+		const mkdtemp = promisify(fs.mkdtemp);
+		const toolsTmpDir = await mkdtemp(getTempFilePath('go-tools-'));
+		let tmpGoModFile: string;
+
+		// Write a temporary go.mod file to avoid version conflicts.
+		tmpGoModFile = path.join(toolsTmpDir, 'go.mod');
+		const writeFile = promisify(fs.writeFile);
+		await writeFile(tmpGoModFile, 'module tools');
+
+		// use the current go executable to download the new version
+		const env = {
+			...toolInstallationEnvironment(),
+			GO111MODULE: 'on',
+		};
+		const [, ...args] = goOption.binpath.split(' ');
+		outputChannel.appendLine(`Running ${goExecutable} ${args.join(' ')}`);
+		try {
+			await execFile(goExecutable, args, {
+				env,
+				cwd: toolsTmpDir,
+			});
+		} catch (getErr) {
+			outputChannel.appendLine(`Error finding Go: ${getErr}`);
+			throw new Error('Could not find Go version.');
+		}
+
+		// run `goX.X download`
+		const newExecutableName = args[1].split('/')[2];
+		const goXExecutable = getBinPath(newExecutableName);
+		outputChannel.appendLine(`Running: ${goXExecutable} download`);
+		try {
+			await execFile(goXExecutable, ['download'], { env });
+		} catch (downloadErr) {
+			outputChannel.appendLine(`Error finishing installation: ${downloadErr}`);
+			throw new Error('Could not download Go version.');
+		}
+
+		outputChannel.appendLine('Finding newly downloaded Go');
+		const sdkPath = path.join(os.homedir(), 'sdk');
+		if (!await pathExists(sdkPath)) {
+			outputChannel.appendLine(`SDK path does not exist: ${sdkPath}`);
+			throw new Error(`SDK path does not exist: ${sdkPath}`);
+		}
+
+		const readdir = promisify(fs.readdir);
+		const subdirs = await readdir(sdkPath);
+		const dir = subdirs.find((subdir) => subdir === newExecutableName);
+		if (!dir) {
+			outputChannel.appendLine('Could not find newly downloaded Go');
+			throw new Error('Could not install Go version.');
+		}
+
+		const binpath = path.join(sdkPath, dir, 'bin', correctBinname('go'));
+		const newOption = new GoEnvironmentOption(binpath, goOption.label);
+		await updateWorkspaceState('selectedGo', newOption);
+
+		// remove tmp directories
+		outputChannel.appendLine('Cleaning up...');
+		rmdirRecursive(toolsTmpDir);
+		outputChannel.appendLine('Success!');
+	});
+}
+
+// PATH value cached before addGoRuntimeBaseToPath modified.
+let defaultPathEnv = '';
+
+// addGoRuntimeBaseToPATH adds the given path to the front of the PATH environment variable.
+// It removes duplicates.
+// TODO: can we avoid changing PATH but utilize toolExecutionEnv?
+export function addGoRuntimeBaseToPATH(newGoRuntimeBase: string) {
+	if (!newGoRuntimeBase) {
+		return;
+	}
+
+	let pathEnvVar: string;
+	if (process.env.hasOwnProperty('PATH')) {
+		pathEnvVar = 'PATH';
+	} else if (process.platform === 'win32' && process.env.hasOwnProperty('Path')) {
+		pathEnvVar = 'Path';
+	} else {
+		return;
+	}
+
+	if (!defaultPathEnv) {  // cache the default value
+		defaultPathEnv = <string>process.env[pathEnvVar];
+	}
+
+	// calling this multiple times will override the previous value.
+	// environmentVariableCollection.clear();
+	if (process.platform !== 'darwin') {
+		environmentVariableCollection?.prepend(pathEnvVar, newGoRuntimeBase + path.delimiter);
+	} else if (!terminalCreationListener) {
+		// We don't use EnvironmentVariableCollection on mac
+		// because this gets confusing for users. Instead we send the
+		// shell command to change the PATH env var,
+		// following the suggestion to workaround described in
+		// https://github.com/microsoft/vscode/issues/99878#issuecomment-642808852
+		const terminalShellArgs = <string[]>(vscode.workspace.getConfiguration('terminal.integrated').get('shellArgs') || []);
+		// User explicitly chose to run the login shell. So, don't mess with their config.
+		if (!terminalShellArgs.includes('-l') && !terminalShellArgs.includes('--login')) {
+			for (const term of vscode.window.terminals) {
+				updateIntegratedTerminal(term);
+			}
+			terminalCreationListener = vscode.window.onDidOpenTerminal(updateIntegratedTerminal);
+		}
+	}
+
+	let pathVars = defaultPathEnv.split(path.delimiter);
+	pathVars = pathVars.filter((p) => p !== newGoRuntimeBase);
+	pathVars.unshift(newGoRuntimeBase);
+	process.env[pathEnvVar] = pathVars.join(path.delimiter);
 }
 
 /**
  * update the PATH variable in the given terminal to default to the currently selected Go
  */
-export async function updateIntegratedTerminal(terminal: vscode.Terminal) {
+export async function updateIntegratedTerminal(terminal: vscode.Terminal): Promise<void> {
 	if (!terminal) { return; }
 	const gorootBin = path.join(getCurrentGoRoot(), 'bin');
-	const defaultGoRuntimeBin = path.dirname(getBinPathFromEnvVar('go', terminalPATH, false));
+	const defaultGoRuntimeBin = path.dirname(getBinPathFromEnvVar('go', defaultPathEnv, false));
 	if (gorootBin === defaultGoRuntimeBin) {
 		return;
 	}
diff --git a/src/goInstallTools.ts b/src/goInstallTools.ts
index eb51b4a..26ccb8b 100644
--- a/src/goInstallTools.ts
+++ b/src/goInstallTools.ts
@@ -12,7 +12,7 @@
 import util = require('util');
 import vscode = require('vscode');
 import { toolInstallationEnvironment } from './goEnv';
-import { initGoStatusBar } from './goEnvironmentStatus';
+import { addGoRuntimeBaseToPATH, initGoStatusBar } from './goEnvironmentStatus';
 import { getLanguageServerToolPath } from './goLanguageServer';
 import { restartLanguageServer } from './goMain';
 import { hideGoStatus, outputChannel, showGoStatus } from './goStatus';
@@ -372,15 +372,8 @@
 
 			// cgo, gopls, and other underlying tools will inherit the environment and attempt
 			// to locate 'go' from the PATH env var.
-			let cachePath = '';
-			if (process.env.hasOwnProperty('PATH')) {
-				cachePath = process.env.PATH;
-			} else {
-				cachePath = process.env.Path;
-			}
-
 			addGoRuntimeBaseToPATH(path.join(getCurrentGoRoot(), 'bin'));
-			initGoStatusBar(cachePath);
+			initGoStatusBar();
 			// TODO: restart language server or synchronize with language server update.
 
 			return resolve();
@@ -388,36 +381,6 @@
 	});
 }
 
-// PATH value cached before addGoRuntimeBaseToPath modified.
-let defaultPathEnv = '';
-
-// addGoRuntimeBaseToPATH adds the given path to the front of the PATH environment variable.
-// It removes duplicates.
-// TODO: can we avoid changing PATH but utilize toolExecutionEnv?
-function addGoRuntimeBaseToPATH(newGoRuntimeBase: string) {
-	if (!newGoRuntimeBase) {
-		return;
-	}
-
-	let pathEnvVar: string;
-	if (process.env.hasOwnProperty('PATH')) {
-		pathEnvVar = 'PATH';
-	} else if (process.platform === 'win32' && process.env.hasOwnProperty('Path')) {
-		pathEnvVar = 'Path';
-	} else {
-		return;
-	}
-
-	if (!defaultPathEnv) {  // cache the default value
-		defaultPathEnv = <string>process.env[pathEnvVar];
-	}
-
-	let pathVars = defaultPathEnv.split(path.delimiter);
-	pathVars = pathVars.filter((p) => p !== newGoRuntimeBase);
-	pathVars.unshift(newGoRuntimeBase);
-	process.env[pathEnvVar] = pathVars.join(path.delimiter);
-}
-
 let alreadyOfferedToInstallTools = false;
 
 export async function offerToInstallTools() {
diff --git a/src/goMain.ts b/src/goMain.ts
index d4292be..4bc637d 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -19,7 +19,7 @@
 import { GoDebugConfigurationProvider } from './goDebugConfiguration';
 import { extractFunction, extractVariable } from './goDoctor';
 import { toolExecutionEnvironment } from './goEnv';
-import { chooseGoEnvironment, disposeGoStatusBar } from './goEnvironmentStatus';
+import { chooseGoEnvironment, disposeGoStatusBar, setEnvironmentVariableCollection } from './goEnvironmentStatus';
 import { runFillStruct } from './goFillStruct';
 import * as goGenerateTests from './goGenerateTests';
 import { goGetPackage } from './goGetPackage';
@@ -74,6 +74,8 @@
 export function activate(ctx: vscode.ExtensionContext) {
 	setGlobalState(ctx.globalState);
 	setWorkspaceState(ctx.workspaceState);
+	setEnvironmentVariableCollection(ctx.environmentVariableCollection);
+
 	const configGOROOT = getGoConfig()['goroot'];
 	if (!!configGOROOT) {
 		setCurrentGoRoot(configGOROOT);