From 4dd68dfc7e5758a6f77c271af319de02f0de0038 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Tue, 24 Mar 2026 18:46:21 +0100 Subject: [PATCH 1/9] feat: package manager --- src/{hatch-cli.ts => cli/hatch.ts} | 33 ++------- src/cli/index.ts | 32 ++++++++ src/cli/installer.ts | 45 ++++++++++++ src/extension.ts | 7 +- src/hatch-env-manager.ts | 43 +++++------ src/hatch-pkg-manager.ts | 113 +++++++++++++++++++++++++++++ 6 files changed, 218 insertions(+), 55 deletions(-) rename src/{hatch-cli.ts => cli/hatch.ts} (64%) create mode 100644 src/cli/index.ts create mode 100644 src/cli/installer.ts create mode 100644 src/hatch-pkg-manager.ts diff --git a/src/hatch-cli.ts b/src/cli/hatch.ts similarity index 64% rename from src/hatch-cli.ts rename to src/cli/hatch.ts index baa389d..840cc52 100644 --- a/src/hatch-cli.ts +++ b/src/cli/hatch.ts @@ -1,13 +1,4 @@ -import { - type ExecFileException, - execFile as execFileCb, - type ProcessEnvOptions, -} from 'node:child_process' -import { promisify } from 'node:util' -import type { Uri } from 'vscode' -import { traceError } from './common/logging.js' - -const execFile = promisify(execFileCb) +import { run } from './index.js' export interface HatchEnvInfo { name: string @@ -55,30 +46,16 @@ export async function findEnv(name: string, cwd: string): Promise { export async function createEnv( name: string, - fspath: string, + cwd: string, { existOk = false }: { existOk?: boolean } = {}, ): Promise { const args = existOk ? ['-e', name, 'run', 'python', '-V'] : ['env', 'create', name] - await run('hatch', args, { cwd: fspath }) + await run('hatch', args, { cwd }) } -export async function removeEnv(name: string, scope: Uri): Promise { - await run('hatch', ['env', 'remove', name], { cwd: scope.fsPath }) +export async function removeEnv(name: string, cwd: string): Promise { + await run('hatch', ['env', 'remove', name], { cwd }) } -async function run( - cmd: string, - args: string[], - opts: ProcessEnvOptions, -): Promise { - try { - const { stdout } = await execFile(cmd, args, opts) - return stdout - } catch (e) { - const err = e as ExecFileException - traceError(err, err.stderr) - throw err - } -} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..eb08cf9 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,32 @@ +import { + type ExecFileException, + execFile as execFileCb, + type ProcessEnvOptions, +} from 'node:child_process' +import paths from 'node:path' +import { promisify } from 'node:util' +import { traceError } from '../common/logging.js' +import { isWindows } from '../common/platform.js' + +const execFile = promisify(execFileCb) + +export async function run( + cmd: string, + args: string[], + opts: ProcessEnvOptions = {}, +): Promise { + try { + const { stdout } = await execFile(cmd, args, opts) + return stdout + } catch (e) { + const err = e as ExecFileException + traceError(err, err.stderr) + throw err + } +} + +export function envBin(envPath: string, name: string): string { + return isWindows() + ? paths.join(envPath, 'Scripts', `${name}.exe`) + : paths.join(envPath, 'bin', name) +} diff --git a/src/cli/installer.ts b/src/cli/installer.ts new file mode 100644 index 0000000..6b2b504 --- /dev/null +++ b/src/cli/installer.ts @@ -0,0 +1,45 @@ +import { envBin, run } from './index.js' + +async function runPipOrUv( + envPath: string, + installer: 'uv' | 'pip', + args: string[], +): Promise { + if (installer === 'uv') { + return run('uv', [ + 'pip', + ...args, + `--python=${envBin(envPath, 'python')}`, + ]) + } + return run(envBin(envPath, 'pip'), [ + ...args, + ...(args[0] === 'uninstall' ? ['--yes'] : []), + ]) +} + +export async function listPackages( + envPath: string, + installer: 'uv' | 'pip', +): Promise<{ name: string; version: string }[]> { + const json = await runPipOrUv(envPath, installer, ['list', '--format=json']) + return JSON.parse(json) as { name: string; version: string }[] +} + +export async function installPackages( + envPath: string, + packages: string[], + installer: 'uv' | 'pip', + { upgrade = false }: { upgrade?: boolean } = {}, +): Promise { + const args = [...(upgrade ? ['--upgrade'] : []), ...packages] + await runPipOrUv(envPath, installer, ['install', ...args]) +} + +export async function uninstallPackages( + envPath: string, + packages: string[], + installer: 'uv' | 'pip', +): Promise { + await runPipOrUv(envPath, installer, ['uninstall', ...packages]) +} diff --git a/src/extension.ts b/src/extension.ts index a86bfb1..c7d7fa0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,6 +2,7 @@ import { type ExtensionContext, window } from 'vscode' import { registerLogger } from './common/logging.js' import { setWorkspacePersistentState } from './common/persistent-state.js' import { HatchEnvManager } from './hatch-env-manager.js' +import { HatchPackageManager } from './hatch-pkg-manager.js' import { getEnvExtApi } from './python-envs-api.js' export async function activate(context: ExtensionContext) { @@ -12,7 +13,11 @@ export async function activate(context: ExtensionContext) { const api = await getEnvExtApi() await setWorkspacePersistentState(context) // resolves instantly const envManager = new HatchEnvManager(api, log) - context.subscriptions.push(api.registerEnvironmentManager(envManager)) + const pkgManager = new HatchPackageManager(api, log) + context.subscriptions.push( + api.registerEnvironmentManager(envManager), + api.registerPackageManager(pkgManager), + ) } export function deactivate() {} diff --git a/src/hatch-env-manager.ts b/src/hatch-env-manager.ts index 818b580..2a4a5a0 100644 --- a/src/hatch-env-manager.ts +++ b/src/hatch-env-manager.ts @@ -1,19 +1,16 @@ -import paths from 'node:path' import { EventEmitter, - type IconPath, type LogOutputChannel, - type MarkdownString, ProgressLocation, ThemeIcon, Uri, window, } from 'vscode' -import { HATCH_ID, HATCH_NAME } from './common/constants.js' +import * as hatch from './cli/hatch.js' +import { envBin } from './cli/index.js' +import { HATCH_ID, HATCH_MANAGER_ID, HATCH_NAME } from './common/constants.js' import { createDeferred, type Deferred } from './common/deferred.js' import { traceVerbose } from './common/logging.js' -import { isWindows } from './common/platform.js' -import * as hatch from './hatch-cli.js' import { clearExtensionCache, getGlobalEnvId, @@ -57,9 +54,11 @@ function syncHatchEnv( } export class HatchEnvManager implements EnvironmentManager { - #globalEnv: PythonEnvironment | undefined - #activeEnv = new Map() // Selected environment for each project - #projectToEnvs = new Map() // Maps a project path to its `hatch env show` output + readonly name = HATCH_ID + readonly displayName = HATCH_NAME + readonly preferredPackageManagerId = HATCH_MANAGER_ID + readonly tooltip = 'Hatch Environment Manager' + readonly iconPath = new ThemeIcon('hatch-logo') readonly #onDidChangeEnvironment = new EventEmitter() @@ -74,21 +73,17 @@ export class HatchEnvManager implements EnvironmentManager { public readonly log: LogOutputChannel, ) { this.#api = api - this.name = HATCH_ID - this.displayName = HATCH_NAME - this.preferredPackageManagerId = 'ms-python.python:pip' // HATCH_MANAGER_ID - this.tooltip = 'Hatch Environment Manager' - this.iconPath = new ThemeIcon('hatch-logo') + this.#globalEnv = undefined + this.#activeEnv = new Map() + this.#projectToEnvs = new Map() } readonly #api: PythonEnvironmentApi - - readonly name: string - readonly displayName: string - readonly preferredPackageManagerId: string - readonly description?: string - readonly tooltip: string | MarkdownString - readonly iconPath?: IconPath + #globalEnv: PythonEnvironment | undefined + /** Selected environment for each project */ + #activeEnv: Map + /** Maps a project path to its `hatch env show` output */ + #projectToEnvs: Map dispose() { this.#onDidChangeEnvironment.dispose() @@ -383,10 +378,6 @@ export class HatchEnvManager implements EnvironmentManager { conf, path, }: hatch.HatchEnvInfo): HatchEnvironment { - const executable = isWindows() - ? paths.join(path, 'Scripts', 'python.exe') - : paths.join(path, 'bin', 'python') - const shellActivation: Map = new Map() const shellDeactivation: Map = @@ -407,7 +398,7 @@ export class HatchEnvManager implements EnvironmentManager { sysPrefix: path, version: '1', // TODO execInfo: { - run: { executable }, + run: { executable: envBin(path, 'python') }, shellActivation, shellDeactivation, }, diff --git a/src/hatch-pkg-manager.ts b/src/hatch-pkg-manager.ts new file mode 100644 index 0000000..8fedb77 --- /dev/null +++ b/src/hatch-pkg-manager.ts @@ -0,0 +1,113 @@ +import { EventEmitter, type LogOutputChannel, ThemeIcon } from 'vscode' +import { + installPackages, + listPackages, + uninstallPackages, +} from './cli/installer.js' +import { HATCH_ID, HATCH_NAME } from './common/constants.js' +import { isHatchEnv } from './hatch-env-manager.js' +import { + type DidChangePackagesEventArgs, + type Package, + PackageChangeKind, + type PackageManagementOptions, + type PackageManager, + type PythonEnvironment, + type PythonEnvironmentApi, +} from './vscode-python-environments/index.js' + +export class HatchPackageManager implements PackageManager { + readonly name = HATCH_ID + readonly displayName = HATCH_NAME + readonly tooltip = 'Hatch Package Manager' + readonly iconPath = new ThemeIcon('hatch-logo') + + readonly #onDidChangePackages: EventEmitter = + new EventEmitter() + readonly onDidChangePackages = this.#onDidChangePackages.event + + constructor( + api: PythonEnvironmentApi, + readonly log: LogOutputChannel, + ) { + this.#api = api + this.#packages = new Map() + } + + readonly #api: PythonEnvironmentApi + readonly #packages: Map + + dispose() { + this.#onDidChangePackages.dispose() + this.#packages.clear() + } + + async manage( + environment: PythonEnvironment, + { upgrade, install = [], uninstall = [] }: PackageManagementOptions, + ): Promise { + if (!isHatchEnv(environment)) return + const { + path: envPath, + conf: { installer }, + } = environment.hatch + + if (install.length > 0) { + await installPackages(envPath, install, installer, { + upgrade, + }) + } + if (uninstall.length > 0) { + await uninstallPackages(envPath, uninstall, installer) + } + + await this.refresh(environment) + } + + async refresh(environment: PythonEnvironment): Promise { + if (!isHatchEnv(environment)) return + const { + path: envPath, + conf: { installer }, + } = environment.hatch + + const raw = await listPackages(envPath, installer) + const packages = raw.map(({ name, version }) => + this.#api.createPackageItem( + { name, displayName: name, version }, + environment, + this, + ), + ) + + const oldPackages = this.#packages.get(envPath) ?? [] + this.#packages.set(envPath, packages) + + const oldIds = new Set(oldPackages.map((p) => p.pkgId.id)) + const newIds = new Set(packages.map((p) => p.pkgId.id)) + + const changes: DidChangePackagesEventArgs['changes'] = [ + ...oldPackages + .filter((p) => !newIds.has(p.pkgId.id)) + .map((pkg) => ({ kind: PackageChangeKind.remove, pkg })), + ...packages + .filter((p) => !oldIds.has(p.pkgId.id)) + .map((pkg) => ({ kind: PackageChangeKind.add, pkg })), + ] + + if (changes.length > 0) { + this.#onDidChangePackages.fire({ + environment, + manager: this, + changes, + }) + } + } + + async getPackages( + environment: PythonEnvironment, + ): Promise { + if (!isHatchEnv(environment)) return undefined + return this.#packages.get(environment.hatch.path) + } +} From 246985ab374462d64266aabd3697501f6dfb472c Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Tue, 24 Mar 2026 20:14:02 +0100 Subject: [PATCH 2/9] kinda works! --- src/cli/hatch.ts | 48 ++++++++++++++++++++++++++++------------ src/cli/installer.ts | 39 ++++++++++++-------------------- src/hatch-env-manager.ts | 23 ++++++++----------- src/hatch-pkg-manager.ts | 20 +++++------------ 4 files changed, 62 insertions(+), 68 deletions(-) diff --git a/src/cli/hatch.ts b/src/cli/hatch.ts index 840cc52..f978be1 100644 --- a/src/cli/hatch.ts +++ b/src/cli/hatch.ts @@ -4,6 +4,7 @@ export interface HatchEnvInfo { name: string path: string conf: HatchEnvConf + projectPath: string } export interface HatchEnvConf { @@ -23,20 +24,28 @@ export interface HatchEnvConf { description?: string } -export async function getEnvs(cwd: string): Promise { - const json = await run('hatch', ['env', 'show', '--json'], { cwd }) +export async function getEnvs(projectPath: string): Promise { + const json = await run('hatch', ['env', 'show', '--json'], { + cwd: projectPath, + }) const envs = JSON.parse(json) as { [name: string]: HatchEnvConf } return await Promise.all( Object.entries(envs).map(async ([name, conf]) => ({ name, conf, - path: await findEnv(name, cwd), + path: await findEnv(name, projectPath), + projectPath, })), ) } -export async function findEnv(name: string, cwd: string): Promise { - const results = await run('hatch', ['env', 'find', name], { cwd }) +export async function findEnv( + name: string, + projectPath: string, +): Promise { + const results = await run('hatch', ['env', 'find', name], { + cwd: projectPath, + }) const [p] = results .split('\n') .map((line) => line.trim()) @@ -44,18 +53,29 @@ export async function findEnv(name: string, cwd: string): Promise { return p } +interface CreateEnvOptions { + mode?: 'create' | 'sync' | 'ensure' +} + export async function createEnv( name: string, - cwd: string, - { existOk = false }: { existOk?: boolean } = {}, + projectPath: string, + { mode = 'create' }: CreateEnvOptions = {}, ): Promise { - const args = existOk - ? ['-e', name, 'run', 'python', '-V'] - : ['env', 'create', name] - await run('hatch', args, { cwd }) + const args = + mode === 'sync' + ? ['-e', name, 'run', 'python', '-V'] + : ['env', 'create', name] + if (mode === 'ensure') + try { + await run('hatch', args, { cwd: projectPath }) + } catch (_) {} + await run('hatch', args, { cwd: projectPath }) } -export async function removeEnv(name: string, cwd: string): Promise { - await run('hatch', ['env', 'remove', name], { cwd }) +export async function removeEnv( + name: string, + projectPath: string, +): Promise { + await run('hatch', ['env', 'remove', name], { cwd: projectPath }) } - diff --git a/src/cli/installer.ts b/src/cli/installer.ts index 6b2b504..0ebce75 100644 --- a/src/cli/installer.ts +++ b/src/cli/installer.ts @@ -1,45 +1,34 @@ +import type { HatchEnvInfo } from './hatch.js' import { envBin, run } from './index.js' -async function runPipOrUv( - envPath: string, - installer: 'uv' | 'pip', - args: string[], -): Promise { - if (installer === 'uv') { - return run('uv', [ - 'pip', - ...args, - `--python=${envBin(envPath, 'python')}`, - ]) - } - return run(envBin(envPath, 'pip'), [ - ...args, - ...(args[0] === 'uninstall' ? ['--yes'] : []), - ]) +async function runPipOrUv(env: HatchEnvInfo, args: string[]): Promise { + const args_ = + env.conf.installer === 'uv' + ? ['uv', 'pip', ...args, `--python=${envBin(env.path, 'python')}`] + : ['pip', ...args, ...(args[0] === 'uninstall' ? ['--yes'] : [])] + + return run('hatch', ['run', ...args_], { cwd: env.projectPath }) } export async function listPackages( - envPath: string, - installer: 'uv' | 'pip', + env: HatchEnvInfo, ): Promise<{ name: string; version: string }[]> { - const json = await runPipOrUv(envPath, installer, ['list', '--format=json']) + const json = await runPipOrUv(env, ['list', '--format=json']) return JSON.parse(json) as { name: string; version: string }[] } export async function installPackages( - envPath: string, + env: HatchEnvInfo, packages: string[], - installer: 'uv' | 'pip', { upgrade = false }: { upgrade?: boolean } = {}, ): Promise { const args = [...(upgrade ? ['--upgrade'] : []), ...packages] - await runPipOrUv(envPath, installer, ['install', ...args]) + await runPipOrUv(env, ['install', ...args]) } export async function uninstallPackages( - envPath: string, + env: HatchEnvInfo, packages: string[], - installer: 'uv' | 'pip', ): Promise { - await runPipOrUv(envPath, installer, ['uninstall', ...packages]) + await runPipOrUv(env, ['uninstall', ...packages]) } diff --git a/src/hatch-env-manager.ts b/src/hatch-env-manager.ts index 2a4a5a0..e387a8e 100644 --- a/src/hatch-env-manager.ts +++ b/src/hatch-env-manager.ts @@ -44,15 +44,6 @@ export function isHatchEnv( return env !== undefined && 'hatch' in env } -function syncHatchEnv( - environment: HatchEnvironment, - fspath: string, -): Promise { - return hatch.createEnv(environment.hatch.name, fspath, { - existOk: true, - }) -} - export class HatchEnvManager implements EnvironmentManager { readonly name = HATCH_ID readonly displayName = HATCH_NAME @@ -191,7 +182,10 @@ export class HatchEnvManager implements EnvironmentManager { title: 'Syncing hatch environment', cancellable: false, }, - () => syncHatchEnv(environment, projectPath), + () => + hatch.createEnv(environment.hatch.name, projectPath, { + mode: 'sync', + }), ) } const oldEnv = this.#activeEnv.get(projectPath) @@ -368,15 +362,16 @@ export class HatchEnvManager implements EnvironmentManager { } } - async #getHatchEnvs(path: string): Promise { - const envs = await hatch.getEnvs(path) + async #getHatchEnvs(projectPath: string): Promise { + const envs = await hatch.getEnvs(projectPath) return envs.map((e) => this.#hatch2pythonEnv(e)) } #hatch2pythonEnv({ name, - conf, path, + conf, + projectPath, }: hatch.HatchEnvInfo): HatchEnvironment { const shellActivation: Map = new Map() @@ -410,7 +405,7 @@ export class HatchEnvManager implements EnvironmentManager { return { ...envInfo, envId: { id: path, managerId }, - hatch: { name, conf, path }, + hatch: { name, path, conf, projectPath }, } } } diff --git a/src/hatch-pkg-manager.ts b/src/hatch-pkg-manager.ts index 8fedb77..00d34a7 100644 --- a/src/hatch-pkg-manager.ts +++ b/src/hatch-pkg-manager.ts @@ -35,11 +35,11 @@ export class HatchPackageManager implements PackageManager { } readonly #api: PythonEnvironmentApi + /** Map from environment path to packages */ readonly #packages: Map dispose() { this.#onDidChangePackages.dispose() - this.#packages.clear() } async manage( @@ -47,31 +47,21 @@ export class HatchPackageManager implements PackageManager { { upgrade, install = [], uninstall = [] }: PackageManagementOptions, ): Promise { if (!isHatchEnv(environment)) return - const { - path: envPath, - conf: { installer }, - } = environment.hatch - if (install.length > 0) { - await installPackages(envPath, install, installer, { + await installPackages(environment.hatch, install, { upgrade, }) } if (uninstall.length > 0) { - await uninstallPackages(envPath, uninstall, installer) + await uninstallPackages(environment.hatch, uninstall) } - await this.refresh(environment) } async refresh(environment: PythonEnvironment): Promise { if (!isHatchEnv(environment)) return - const { - path: envPath, - conf: { installer }, - } = environment.hatch - - const raw = await listPackages(envPath, installer) + const { path: envPath } = environment.hatch + const raw = await listPackages(environment.hatch) const packages = raw.map(({ name, version }) => this.#api.createPackageItem( { name, displayName: name, version }, From 3358ba9cf1361ad8ba653dd5c736afd1b6d1d4c9 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Tue, 24 Mar 2026 21:56:07 +0100 Subject: [PATCH 3/9] sync when refreshing --- src/cli/index.ts | 6 ------ src/cli/installer.ts | 8 +++++--- src/hatch-env-manager.ts | 9 ++++++++- src/hatch-pkg-manager.ts | 7 ++++++- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index eb08cf9..edad9d7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -24,9 +24,3 @@ export async function run( throw err } } - -export function envBin(envPath: string, name: string): string { - return isWindows() - ? paths.join(envPath, 'Scripts', `${name}.exe`) - : paths.join(envPath, 'bin', name) -} diff --git a/src/cli/installer.ts b/src/cli/installer.ts index 0ebce75..516a801 100644 --- a/src/cli/installer.ts +++ b/src/cli/installer.ts @@ -1,13 +1,15 @@ import type { HatchEnvInfo } from './hatch.js' -import { envBin, run } from './index.js' +import { run } from './index.js' async function runPipOrUv(env: HatchEnvInfo, args: string[]): Promise { const args_ = env.conf.installer === 'uv' - ? ['uv', 'pip', ...args, `--python=${envBin(env.path, 'python')}`] + ? ['uv', 'pip', ...args] : ['pip', ...args, ...(args[0] === 'uninstall' ? ['--yes'] : [])] - return run('hatch', ['run', ...args_], { cwd: env.projectPath }) + return run('hatch', ['-e', env.name, 'run', ...args_], { + cwd: env.projectPath, + }) } export async function listPackages( diff --git a/src/hatch-env-manager.ts b/src/hatch-env-manager.ts index e387a8e..1b4cf3a 100644 --- a/src/hatch-env-manager.ts +++ b/src/hatch-env-manager.ts @@ -1,3 +1,4 @@ +import paths from 'node:path' import { EventEmitter, type LogOutputChannel, @@ -7,10 +8,10 @@ import { window, } from 'vscode' import * as hatch from './cli/hatch.js' -import { envBin } from './cli/index.js' import { HATCH_ID, HATCH_MANAGER_ID, HATCH_NAME } from './common/constants.js' import { createDeferred, type Deferred } from './common/deferred.js' import { traceVerbose } from './common/logging.js' +import { isWindows } from './common/platform.js' import { clearExtensionCache, getGlobalEnvId, @@ -409,3 +410,9 @@ export class HatchEnvManager implements EnvironmentManager { } } } + +export function envBin(envPath: string, name: string): string { + return isWindows() + ? paths.join(envPath, 'Scripts', `${name}.exe`) + : paths.join(envPath, 'bin', name) +} diff --git a/src/hatch-pkg-manager.ts b/src/hatch-pkg-manager.ts index 00d34a7..9ab39ec 100644 --- a/src/hatch-pkg-manager.ts +++ b/src/hatch-pkg-manager.ts @@ -98,6 +98,11 @@ export class HatchPackageManager implements PackageManager { environment: PythonEnvironment, ): Promise { if (!isHatchEnv(environment)) return undefined - return this.#packages.get(environment.hatch.path) + const packages = this.#packages.get(environment.hatch.path) + if (packages === undefined) { + await this.refresh(environment) + return this.#packages.get(environment.hatch.path) + } + return packages } } From d1df5bdd15feb1bbc41c36029ae3d4189a483a83 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Tue, 24 Mar 2026 21:59:36 +0100 Subject: [PATCH 4/9] better --- src/hatch-pkg-manager.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/hatch-pkg-manager.ts b/src/hatch-pkg-manager.ts index 9ab39ec..75445e8 100644 --- a/src/hatch-pkg-manager.ts +++ b/src/hatch-pkg-manager.ts @@ -99,10 +99,12 @@ export class HatchPackageManager implements PackageManager { ): Promise { if (!isHatchEnv(environment)) return undefined const packages = this.#packages.get(environment.hatch.path) - if (packages === undefined) { - await this.refresh(environment) - return this.#packages.get(environment.hatch.path) - } - return packages + if (packages !== undefined) return packages + await this.refresh(environment) + return this.#packages.get(environment.hatch.path) + } + + async clearCache(): Promise { + this.#packages.clear() } } From b9a5bc2f766ae7091d07ffd61b0ba85cd7361e68 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Tue, 24 Mar 2026 22:04:53 +0100 Subject: [PATCH 5/9] progress --- src/hatch-pkg-manager.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/hatch-pkg-manager.ts b/src/hatch-pkg-manager.ts index 75445e8..2453981 100644 --- a/src/hatch-pkg-manager.ts +++ b/src/hatch-pkg-manager.ts @@ -1,4 +1,10 @@ -import { EventEmitter, type LogOutputChannel, ThemeIcon } from 'vscode' +import { + EventEmitter, + type LogOutputChannel, + ProgressLocation, + ThemeIcon, + window, +} from 'vscode' import { installPackages, listPackages, @@ -61,7 +67,14 @@ export class HatchPackageManager implements PackageManager { async refresh(environment: PythonEnvironment): Promise { if (!isHatchEnv(environment)) return const { path: envPath } = environment.hatch - const raw = await listPackages(environment.hatch) + const raw = await window.withProgress( + { + location: ProgressLocation.Window, + title: 'Syncing hatch environment', + cancellable: false, + }, + () => listPackages(environment.hatch), + ) const packages = raw.map(({ name, version }) => this.#api.createPackageItem( { name, displayName: name, version }, From 362afff8805e626009f4e3b1c516f747a9fd09db Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Tue, 24 Mar 2026 22:12:01 +0100 Subject: [PATCH 6/9] remove --- src/hatch-env-manager.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/hatch-env-manager.ts b/src/hatch-env-manager.ts index 1b4cf3a..8c437ee 100644 --- a/src/hatch-env-manager.ts +++ b/src/hatch-env-manager.ts @@ -101,6 +101,19 @@ export class HatchEnvManager implements EnvironmentManager { } } + async remove(environment: PythonEnvironment): Promise { + if (!isHatchEnv(environment)) { + window.showErrorMessage( + 'Cannot remove environment: is not a hatch environment', + ) + return + } + await hatch.removeEnv( + environment.hatch.name, + environment.hatch.projectPath, + ) + } + async refresh(scope: RefreshEnvironmentsScope): Promise { traceVerbose(`Called refresh with scope: ${scope}`) From 3d858bbc66e71b6d591e12d567a370222b1bd3e0 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Wed, 25 Mar 2026 09:17:08 +0100 Subject: [PATCH 7/9] some cleanup --- src/hatch-env-manager.ts | 199 ++++++++++++++++----------------------- src/hatch-pkg-manager.ts | 60 ++++++------ 2 files changed, 113 insertions(+), 146 deletions(-) diff --git a/src/hatch-env-manager.ts b/src/hatch-env-manager.ts index 8c437ee..4e5fddd 100644 --- a/src/hatch-env-manager.ts +++ b/src/hatch-env-manager.ts @@ -91,9 +91,7 @@ export class HatchEnvManager implements EnvironmentManager { if (this.#initialized) { return this.#initialized.promise } - this.#initialized = createDeferred() - try { await this.#refreshAll() } finally { @@ -102,21 +100,19 @@ export class HatchEnvManager implements EnvironmentManager { } async remove(environment: PythonEnvironment): Promise { - if (!isHatchEnv(environment)) { - window.showErrorMessage( - 'Cannot remove environment: is not a hatch environment', - ) - return - } + if (!isHatchEnv(environment)) return await hatch.removeEnv( environment.hatch.name, environment.hatch.projectPath, ) + // Show info message as there is otherwise no visual indicator + await window.showInformationMessage( + `Removed environment “${environment.name}”`, + ) } async refresh(scope: RefreshEnvironmentsScope): Promise { traceVerbose(`Called refresh with scope: ${scope}`) - if (scope instanceof Uri) { await this.#refreshOne(scope) } else { @@ -128,34 +124,19 @@ export class HatchEnvManager implements EnvironmentManager { scope: GetEnvironmentsScope, ): Promise { traceVerbose(`Called getEnvironments with scope: ${scope}`) - await this.initialize() - - if (scope === 'all') { - return [...this.#buildEnvLookup().values()] - } - - if (scope instanceof Uri) { - const project = this.#api.getPythonProject(scope) - return project - ? this.#projectToEnvs.get(project.uri.fsPath) || [] - : [] - } - - return [] + if (scope === 'all') return [...this.#buildEnvLookup().values()] + if (scope === 'global') return [] + const project = this.#api.getPythonProject(scope) + return (project && this.#projectToEnvs.get(project.uri.fsPath)) ?? [] } async get( scope: GetEnvironmentScope, ): Promise { traceVerbose(`Called get with scope: ${scope}`) - await this.initialize() - - if (!scope) { - return this.#globalEnv - } - + if (!scope) return this.#globalEnv const project = this.#api.getPythonProject(scope) return project ? this.#activeEnv.get(project.uri.fsPath) @@ -184,22 +165,19 @@ export class HatchEnvManager implements EnvironmentManager { const uris = scope instanceof Uri ? [scope] : scope for (const uri of uris) { const project = this.#api.getPythonProject(uri) - if (!project) { - continue - } + if (!project) continue const projectPath = project.uri.fsPath if (isHatchEnv(environment)) { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Syncing hatch environment', - cancellable: false, - }, - () => - hatch.createEnv(environment.hatch.name, projectPath, { - mode: 'sync', - }), + const opts = { + location: ProgressLocation.Notification, + title: 'Syncing hatch environment', + cancellable: false, + } + await window.withProgress(opts, () => + hatch.createEnv(environment.hatch.name, projectPath, { + mode: 'sync', + }), ) } const oldEnv = this.#activeEnv.get(projectPath) @@ -220,7 +198,7 @@ export class HatchEnvManager implements EnvironmentManager { ): Promise { traceVerbose(`Called resolve with context: ${context}`) const project = this.#api.getPythonProject(context) - return project ? this.#activeEnv.get(project.uri.fsPath) : undefined + return project && this.#activeEnv.get(project.uri.fsPath) } async clearCache() { @@ -242,95 +220,78 @@ export class HatchEnvManager implements EnvironmentManager { ): DidChangeEnvironmentsEventArgs { const oldIds = new Set(oldEnvs.map((e) => e.envId.id)) const newIds = new Set(newEnvs.map((e) => e.envId.id)) - return [ - ...oldEnvs - .filter((e) => !newIds.has(e.envId.id)) - .map((e) => ({ - environment: e, - kind: EnvironmentChangeKind.remove, - })), - ...newEnvs - .filter((e) => !oldIds.has(e.envId.id)) - .map((e) => ({ - environment: e, - kind: EnvironmentChangeKind.add, - })), - ] + { envs: oldEnvs, ids: newIds, kind: EnvironmentChangeKind.remove }, + { envs: newEnvs, ids: oldIds, kind: EnvironmentChangeKind.add }, + ].flatMap(({ envs, ids, kind }) => + envs + .filter((e) => !ids.has(e.envId.id)) + .map((e) => ({ environment: e, kind })), + ) } async #refreshAll(): Promise { - await window.withProgress( - { - location: ProgressLocation.Window, - title: 'Discovering Hatch environments', - }, - async () => { - const oldProjectToEnvs = new Map(this.#projectToEnvs) - this.#projectToEnvs.clear() - - // Collect project paths from registered Python projects and search paths - const projects = this.#api.getPythonProjects() - const projectMap = new Map( - projects.map((p) => [p.uri.fsPath, p]), - ) + const opts = { + location: ProgressLocation.Window, + title: 'Discovering Hatch environments', + } + await window.withProgress(opts, async () => { + const oldProjectToEnvs = new Map(this.#projectToEnvs) + this.#projectToEnvs.clear() + + // Collect project paths from registered Python projects and search paths + const projects = this.#api.getPythonProjects() + const projectMap = new Map(projects.map((p) => [p.uri.fsPath, p])) + // Hatch has no global paths, so not adding any here + const projectPaths = new Set(projectMap.keys()) + + const changes: DidChangeEnvironmentsEventArgs = [] + + await Promise.all( + [...projectPaths].map(async (projectPath) => { + const oldEnvs = oldProjectToEnvs.get(projectPath) || [] + const newEnvs = await this.#getHatchEnvs(projectPath) + + changes.push(...this.#diffEnvironments(oldEnvs, newEnvs)) + this.#projectToEnvs.set(projectPath, newEnvs) + }), + ) - const searchPathRoots = [] as const //await resolveHatchProjectPaths(); - const projectPaths = new Set([ - ...projectMap.keys(), - ...searchPathRoots, - ]) + this.#onDidChangeEnvironments.fire(changes) - const changes: DidChangeEnvironmentsEventArgs = [] + const envLookup = this.#buildEnvLookup() - await Promise.all( - [...projectPaths].map(async (projectPath) => { - const oldEnvs = oldProjectToEnvs.get(projectPath) || [] - const newEnvs = await this.#getHatchEnvs(projectPath) + // Update global environment + const globalEnvId = await getGlobalEnvId() + const globalEnv = globalEnvId + ? envLookup.get(globalEnvId) + : undefined + this.#triggerDidChangeEnvironment( + undefined, + this.#globalEnv, + globalEnv, + ) + this.#globalEnv = globalEnv - changes.push( - ...this.#diffEnvironments(oldEnvs, newEnvs), - ) - this.#projectToEnvs.set(projectPath, newEnvs) - }), - ) + // Update active environments for each project + const oldActiveEnv = new Map(this.#activeEnv) + this.#activeEnv.clear() - this.#onDidChangeEnvironments.fire(changes) + for (const projectPath of projectPaths) { + const envId = await getProjectEnvId(projectPath) + const env = envId ? envLookup.get(envId) : undefined - const envLookup = this.#buildEnvLookup() + if (env) { + this.#activeEnv.set(projectPath, env) + } - // Update global environment - const globalEnvId = await getGlobalEnvId() - const globalEnv = globalEnvId - ? envLookup.get(globalEnvId) - : undefined this.#triggerDidChangeEnvironment( - undefined, - this.#globalEnv, - globalEnv, + projectMap.get(projectPath)?.uri, + oldActiveEnv.get(projectPath), + env, ) - this.#globalEnv = globalEnv - - // Update active environments for each project - const oldActiveEnv = new Map(this.#activeEnv) - this.#activeEnv.clear() - - for (const projectPath of projectPaths) { - const envId = await getProjectEnvId(projectPath) - const env = envId ? envLookup.get(envId) : undefined - - if (env) { - this.#activeEnv.set(projectPath, env) - } - - this.#triggerDidChangeEnvironment( - projectMap.get(projectPath)?.uri, - oldActiveEnv.get(projectPath), - env, - ) - } - }, - ) + } + }) } async #refreshOne(scope: Uri): Promise { diff --git a/src/hatch-pkg-manager.ts b/src/hatch-pkg-manager.ts index 2453981..4d68e54 100644 --- a/src/hatch-pkg-manager.ts +++ b/src/hatch-pkg-manager.ts @@ -67,37 +67,26 @@ export class HatchPackageManager implements PackageManager { async refresh(environment: PythonEnvironment): Promise { if (!isHatchEnv(environment)) return const { path: envPath } = environment.hatch - const raw = await window.withProgress( - { - location: ProgressLocation.Window, - title: 'Syncing hatch environment', - cancellable: false, - }, - () => listPackages(environment.hatch), - ) - const packages = raw.map(({ name, version }) => - this.#api.createPackageItem( - { name, displayName: name, version }, - environment, - this, - ), - ) + const opts = { + location: ProgressLocation.Window, + title: 'Syncing hatch environment', + cancellable: false, + } + const packages = await window.withProgress(opts, async () => { + const packages = await listPackages(environment.hatch) + return packages.map(({ name, version }) => + this.#api.createPackageItem( + { name, displayName: name, version }, + environment, + this, + ), + ) + }) const oldPackages = this.#packages.get(envPath) ?? [] this.#packages.set(envPath, packages) - const oldIds = new Set(oldPackages.map((p) => p.pkgId.id)) - const newIds = new Set(packages.map((p) => p.pkgId.id)) - - const changes: DidChangePackagesEventArgs['changes'] = [ - ...oldPackages - .filter((p) => !newIds.has(p.pkgId.id)) - .map((pkg) => ({ kind: PackageChangeKind.remove, pkg })), - ...packages - .filter((p) => !oldIds.has(p.pkgId.id)) - .map((pkg) => ({ kind: PackageChangeKind.add, pkg })), - ] - + const changes = this.#diffPkgs(oldPackages, packages) if (changes.length > 0) { this.#onDidChangePackages.fire({ environment, @@ -120,4 +109,21 @@ export class HatchPackageManager implements PackageManager { async clearCache(): Promise { this.#packages.clear() } + + #diffPkgs( + oldPackages: Package[], + packages: Package[], + ): DidChangePackagesEventArgs['changes'] { + const oldIds = new Set(oldPackages.map((p) => p.pkgId.id)) + const newIds = new Set(packages.map((p) => p.pkgId.id)) + + return [ + { pkgs: oldPackages, ids: newIds, kind: PackageChangeKind.remove }, + { pkgs: packages, ids: oldIds, kind: PackageChangeKind.add }, + ].flatMap(({ pkgs, ids, kind }) => + pkgs + .filter((p) => !ids.has(p.pkgId.id)) + .map((pkg) => ({ pkg, kind })), + ) + } } From 655b41123d728f686dd590bdbfd5001adbed4b57 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Wed, 25 Mar 2026 09:50:27 +0100 Subject: [PATCH 8/9] readme --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4c4679c..7e9054d3 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,28 @@ -# VS Code Hatch -An extension to manage Hatch environments through [`vscode-python-environments`][]. +# Hatch Code +An extension to manage [Hatch environments] through [`vscode-python-environments`][]. +To make use of it, make sure your user settings contain `"python.useEnvironmentsExtension": true`. + +[hatch environments]: https://hatch.pypa.io/latest/tutorials/environment/basic-usage/ [`vscode-python-environments`]: https://github.com/microsoft/vscode-python-environments/#readme ## Features -- List all configured Hatch envs and allow to activate them +- List all configured [Hatch environments] +- Provide controls to set them as active environment for your project, activate them in a terminal, and remove them[^1] +- Temporarily[^2] modify an environment’s packages using the configured [`installer`] + +[^1]: “remove” in this context means deleting it on disk, it will stay listed and will be recreated when interacting with it. +[^2]: since many actions currently use `hatch run` and therefore sync the environment, e.g. removing a package that is pulled in as a dependency will not persist for long. + +[`installer`]: https://hatch.pypa.io/latest/how-to/environment/select-installer/ ## Extension Settings TODO +## Limitations +- It’s pretty unclear which environments exist on disk and which don’t +- We list internal envs that users don’t usually interact with, such as `hatch-uv` and `hatch-build` + ## Release Notes ### 0.0.1 Unreleased From 8893e8fa900ef9d5105e1f4492664e314746a319 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Wed, 25 Mar 2026 09:58:42 +0100 Subject: [PATCH 9/9] readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7e9054d3..866d7e9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ TODO ## Limitations - It’s pretty unclear which environments exist on disk and which don’t - We list internal envs that users don’t usually interact with, such as `hatch-uv` and `hatch-build` +- [Terminal activation is slow](https://github.com/microsoft/vscode-python-environments/issues/1391) ## Release Notes ### 0.0.1