blob: 52fdbc381d8ae5306a848ed8b2342db2bc4aa441 [file] [log] [blame]
/*---------------------------------------------------------
* Copyright 2022 The Go Authors. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------*/
import vscode = require('vscode');
import vscodeUri = require('vscode-uri');
import os = require('os');
import path = require('path');
import { getGoConfig, getGoplsConfig } from './config';
import { getBinPath, getGoVersion } from './util';
import { getConfiguredTools } from './goTools';
import { inspectGoToolVersion } from './goInstallTools';
import { runGoEnv } from './goModules';
/**
* GoExplorerProvider provides data for the Go tree view in the Explorer
* Tree View Container.
*/
export class GoExplorerProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private goEnvCache = new Cache((uri) => GoEnv.get(uri ? vscode.Uri.parse(uri) : undefined), Time.MINUTE);
private toolDetailCache = new Cache((name) => getToolDetail(name), Time.HOUR);
private activeFolder?: vscode.WorkspaceFolder;
private activeDocument?: vscode.TextDocument;
static setup(ctx: vscode.ExtensionContext) {
const provider = new this(ctx);
const {
window: { registerTreeDataProvider },
commands: { registerCommand, executeCommand }
} = vscode;
ctx.subscriptions.push(
registerTreeDataProvider('go.explorer', provider),
registerCommand('go.explorer.refresh', () => provider.update(true)),
registerCommand('go.explorer.open', (item) => provider.open(item)),
registerCommand('go.workspace.editEnv', (item) => provider.editEnv(item)),
registerCommand('go.workspace.resetEnv', (item) => provider.resetEnv(item))
);
executeCommand('setContext', 'go.showExplorer', true);
return provider;
}
private _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | void>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
constructor(ctx: vscode.ExtensionContext) {
this.update();
ctx.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(() => this.update()),
vscode.workspace.onDidChangeWorkspaceFolders(() => this.update()),
vscode.workspace.onDidChangeConfiguration(() => this.update(true)),
vscode.workspace.onDidCloseTextDocument((doc) => {
if (!this.activeFolder) {
this.goEnvCache.delete(vscodeUri.Utils.dirname(doc.uri).toString());
}
})
);
}
getTreeItem(element: vscode.TreeItem) {
return element;
}
getChildren(element?: vscode.TreeItem) {
if (!element) {
return [this.envTree(), this.toolTree()];
}
if (isEnvTree(element)) {
return this.envTreeItems(element.workspace);
}
if (isToolTree(element)) {
return this.toolTreeItems();
}
if (isToolTreeItem(element)) {
return element.children;
}
}
private update(clearCache = false) {
if (clearCache) {
this.goEnvCache.clear();
this.toolDetailCache.clear();
}
const { activeTextEditor } = vscode.window;
const { getWorkspaceFolder, workspaceFolders } = vscode.workspace;
this.activeDocument = activeTextEditor?.document;
this.activeFolder = activeTextEditor?.document
? getWorkspaceFolder(activeTextEditor.document.uri) || workspaceFolders?.[0]
: workspaceFolders?.[0];
this._onDidChangeTreeData.fire();
}
private async open(item: EnvTreeItem) {
if (typeof item.file === 'undefined') return;
const edit = new vscode.WorkspaceEdit();
edit.createFile(item.file, { ignoreIfExists: true });
await vscode.workspace.applyEdit(edit);
const doc = await vscode.workspace.openTextDocument(item.file);
await vscode.window.showTextDocument(doc);
}
private async editEnv(item?: EnvTreeItem) {
const uri = this.activeFolder?.uri;
if (!uri) {
return;
}
let pick: { label?: string; description?: string } | undefined;
if (isEnvTreeItem(item)) {
pick = { label: item.key, description: item.value };
} else {
const items = Object.entries<string>(await runGoEnv(uri))
.filter(([label]) => !GoEnv.readonlyVars.has(label))
.map(([label, description]) => ({
label,
description
}));
pick = await vscode.window.showQuickPick(items, { title: 'Go: Edit Workspace Env' });
}
if (!pick) return;
const { label, description } = pick;
const value = await vscode.window.showInputBox({ title: label, value: description });
if (label && typeof value !== 'undefined') {
await GoEnv.edit({ [label]: value });
}
}
private async resetEnv(item?: EnvTreeItem) {
if (item?.key) {
await GoEnv.reset([item.key]);
return;
}
await GoEnv.reset();
}
private envTree() {
if (this.activeFolder) {
const { name, uri } = this.activeFolder;
return new EnvTree(name, uri);
}
if (this.activeDocument) {
const { fileName, uri } = this.activeDocument;
return new EnvTree(path.basename(fileName), vscodeUri.Utils.dirname(uri));
}
return new EnvTree();
}
private async envTreeItems(uri?: vscode.Uri) {
const env = await this.goEnvCache.get(uri?.toString() ?? '');
const items = [];
for (const [k, v] of Object.entries(env)) {
if (v !== '') {
items.push(new EnvTreeItem(k, v));
}
}
return items;
}
private toolTree() {
return new ToolTree();
}
private async toolTreeItems() {
const goVersion = await getGoVersion();
const allTools = getConfiguredTools(goVersion, getGoConfig(), getGoplsConfig());
const toolsInfo = await Promise.all(allTools.map((tool) => this.toolDetailCache.get(tool.name)));
const items = [];
for (const t of toolsInfo) {
items.push(new ToolTreeItem(t));
}
return items;
}
}
class EnvTree implements vscode.TreeItem {
label = 'env';
contextValue = 'go:explorer:envtree';
collapsibleState = vscode.TreeItemCollapsibleState.Expanded;
iconPath = new vscode.ThemeIcon('symbol-folder');
constructor(public description = '', public workspace?: vscode.Uri) {}
}
function isEnvTree(item?: vscode.TreeItem): item is EnvTree {
return item?.contextValue === 'go:explorer:envtree';
}
class EnvTreeItem implements vscode.TreeItem {
file?: vscode.Uri;
label: string;
contextValue?: string;
tooltip?: string;
constructor(public key: string, public value: string) {
this.label = `${key}=${replaceHome(value)}`;
this.contextValue = 'go:explorer:envitem';
if (GoEnv.fileVars.has(key)) {
this.contextValue = 'go:explorer:envfile';
this.file = vscode.Uri.file(value);
}
this.tooltip = `${key}=${value}`;
}
}
function isEnvTreeItem(item?: vscode.TreeItem): item is EnvTreeItem {
return item?.contextValue === 'go:explorer:envitem';
}
class GoEnv {
/**
* get returns a subset of go env vars, the union of this.vars and values
* set with toolsEnvVars in the go workspace config.
* @param uri the directory from which to run go env.
* @returns the output of running go env -json VAR1 VAR2...
*/
static async get(uri?: vscode.Uri) {
const toolsEnv = await getGoConfig(uri)['toolsEnvVars'];
const output = await runGoEnv(uri, [...this.vars, ...Object.keys(toolsEnv)]);
return output as Record<string, string>;
}
/**
* update writes to toolsEnvVars in the go workspace config.
* @param vars a record of env vars to update.
*/
static async edit(vars: Record<string, string>) {
const config = getGoConfig();
await config.update('toolsEnvVars', { ...config['toolsEnvVars'], ...vars });
}
/**
* reset removes entries from toolsEnvVars in the go workspace config.
* @param vars env vars to reset.
*/
static async reset(vars?: string[]) {
const config = getGoConfig();
let env: Record<string, string> = {};
if (vars) {
env = { ...config['toolsEnvVars'] };
for (const v of vars) {
delete env[v];
}
}
await config.update('toolsEnvVars', env);
}
/** Vars that point to files. */
static fileVars = new Set(['GOMOD', 'GOWORK', 'GOENV']);
/** Vars available from 'go env' but not read from the environment */
static readonlyVars = new Set([
'GOEXE',
'GOGCCFLAGS',
'GOHOSTARCH',
'GOHOSTOS',
'GOMOD',
'GOTOOLDIR',
'GOVERSION',
'GOWORK'
]);
/** Vars that should always be visible if they contain a value. */
private static vars = ['GOPRIVATE', 'GOMOD', 'GOWORK', 'GOENV'];
}
class ToolTree implements vscode.TreeItem {
label = 'tools';
contextValue = 'go:explorer:tools';
collapsibleState = vscode.TreeItemCollapsibleState.Collapsed;
iconPath = new vscode.ThemeIcon('tools');
}
function isToolTree(item?: vscode.TreeItem): item is ToolTree {
return item?.contextValue === 'go:explorer:tools';
}
class ToolTreeItem implements vscode.TreeItem {
contextValue = 'go:explorer:toolitem';
description = 'not installed';
label: string;
children?: vscode.TreeItem[];
collapsibleState?: vscode.TreeItemCollapsibleState;
tooltip?: string;
constructor({ name, version, goVersion, binPath, error }: ToolDetail) {
this.label = name;
if (binPath) {
this.label = `${name}@${version}`;
this.description = `${replaceHome(binPath)} ${goVersion}`;
this.tooltip = `${this.label} ${this.description}`;
}
if (error) {
const msg = `go version -m failed: ${error}`;
this.description = msg;
this.tooltip = msg;
}
}
}
function isToolTreeItem(item?: vscode.TreeItem): item is ToolTreeItem {
return item?.contextValue === 'go:explorer:toolitem';
}
interface ToolDetail {
name: string;
goVersion?: string;
version?: string;
binPath?: string;
error?: Error;
}
async function getToolDetail(name: string): Promise<ToolDetail> {
const toolPath = getBinPath(name);
if (!path.isAbsolute(toolPath)) {
return { name: name };
}
try {
const { goVersion, moduleVersion } = await inspectGoToolVersion(toolPath);
return {
name: name,
binPath: toolPath,
goVersion: goVersion,
version: moduleVersion
};
} catch (e) {
return { name: name, error: e as Error };
}
}
const enum Time {
SECOND = 1000,
MINUTE = SECOND * 60,
HOUR = MINUTE * 60
}
interface CacheEntry<T> {
entry: T;
updatedAt: number;
}
class Cache<T> {
private cache = new Map<string, CacheEntry<T>>();
constructor(private fn: (key: string) => Promise<T>, private ttl: number) {}
async get(key: string, ttl = this.ttl) {
const cache = this.cache.get(key);
const useCache = cache && Date.now() - cache.updatedAt < ttl;
if (useCache) {
return cache.entry;
}
const entry = await this.fn(key);
this.cache.set(key, { entry, updatedAt: Date.now() });
return entry;
}
clear() {
return this.cache.clear();
}
delete(key: string) {
return this.cache.delete(key);
}
}
/**
* replaceHome replaces the home directory prefix of a string with `~`.
* @param maybePath a string that might be a file system path.
* @returns the string with os.homedir() replaced by `~`.
*/
function replaceHome(maybePath: string) {
return maybePath.replace(new RegExp(`^${os.homedir()}`), '~');
}