diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5541b..ccbd04c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to the "Conda Wingman" extension will be documented in this Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## [1.2.0] - 2026-04 + +### Added +- **Clickable package links** — package names in YAML files link to anaconda.org (conda deps) or PyPI (pip deps) +- **Hover tooltips** — hover over packages to see description and latest version from PyPI +- **Switch Environment command** — quick pick of all conda envs, activate any one from the palette +- **Active env in status bar** — shows conda env name and Python version, click to switch +- **Auto-set Python interpreter** — automatically sets `python.defaultInterpreterPath` from detected conda env +- `condaWingman.autoSetInterpreter` setting to control interpreter auto-setting + ## [1.1.0] - 2026-04 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2e16cac..564af20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,8 +21,11 @@ No build step — the extension runs directly from JS source files. ``` extension.js (activate: check conda, register commands, wire status bar, config listener) - ├── commands.js (4 command handlers: build, activate, export, delete) - ├── statusBarItems.js (CustomStatusBarItem class, 4 items, showAll/hideAll) + ├── commands.js (5 command handlers: build, activate, export, delete, switch) + ├── statusBarItems.js (CustomStatusBarItem class, 4 command items + env info item, showAll/hideAll) + ├── interpreter.js (find conda env Python path, set python.defaultInterpreterPath) + ├── documentLinks.js (clickable package links in YAML → anaconda.org / PyPI) + ├── codeLens.js (hover tooltips with PyPI package info) ├── config.js (reads condaWingman.* settings) └── utils.js (sendCommandToTerminal, YAML helpers, activeFileIsYAML) ``` diff --git a/README.md b/README.md index 7381650..1f2f3a6 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,28 @@ The supported commands are: ``` - **VS Code Command Palette:** `>Conda Wingman: Delete Conda Environment` +### Switch Environment +- **Command:** Pick from all available conda environments and activate the selected one. +- **VS Code Command Palette:** `>Conda Wingman: Switch Environment` + +### Clickable Package Links +Package names in conda environment YAML files are clickable — conda packages link to [anaconda.org](https://anaconda.org), pip sub-dependencies link to [PyPI](https://pypi.org). + +### Hover Tooltips +Hover over a package name in a YAML file to see its description and latest version from PyPI. + +### Active Environment in Status Bar +The status bar shows the current conda environment name and Python version, detected from the open YAML file. Click it to switch environments. + +### Auto-Set Python Interpreter +When a conda environment is detected, the workspace Python interpreter (`python.defaultInterpreterPath`) is automatically set to point to it. + ## Settings | Setting | Default | Description | |---|---|---| | `condaWingman.showStatusBarItems` | `true` | Show/hide status bar buttons | +| `condaWingman.autoSetInterpreter` | `true` | Auto-set workspace Python interpreter from conda env | ## Release Notes diff --git a/package.json b/package.json index 6e6471f..7ba66ad 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "conda-wingman", "displayName": "Conda Wingman", "description": "Status bar buttons and commands for managing Conda environments from YAML files.", - "version": "1.1.0", + "version": "1.2.0", "publisher": "DJSaunders1997", "engines": { "vscode": "^1.86.0" @@ -31,7 +31,8 @@ "onCommand:conda-wingman.buildCondaYAML", "onCommand:conda-wingman.activateCondaYAML", "onCommand:conda-wingman.writeRequirementsFile", - "onCommand:conda-wingman.deleteCondaEnv" + "onCommand:conda-wingman.deleteCondaEnv", + "onCommand:conda-wingman.switchEnvironment" ], "main": "./src/extension.js", "contributes": { @@ -51,6 +52,10 @@ { "command": "conda-wingman.deleteCondaEnv", "title": "Conda Wingman: Delete Environment" + }, + { + "command": "conda-wingman.switchEnvironment", + "title": "Conda Wingman: Switch Environment" } ], "configuration": { @@ -60,6 +65,11 @@ "type": "boolean", "default": true, "description": "Show Conda Wingman buttons in the status bar when a YAML file is open." + }, + "condaWingman.autoSetInterpreter": { + "type": "boolean", + "default": true, + "description": "Automatically set the workspace Python interpreter when a conda environment is detected." } } } diff --git a/src/codeLens.js b/src/codeLens.js new file mode 100644 index 0000000..f0ff9ce --- /dev/null +++ b/src/codeLens.js @@ -0,0 +1,156 @@ +// Provides hover tooltips on package names in conda environment YAML files, +// showing package description and latest version from PyPI. + +const vscode = require("vscode"); +const https = require("https"); +const { findCondaDepPositions } = require("./documentLinks"); + +/** Simple in-memory cache: pkgName -> { version, summary, fetchedAt } */ +const cache = new Map(); +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** Invisible decoration type used only to attach hover tooltips to package names */ +const hoverDecorationType = vscode.window.createTextEditorDecorationType({}); + +/** + * Fetches package info from the PyPI JSON API. + * Returns { version, summary } or null on failure. + */ +function fetchPypiInfo(pkgName) { + return new Promise(resolve => { + const url = `https://pypi.org/pypi/${encodeURIComponent(pkgName)}/json`; + const req = https.get(url, { timeout: 5000 }, res => { + if (res.statusCode !== 200) { + res.resume(); + resolve(null); + return; + } + let data = ""; + res.on("data", chunk => { data += chunk; }); + res.on("end", () => { + try { + const json = JSON.parse(data); + resolve({ + version: json.info.version, + summary: json.info.summary || "", + }); + } catch { + resolve(null); + } + }); + }); + req.on("error", () => resolve(null)); + req.on("timeout", () => { req.destroy(); resolve(null); }); + }); +} + +/** + * Returns cached PyPI info or fetches it, with TTL-based expiry. + */ +async function getPypiInfo(pkgName) { + const cached = cache.get(pkgName); + if (cached && (Date.now() - cached.fetchedAt) < CACHE_TTL_MS) { + return cached; + } + const info = await fetchPypiInfo(pkgName); + if (info) { + cache.set(pkgName, { ...info, fetchedAt: Date.now() }); + } + return info; +} + +/** Track the current update so we can cancel stale runs */ +let updateCounter = 0; + +/** + * Updates hover decorations for the given text editor. + * Only applies to YAML files that look like conda environment files. + */ +async function updateDecorations(editor) { + if (!editor) return; + + const filePath = editor.document.uri.path; + if (!filePath.endsWith(".yml") && !filePath.endsWith(".yaml")) return; + + const thisUpdate = ++updateCounter; + const text = editor.document.getText(); + const positions = findCondaDepPositions(text); + + if (positions.length === 0) { + editor.setDecorations(hoverDecorationType, []); + return; + } + + const results = await Promise.all( + positions.map(async (pos) => { + const info = await getPypiInfo(pos.name); + return { ...pos, info }; + }) + ); + + // Check we haven't been superseded by a newer update + if (thisUpdate !== updateCounter) return; + if (editor !== vscode.window.activeTextEditor) return; + + const hoverDecorations = []; + + for (const { lineNum, colStart, colEnd, name, isPip, info } of results) { + if (!info) continue; + + // Build hover tooltip content + const parts = []; + if (info.summary) parts.push(info.summary); + parts.push(`**Latest version:** ${info.version}`); + + // Link to the right package index + if (isPip) { + parts.push(`[View on PyPI](https://pypi.org/project/${name}/)`); + } else { + parts.push(`[View on Anaconda](https://anaconda.org/conda-forge/${name})`); + parts.push(`[View on PyPI](https://pypi.org/project/${name}/)`); + } + + hoverDecorations.push({ + range: new vscode.Range(lineNum, colStart, lineNum, colEnd), + hoverMessage: new vscode.MarkdownString(parts.join("\n\n")), + }); + } + + editor.setDecorations(hoverDecorationType, hoverDecorations); +} + +/** Debounce timer for document changes */ +let debounceTimer; + +/** + * Registers hover tooltip decorations for conda YAML files. + * @param {vscode.ExtensionContext} context + */ +function registerCodeLens(context) { + // Decorate the active editor if it's a YAML file + if (vscode.window.activeTextEditor) { + updateDecorations(vscode.window.activeTextEditor); + } + + // Re-decorate when switching editors + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor) { + updateDecorations(editor); + } + }) + ); + + // Re-decorate on document changes (debounced) + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(e => { + const editor = vscode.window.activeTextEditor; + if (editor && e.document === editor.document) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => updateDecorations(editor), 1000); + } + }) + ); +} + +module.exports = { registerCodeLens }; diff --git a/src/commands.js b/src/commands.js index 4b7eb44..c47b55b 100644 --- a/src/commands.js +++ b/src/commands.js @@ -1,5 +1,6 @@ const vscode = require("vscode"); const path = require("path"); +const { execSync } = require("child_process"); const { sendCommandToTerminal, @@ -9,6 +10,8 @@ const { deleteEnvFromYAML, createYAMLInputBox, } = require("./utils"); +const { getCondaInterpreterPath, setWorkspacePythonInterpreter } = require("./interpreter"); +const { getConfig } = require("./config"); const { activateEnvIcon, writeEnvIcon, @@ -102,9 +105,61 @@ async function writeRequirementsFile() { writeEnvIcon.displayDefault(); } +/** + * Shows a quick pick of all conda environments and activates the selected one. + * Runs `conda env list --json` to get the list of environments. + * Also sets the workspace Python interpreter if autoSetInterpreter is enabled. + */ +async function switchEnvironment() { + let envs; + try { + const output = execSync("conda env list --json", { encoding: "utf8", timeout: 10000 }); + const parsed = JSON.parse(output); + envs = parsed.envs || []; + } catch { + vscode.window.showErrorMessage("Failed to list conda environments. Is conda installed?"); + return; + } + + if (envs.length === 0) { + vscode.window.showInformationMessage("No conda environments found."); + return; + } + + // Build quick pick items from env paths + // Each path looks like /home/user/miniconda3/envs/myenv or /home/user/miniconda3 (base) + const items = envs.map(envPath => { + const name = path.basename(envPath); + // The base env is the conda root dir itself, detect it + const isBase = !envPath.includes(path.join("envs", name)); + return { + label: isBase ? "base" : name, + description: envPath, + envPath: envPath, + }; + }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: "Select a conda environment to activate", + }); + + if (!selected) return; + + sendCommandToTerminal(`conda activate ${selected.label}`); + + // Auto-set interpreter if enabled + if (getConfig().autoSetInterpreter) { + const interpreterPath = getCondaInterpreterPath(selected.label); + if (interpreterPath) { + setWorkspacePythonInterpreter(interpreterPath).catch(() => {}); + } + } +} + module.exports = { buildCondaYAML, activateCondaYAML, writeRequirementsFile, deleteCondaEnv, + switchEnvironment, }; diff --git a/src/config.js b/src/config.js index 4236e17..206088d 100644 --- a/src/config.js +++ b/src/config.js @@ -7,6 +7,7 @@ function getConfig() { const cfg = vscode.workspace.getConfiguration('condaWingman'); return { showStatusBarItems: cfg.get('showStatusBarItems', true), + autoSetInterpreter: cfg.get('autoSetInterpreter', true), }; } diff --git a/src/documentLinks.js b/src/documentLinks.js new file mode 100644 index 0000000..2ab7422 --- /dev/null +++ b/src/documentLinks.js @@ -0,0 +1,103 @@ +// Provides clickable links for package names in conda environment YAML files. +// Conda packages link to anaconda.org, pip sub-dependencies link to PyPI. + +const vscode = require("vscode"); +const yaml = require("js-yaml"); + +const ANACONDA_BASE = "https://anaconda.org/conda-forge/"; +const PYPI_BASE = "https://pypi.org/project/"; + +/** + * Parses a conda environment YAML and returns dependency positions. + * Returns [{ lineNum, colStart, colEnd, name, isPip }] + */ +function findCondaDepPositions(text) { + let doc; + try { + doc = yaml.load(text); + } catch { + return []; + } + + if (!doc || !Array.isArray(doc.dependencies)) return []; + + // Collect all known dependency names so we can find them in the text + const condaNames = new Set(); + const pipNames = new Set(); + + for (const dep of doc.dependencies) { + if (typeof dep === "string") { + // Conda dep like "numpy=1.24.0" or "python>=3.10" + const name = dep.split(/[=<>!~\s]/)[0].trim(); + if (name) condaNames.add(name); + } else if (typeof dep === "object" && Array.isArray(dep.pip)) { + // Pip sub-dependencies + for (const pipDep of dep.pip) { + if (typeof pipDep === "string") { + const name = pipDep.split(/[=<>!~\s\[]/)[0].trim(); + if (name) pipNames.add(name); + } + } + } + } + + // Scan lines to find positions of known package names + const results = []; + const lines = text.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Match lines like " - packagename" or " - packagename=1.2.3" or " - packagename>=1.0" + const depMatch = line.match(/^(\s*-\s+)([a-zA-Z0-9][\w.-]*)/); + if (!depMatch) continue; + + const name = depMatch[2]; + const colStart = depMatch[1].length; + const colEnd = colStart + name.length; + + if (condaNames.has(name)) { + results.push({ lineNum: i, colStart, colEnd, name, isPip: false }); + } else if (pipNames.has(name)) { + results.push({ lineNum: i, colStart, colEnd, name, isPip: true }); + } + } + + return results; +} + +/** + * DocumentLinkProvider for conda environment YAML files. + * Links conda packages to anaconda.org and pip packages to PyPI. + */ +class CondaYAMLLinkProvider { + provideDocumentLinks(document) { + const text = document.getText(); + const positions = findCondaDepPositions(text); + + return positions.map(pos => { + const range = new vscode.Range( + new vscode.Position(pos.lineNum, pos.colStart), + new vscode.Position(pos.lineNum, pos.colEnd) + ); + // Conda packages go to anaconda.org, pip packages go to PyPI + const base = pos.isPip ? PYPI_BASE : ANACONDA_BASE; + const uri = vscode.Uri.parse(base + pos.name); + return new vscode.DocumentLink(range, uri); + }); + } +} + +/** + * Registers the document link provider for conda YAML files. + * @param {vscode.ExtensionContext} context + */ +function registerDocumentLinks(context) { + // Register for all YAML files — findCondaDepPositions returns empty + // for non-conda YAML files (no dependencies key) so this is safe. + context.subscriptions.push( + vscode.languages.registerDocumentLinkProvider({ language: "yaml" }, new CondaYAMLLinkProvider()) + ); +} + +module.exports = { registerDocumentLinks, findCondaDepPositions }; diff --git a/src/extension.js b/src/extension.js index 3844f0b..4297769 100644 --- a/src/extension.js +++ b/src/extension.js @@ -9,9 +9,13 @@ const { activateCondaYAML, writeRequirementsFile, deleteCondaEnv, + switchEnvironment, } = require("./commands"); const { showAllStatusBarItems, hideAllStatusBarItems } = require("./statusBarItems"); const { getConfig } = require("./config"); +const { getCondaInterpreterPath, setWorkspacePythonInterpreter } = require("./interpreter"); +const { registerDocumentLinks } = require("./documentLinks"); +const { registerCodeLens } = require("./codeLens"); /** * Checks whether the `conda` CLI is available on PATH. @@ -73,7 +77,38 @@ function activate(context) { deleteCondaEnv ); - context.subscriptions.push(buildCommand, activateCommand, writeCommand, deleteCommand); + const switchCommand = vscode.commands.registerCommand( + "conda-wingman.switchEnvironment", + switchEnvironment + ); + + context.subscriptions.push(buildCommand, activateCommand, writeCommand, deleteCommand, switchCommand); + + // Register clickable package links in YAML files + registerDocumentLinks(context); + + // Register hover tooltips with PyPI info + registerCodeLens(context); + + // Auto-set interpreter on activation if enabled + if (getConfig().autoSetInterpreter) { + const editor = vscode.window.activeTextEditor; + if (editor) { + const yaml = require("js-yaml"); + const fs = require("fs"); + try { + const doc = yaml.load(fs.readFileSync(editor.document.fileName, "utf8")); + if (doc && doc.name) { + const interpreterPath = getCondaInterpreterPath(doc.name); + if (interpreterPath) { + setWorkspacePythonInterpreter(interpreterPath).catch(() => {}); + } + } + } catch { + // Not a valid YAML or no env name, ignore + } + } + } // Show or hide status bar based on setting if (getConfig().showStatusBarItems) { diff --git a/src/interpreter.js b/src/interpreter.js new file mode 100644 index 0000000..66e88d3 --- /dev/null +++ b/src/interpreter.js @@ -0,0 +1,103 @@ +// Handles Python interpreter detection and auto-setting for conda environments. + +const vscode = require("vscode"); +const { execSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); + +/** + * Gets the base path for conda environments by running `conda info --json`. + * Returns the first envs directory, or null if conda is unavailable. + */ +function getCondaEnvsDir() { + try { + const output = execSync("conda info --json", { encoding: "utf8", timeout: 10000 }); + const info = JSON.parse(output); + // conda info returns an array of envs directories + if (info.envs_dirs && info.envs_dirs.length > 0) { + return info.envs_dirs[0]; + } + } catch { + // conda not available or failed + } + return null; +} + +/** + * Finds the Python interpreter path for a given conda environment name. + * Checks the standard conda envs directory structure. + * @param {string} envName - Name of the conda environment + * @returns {string|null} Path to the Python binary, or null if not found + */ +function getCondaInterpreterPath(envName) { + if (!envName) return null; + + const envsDir = getCondaEnvsDir(); + if (!envsDir) return null; + + const envPath = path.join(envsDir, envName); + + // Check platform-specific Python binary locations + let candidates; + if (process.platform === "win32") { + candidates = [ + path.join(envPath, "python.exe"), + path.join(envPath, "Scripts", "python.exe"), + ]; + } else { + candidates = [ + path.join(envPath, "bin", "python"), + path.join(envPath, "bin", "python3"), + ]; + } + + for (const p of candidates) { + if (fs.existsSync(p)) { + return p; + } + } + + return null; +} + +/** + * Update workspace Python interpreter settings to point to the given interpreter path. + * Uses the modern "python.defaultInterpreterPath" setting. + */ +async function setWorkspacePythonInterpreter(interpreterPath) { + if (!interpreterPath) return false; + try { + const pythonConfig = vscode.workspace.getConfiguration("python"); + await pythonConfig.update("defaultInterpreterPath", interpreterPath, vscode.ConfigurationTarget.Workspace); + console.log(`Successfully set workspace Python interpreter to: ${interpreterPath}`); + return true; + } catch (err) { + console.error("Failed to set workspace interpreter:", err); + return false; + } +} + +/** + * Gets the Python version string from a conda environment's Python binary. + * @param {string} envName - Name of the conda environment + * @returns {string|null} Version string like "3.12.1", or null + */ +function getCondaPythonVersion(envName) { + const interpreterPath = getCondaInterpreterPath(envName); + if (!interpreterPath) return null; + + try { + const output = execSync(`"${interpreterPath}" --version`, { encoding: "utf8", timeout: 5000 }).trim(); + // Output is like "Python 3.12.1" + return output.replace("Python ", ""); + } catch { + return null; + } +} + +module.exports = { + getCondaEnvsDir, + getCondaInterpreterPath, + setWorkspacePythonInterpreter, + getCondaPythonVersion, +}; diff --git a/src/statusBarItems.js b/src/statusBarItems.js index 094992b..ca14770 100644 --- a/src/statusBarItems.js +++ b/src/statusBarItems.js @@ -1,5 +1,6 @@ const vscode = require("vscode"); -const { activeFileIsYAML } = require("./utils"); +const { activeFileIsYAML, getEnvNameFromYAML } = require("./utils"); +const { getCondaPythonVersion } = require("./interpreter"); /** * Class to extend the vscode createStatusBarItem with additional functionality. @@ -67,6 +68,39 @@ const deleteEnvIcon = new CustomStatusBarItem( "conda-wingman.deleteCondaEnv" ); +// Status bar item showing active conda env name and Python version. +// Clicking it opens the switch environment picker. +const envInfoItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); +envInfoItem.command = "conda-wingman.switchEnvironment"; +envInfoItem.tooltip = "Click to switch conda environment"; +envInfoItem.text = "$(snake) conda: --"; + +/** + * Reads the env name from the currently open YAML file and updates the + * env info status bar item with the environment name and Python version. + */ +function updateEnvInfo() { + const editor = vscode.window.activeTextEditor; + if (!editor || !activeFileIsYAML()) { + // Keep showing last known state, don't clear it + return; + } + + const filePath = editor.document.fileName; + const envName = getEnvNameFromYAML(filePath); + if (!envName) { + envInfoItem.text = "$(snake) conda: --"; + return; + } + + const version = getCondaPythonVersion(envName); + if (version) { + envInfoItem.text = `$(snake) conda: ${envName} (${version})`; + } else { + envInfoItem.text = `$(snake) conda: ${envName}`; + } +} + // All items that participate in show/hide grouping const allItems = [createEnvIcon, activateEnvIcon, writeEnvIcon, deleteEnvIcon]; @@ -74,15 +108,18 @@ function showAllStatusBarItems() { for (const item of allItems) { item.displayDefault(); } + envInfoItem.show(); + updateEnvInfo(); } function hideAllStatusBarItems() { for (const item of allItems) { item.statusBar.hide(); } + envInfoItem.hide(); } module.exports = { createEnvIcon, activateEnvIcon, writeEnvIcon, deleteEnvIcon, - showAllStatusBarItems, hideAllStatusBarItems, + envInfoItem, showAllStatusBarItems, hideAllStatusBarItems, updateEnvInfo, }; diff --git a/src/utils.js b/src/utils.js index 7eb591a..85297d4 100644 --- a/src/utils.js +++ b/src/utils.js @@ -162,6 +162,7 @@ module.exports = { sendCommandToTerminal, activeFileIsYAML, getOpenDocumentPath, + getEnvNameFromYAML, activateEnvFromYAML, createYAMLInputBox, deleteEnvFromYAML