src/goExplorer.ts (300 lines of code) (raw):

/*--------------------------------------------------------- * 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()}`), '~'); }