From 63852b8815a8dcba4a2fabb3b0306b996f38a9ad Mon Sep 17 00:00:00 2001 From: Lars Kaiser Date: Sun, 27 Sep 2026 23:23:36 +0200 Subject: [PATCH 1/5] fix: keep valid Pixi environments when one target is unsupported --- src/pixi/utils.ts | 9 ++- src/test/pixi.unit.test.ts | 126 +++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/test/pixi.unit.test.ts diff --git a/src/pixi/utils.ts b/src/pixi/utils.ts index f49d12e..f4f7020 100644 --- a/src/pixi/utils.ts +++ b/src/pixi/utils.ts @@ -113,7 +113,14 @@ export async function refreshPixi(projectPath: string): Promise { - const pixiPackages = await listPixiPackages(pixiEnv.name, projectPath); + let pixiPackages: PixiPackage[]; + try { + pixiPackages = await listPixiPackages(pixiEnv.name, projectPath); + } catch (error) { + traceInfo(`Skipping Pixi environment '${pixiEnv.name}': ${error}`); + return null; + } + const pythonPackage = pixiPackages.find((pkg) => pkg.name === 'python'); if (!pythonPackage) { diff --git a/src/test/pixi.unit.test.ts b/src/test/pixi.unit.test.ts new file mode 100644 index 0000000..1ce4dfe --- /dev/null +++ b/src/test/pixi.unit.test.ts @@ -0,0 +1,126 @@ +import * as assert from 'node:assert/strict'; +import { EventEmitter as NodeEventEmitter } from 'node:events'; +import * as path from 'node:path'; +import { test } from 'node:test'; + +type SpawnCall = { executable: string; args: string[]; options: { cwd?: string } }; +type SpawnResult = { stdout?: string; stderr?: string; exitCode?: number }; + +const calls: SpawnCall[] = []; +const infoMessages: string[] = []; +let configuredExecutable = 'pixi'; +let workspaceFolders: { uri: { fsPath: string } }[] = []; +let respond: (call: SpawnCall) => SpawnResult = () => ({ exitCode: 0 }); + +class TestEventEmitter { + event = () => undefined; + fire(_value: T) {} + dispose() {} +} + +const vscode = { + CancellationError: class extends Error {}, + EventEmitter: TestEventEmitter, + ProgressLocation: { Window: 1 }, + ThemeIcon: class { + constructor(readonly id: string) {} + }, + Uri: { file: (fsPath: string) => ({ fsPath }) }, + window: { + showErrorMessage: () => undefined, + withProgress: async (_options: unknown, task: () => Promise) => task(), + }, + workspace: { + getConfiguration: () => ({ get: () => configuredExecutable }), + get workspaceFolders() { + return workspaceFolders; + }, + }, +}; + +const childProcess = { + spawn(executable: string, args: string[], options: { cwd?: string }) { + const call = { executable, args, options }; + calls.push(call); + const proc = new NodeEventEmitter() as NodeEventEmitter & { + stdout: NodeEventEmitter; + stderr: NodeEventEmitter; + kill: () => void; + }; + proc.stdout = new NodeEventEmitter(); + proc.stderr = new NodeEventEmitter(); + proc.kill = () => undefined; + queueMicrotask(() => { + const result = respond(call); + if (result.stdout) { + proc.stdout.emit('data', result.stdout); + } + if (result.stderr) { + proc.stderr.emit('data', result.stderr); + } + proc.emit('exit', result.exitCode ?? 0); + proc.emit('close'); + }); + return proc; + }, +}; + +// VS Code is unavailable in Node's unit-test process. Load the production modules +// with only their host APIs replaced; the discovery and command logic remains real. +const loader = require('node:module') as { + _load: (request: string, parent?: NodeModule, isMain?: boolean) => unknown; +}; +const originalLoad = loader._load; +loader._load = function (request, parent, isMain) { + if (request === 'vscode') { + return vscode; + } + if (request === 'child_process') { + return childProcess; + } + return originalLoad.call(this, request, parent, isMain); +}; +const { refreshPixi } = require('../pixi/utils') as typeof import('../pixi/utils'); +const { registerLogger } = require('../common/logging') as typeof import('../common/logging'); +loader._load = originalLoad; + +registerLogger({ + info: (message: string) => void infoMessages.push(message), + error: () => undefined, +} as unknown as Parameters[0]); + +test('an unsupported Pixi environment does not hide valid Python environments', async () => { + calls.length = 0; + infoMessages.length = 0; + workspaceFolders = [{ uri: { fsPath: path.resolve('workspace') } }]; + const projectPath = path.resolve('workspace', 'project'); + respond = ({ args }) => { + if (args[0] === 'info') { + return { + stdout: JSON.stringify({ + project_info: { name: 'project', manifest_path: path.join(projectPath, 'pixi.toml') }, + environments_info: [ + { name: 'default', prefix: path.join(projectPath, '.pixi', 'envs', 'default') }, + { name: 'foreign', prefix: path.join(projectPath, '.pixi', 'envs', 'foreign') }, + { name: 'test', prefix: path.join(projectPath, '.pixi', 'envs', 'test') }, + ], + }), + }; + } + const envName = args.at(-1); + if (envName === 'foreign') { + return { stderr: "No packages found in 'foreign' environment for this platform", exitCode: 1 }; + } + return { stdout: JSON.stringify([{ name: 'python', version: '3.12.1', is_explicit: true }]) }; + }; + + const environments = await refreshPixi(projectPath); + + assert.deepEqual( + environments.map((environment) => environment.name), + ['project:default (3.12.1)', 'project:test (3.12.1)'], + ); + assert.equal(calls.length, 4); + assert.ok(calls.every((call) => call.options.cwd === projectPath)); + assert.ok(infoMessages.some((message) => message.includes("Skipping Pixi environment 'foreign'"))); +}); From ce6e8d793ff8df573aa51e0164be75f5fa2ee460 Mon Sep 17 00:00:00 2001 From: Lars Kaiser Date: Sun, 27 Sep 2026 23:25:10 +0200 Subject: [PATCH 2/5] fix: handle package refresh failures and use Pixi environment names --- src/pixi/projectManager.ts | 26 +++++++++++++++++++------ src/test/pixi.unit.test.ts | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/pixi/projectManager.ts b/src/pixi/projectManager.ts index b76b4a1..36e5bbe 100644 --- a/src/pixi/projectManager.ts +++ b/src/pixi/projectManager.ts @@ -20,7 +20,7 @@ import { window, } from 'vscode'; -import { traceVerbose } from '../common/logging'; +import { traceInfo, traceVerbose } from '../common/logging'; import { PixiEnvironment } from './types'; import { listPixiPackages, pixiPkgsToPackages } from './utils'; @@ -72,13 +72,27 @@ export class PixiPackageManager implements PackageManager, Disposable { } const projectPath = path.dirname(environment.pixiInfo.project_info.manifest_path); - const pixiPackages = await listPixiPackages(environment.name, projectPath); + const pixiEnvironment = environment.pixiInfo.environments_info.find( + (info) => info.prefix === environment.envId.id, + ); + if (!pixiEnvironment) { + traceInfo( + `Failed to refresh packages: Pixi environment '${environment.name}' is missing from project info`, + ); + return; + } - const before = environment.packages; - const after = pixiPkgsToPackages(pixiPackages, environment.envId.id); + try { + const pixiPackages = await listPixiPackages(pixiEnvironment.name, projectPath); - environment.packages = after; - this.triggerOnDidChangePackages(environment, before, after); + const before = environment.packages; + const after = pixiPkgsToPackages(pixiPackages, environment.envId.id); + + environment.packages = after; + this.triggerOnDidChangePackages(environment, before, after); + } catch (error) { + traceInfo(`Failed to refresh packages for environment '${environment.name}': ${error}`); + } }, ); } diff --git a/src/test/pixi.unit.test.ts b/src/test/pixi.unit.test.ts index 1ce4dfe..7acd4a3 100644 --- a/src/test/pixi.unit.test.ts +++ b/src/test/pixi.unit.test.ts @@ -3,6 +3,8 @@ import { EventEmitter as NodeEventEmitter } from 'node:events'; import * as path from 'node:path'; import { test } from 'node:test'; +import { PixiEnvironment } from '../pixi/types'; + type SpawnCall = { executable: string; args: string[]; options: { cwd?: string } }; type SpawnResult = { stdout?: string; stderr?: string; exitCode?: number }; @@ -78,15 +80,20 @@ loader._load = function (request, parent, isMain) { if (request === 'child_process') { return childProcess; } + if (request === '@vscode/python-environments') { + return { PackageChangeKind: { add: 1, remove: 2 } }; + } return originalLoad.call(this, request, parent, isMain); }; const { refreshPixi } = require('../pixi/utils') as typeof import('../pixi/utils'); +const { PixiPackageManager } = require('../pixi/projectManager') as typeof import('../pixi/projectManager'); const { registerLogger } = require('../common/logging') as typeof import('../common/logging'); loader._load = originalLoad; registerLogger({ info: (message: string) => void infoMessages.push(message), error: () => undefined, + debug: () => undefined, } as unknown as Parameters[0]); test('an unsupported Pixi environment does not hide valid Python environments', async () => { @@ -124,3 +131,35 @@ test('an unsupported Pixi environment does not hide valid Python environments', assert.ok(calls.every((call) => call.options.cwd === projectPath)); assert.ok(infoMessages.some((message) => message.includes("Skipping Pixi environment 'foreign'"))); }); + +test('package refresh uses the Pixi environment name and keeps existing packages on failure', async () => { + calls.length = 0; + infoMessages.length = 0; + const projectPath = path.resolve('workspace', 'project'); + const prefix = path.join(projectPath, '.pixi', 'envs', 'default'); + const packages = [{ name: 'python', version: '3.12.1' }]; + const environment = { + name: 'project:default (3.12.1)', + envId: { id: prefix }, + packages, + pixiInfo: { + project_info: { name: 'project', manifest_path: path.join(projectPath, 'pixi.toml') }, + environments_info: [{ name: 'default', prefix }], + }, + } as unknown as PixiEnvironment; + const manager = new PixiPackageManager( + {} as ConstructorParameters[0], + {} as ConstructorParameters[1], + ); + + respond = () => ({ stderr: 'Pixi list failed', exitCode: 1 }); + await manager.refresh(environment); + assert.equal(environment.packages, packages); + assert.equal(calls[0].args.at(-1), 'default'); + assert.equal(calls[0].options.cwd, projectPath); + assert.ok(infoMessages.some((message) => message.includes('Failed to refresh packages'))); + + respond = () => ({ stdout: JSON.stringify([{ name: 'python', version: '3.13.0', is_explicit: true }]) }); + await manager.refresh(environment); + assert.equal(environment.packages[0].version, '3.13.0'); +}); From 2365903512444c8bdafb06145abe43fb505c4526 Mon Sep 17 00:00:00 2001 From: Lars Kaiser Date: Sun, 27 Sep 2026 23:26:44 +0200 Subject: [PATCH 3/5] feat: resolve configured Pixi paths relative to workspaces --- src/pixi/utils.ts | 29 +++++++++++++++++++++++++---- src/test/pixi.unit.test.ts | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/pixi/utils.ts b/src/pixi/utils.ts index f4f7020..04b0a86 100644 --- a/src/pixi/utils.ts +++ b/src/pixi/utils.ts @@ -1,10 +1,12 @@ import { Package } from '@vscode/python-environments'; import * as ch from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; import { CancellationError, CancellationToken, ThemeIcon, Uri, window, workspace } from 'vscode'; import which from 'which'; import { createDeferred } from '../common/deferred'; -import { quoteArgs } from '../common/execUtils'; +import { quoteArgs, quoteStringIfNecessary } from '../common/execUtils'; import { findPythonExecutable } from '../common/findPython'; import { traceError, traceInfo, traceVerbose } from '../common/logging'; import { getWorkspacePersistentState } from '../common/persistentState'; @@ -27,7 +29,21 @@ export async function getPixi(): Promise { const value = config.get('pixiExecutable'); if (value) { - return untildify(value); + let resolved = untildify(value); + if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { + const firstFolder = workspace.workspaceFolders[0].uri.fsPath; + resolved = path.normalize(resolved.replace(/\$\{workspaceFolder\}/g, firstFolder)); + if (!path.isAbsolute(resolved)) { + for (const folder of workspace.workspaceFolders) { + const candidate = path.resolve(folder.uri.fsPath, resolved); + if (fs.existsSync(candidate)) { + resolved = candidate; + break; + } + } + } + } + return resolved; } const pixiPath = await findPixi(); @@ -48,7 +64,7 @@ async function _runPixi( ): Promise { const deferred = createDeferred(); args = quoteArgs(args); - const proc = ch.spawn(pixi, args, { shell: true, ...options }); + const proc = ch.spawn(quoteStringIfNecessary(pixi), args, { shell: true, ...options }); const cancelDisposable = token?.onCancellationRequested(() => { proc.kill(); @@ -87,7 +103,12 @@ async function _runPixi( export async function runPixi(args: string[], options?: ch.SpawnOptions, token?: CancellationToken): Promise { const pixi = await getPixi(); - return _runPixi(pixi, args, options, token); + const defaultCwd = workspace.workspaceFolders?.[0]?.uri.fsPath; + const spawnOptions: ch.SpawnOptions = { + ...(defaultCwd ? { cwd: defaultCwd } : {}), + ...options, + }; + return _runPixi(pixi, args, spawnOptions, token); } export async function listPixiPackages(envName: string, projectPath: string): Promise { diff --git a/src/test/pixi.unit.test.ts b/src/test/pixi.unit.test.ts index 7acd4a3..0582332 100644 --- a/src/test/pixi.unit.test.ts +++ b/src/test/pixi.unit.test.ts @@ -1,5 +1,7 @@ import * as assert from 'node:assert/strict'; import { EventEmitter as NodeEventEmitter } from 'node:events'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; import * as path from 'node:path'; import { test } from 'node:test'; @@ -85,7 +87,7 @@ loader._load = function (request, parent, isMain) { } return originalLoad.call(this, request, parent, isMain); }; -const { refreshPixi } = require('../pixi/utils') as typeof import('../pixi/utils'); +const { getPixi, refreshPixi, runPixi } = require('../pixi/utils') as typeof import('../pixi/utils'); const { PixiPackageManager } = require('../pixi/projectManager') as typeof import('../pixi/projectManager'); const { registerLogger } = require('../common/logging') as typeof import('../common/logging'); loader._load = originalLoad; @@ -163,3 +165,34 @@ test('package refresh uses the Pixi environment name and keeps existing packages await manager.refresh(environment); assert.equal(environment.packages[0].version, '3.13.0'); }); + +test('configured Pixi paths resolve from workspaces and commands use a workspace cwd', async () => { + calls.length = 0; + const root = await mkdtemp(path.join(os.tmpdir(), 'pixi code ')); + const firstFolder = path.join(root, 'first'); + const secondFolder = path.join(root, 'second'); + const secondExecutable = path.join(secondFolder, 'bin', 'pixi'); + try { + await mkdir(path.dirname(secondExecutable), { recursive: true }); + await mkdir(firstFolder); + await writeFile(secondExecutable, ''); + workspaceFolders = [{ uri: { fsPath: firstFolder } }, { uri: { fsPath: secondFolder } }]; + configuredExecutable = path.join('bin', 'pixi'); + assert.equal(await getPixi(), secondExecutable); + + respond = () => ({ stdout: 'pixi 0.67.0' }); + assert.equal(await runPixi(['--version']), 'pixi 0.67.0'); + assert.equal(calls[0].executable, `"${secondExecutable}"`); + assert.equal(calls[0].options.cwd, firstFolder); + + await runPixi(['--version'], { cwd: secondFolder }); + assert.equal(calls[1].options.cwd, secondFolder); + + configuredExecutable = '${workspaceFolder}/bin/pixi'; + assert.equal(await getPixi(), path.join(firstFolder, 'bin', 'pixi')); + } finally { + configuredExecutable = 'pixi'; + workspaceFolders = []; + await rm(root, { recursive: true, force: true }); + } +}); From d1b8b505815ba2347cc3fe86e79e87ff1669c997 Mon Sep 17 00:00:00 2001 From: Lars Kaiser Date: Sun, 27 Sep 2026 23:27:48 +0200 Subject: [PATCH 4/5] docs: document PR 52 port and bump version to 0.2.1 --- CHANGELOG.md | 7 +++++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 4 ++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a9de4..d2f31c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the "pixi-code" extension will be documented in this file The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.1] + +- Keep valid Python environments visible when a Pixi environment targets an unsupported platform +- Handle package refresh failures and use Pixi environment names for package queries +- Support relative paths and `${workspaceFolder}` in `pixi-code.pixiExecutable` +- Use the first workspace folder as the default working directory for Pixi commands + ## [0.2.0] - Use published @vscode/python-environments API package diff --git a/README.md b/README.md index 21cd0c8..9007a02 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The extension will automatically discover Pixi environments and register them wi ## Extension Settings -- `pixi-code.pixiExecutable`: Path to the Pixi executable. Leave empty to use auto-discovery (default). +- `pixi-code.pixiExecutable`: Path to the Pixi executable. Supports `${workspaceFolder}` and paths relative to a workspace folder. Leave empty to use auto-discovery (default). ## Limitations diff --git a/package-lock.json b/package-lock.json index 6cf5cc1..88e4c77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixi-code", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixi-code", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@types/semver": "^7.7.1", diff --git a/package.json b/package.json index 969d98f..532ec7b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Pixi support for the Python Environments extension", "icon": "assets/icon.png", "license": "MIT", - "version": "0.2.0", + "version": "0.2.1", "homepage": "https://github.com/kaiserls/pixi-code", "repository": { "type": "git", @@ -51,7 +51,7 @@ "pixi-code.pixiExecutable": { "type": "string", "default": "", - "description": "Path to the Pixi executable. Leave empty to use auto-discovery.", + "description": "Path to the Pixi executable. Supports ${workspaceFolder} and paths relative to a workspace folder. Leave empty to use auto-discovery.", "scope": "machine-overridable" } } From 764093373fc8a1d79bfcca56f661b9e8964948a4 Mon Sep 17 00:00:00 2001 From: Lars Kaiser Date: Mon, 28 Sep 2026 00:06:11 +0200 Subject: [PATCH 5/5] test: run configured Pixi executable from a spaced path --- src/test/pixi.unit.test.ts | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/test/pixi.unit.test.ts b/src/test/pixi.unit.test.ts index 0582332..a04db0f 100644 --- a/src/test/pixi.unit.test.ts +++ b/src/test/pixi.unit.test.ts @@ -1,13 +1,14 @@ import * as assert from 'node:assert/strict'; +import { spawn as realSpawn, SpawnOptions } from 'node:child_process'; import { EventEmitter as NodeEventEmitter } from 'node:events'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { test } from 'node:test'; import { PixiEnvironment } from '../pixi/types'; -type SpawnCall = { executable: string; args: string[]; options: { cwd?: string } }; +type SpawnCall = { executable: string; args: string[]; options: SpawnOptions }; type SpawnResult = { stdout?: string; stderr?: string; exitCode?: number }; const calls: SpawnCall[] = []; @@ -15,6 +16,7 @@ const infoMessages: string[] = []; let configuredExecutable = 'pixi'; let workspaceFolders: { uri: { fsPath: string } }[] = []; let respond: (call: SpawnCall) => SpawnResult = () => ({ exitCode: 0 }); +let useRealSpawn = false; class TestEventEmitter { event = () => undefined; @@ -43,9 +45,12 @@ const vscode = { }; const childProcess = { - spawn(executable: string, args: string[], options: { cwd?: string }) { + spawn(executable: string, args: string[], options: SpawnOptions) { const call = { executable, args, options }; calls.push(call); + if (useRealSpawn) { + return realSpawn(executable, args, options); + } const proc = new NodeEventEmitter() as NodeEventEmitter & { stdout: NodeEventEmitter; stderr: NodeEventEmitter; @@ -196,3 +201,28 @@ test('configured Pixi paths resolve from workspaces and commands use a workspace await rm(root, { recursive: true, force: true }); } }); + +test('a configured executable in a path with spaces can run through the shell', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'pixi executable ')); + const executable = path.join(root, process.platform === 'win32' ? 'pixi.cmd' : 'pixi'); + try { + const script = + process.platform === 'win32' + ? '@echo off\r\necho pixi-test %1\r\n' + : '#!/bin/sh\nprintf "pixi-test %s\\n" "$1"\n'; + await writeFile(executable, script); + if (process.platform !== 'win32') { + await chmod(executable, 0o755); + } + configuredExecutable = executable; + workspaceFolders = [{ uri: { fsPath: root } }]; + useRealSpawn = true; + + assert.equal((await runPixi(['--version'])).trim(), 'pixi-test --version'); + } finally { + useRealSpawn = false; + configuredExecutable = 'pixi'; + workspaceFolders = []; + await rm(root, { recursive: true, force: true }); + } +});