From e3184e5f53fe1012760259c4512296f9b909e279 Mon Sep 17 00:00:00 2001 From: Mohit Yadav Date: Sun, 19 Jul 2026 15:44:27 +0530 Subject: [PATCH] Fix #1599: onDidChangePythonProjects never fires on runtime project add/remove --- src/features/pythonApi.ts | 25 ++++++++++- src/test/features/pythonApi.unit.test.ts | 55 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/test/features/pythonApi.unit.test.ts diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 0544c4fd..7286c771 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -33,6 +33,7 @@ import { } from '../api'; import { traceError, traceInfo } from '../common/logging'; import { pickEnvironmentManager } from '../common/pickers/managers'; +import { timeout } from '../common/utils/asyncUtils'; import { createDeferred } from '../common/utils/deferred'; import { checkUri } from '../common/utils/pathUtils'; import { handlePythonPath } from '../common/utils/pythonPath'; @@ -44,7 +45,6 @@ import { PythonPackageImpl, PythonProjectManager, } from '../internal.api'; -import { timeout } from '../common/utils/asyncUtils'; import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './common/managerReady'; import { EnvVarManager } from './execution/envVariableManager'; import { runAsTask } from './execution/runAsTask'; @@ -58,12 +58,15 @@ import { TerminalManager } from './terminal/terminalManager'; const GET_ENVIRONMENT_TIMEOUT_MS = 1000; const GET_ENVIRONMENT_TIMED_OUT = Symbol('getEnvironmentTimedOut'); -class PythonEnvironmentApiImpl implements PythonEnvironmentApi { +export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { private readonly _onDidChangeEnvironments = new EventEmitter(); private readonly _onDidChangeEnvironment = new EventEmitter(); private readonly _onDidChangePythonProjects = new EventEmitter(); private readonly _onDidChangePackages = new EventEmitter(); private readonly _onDidChangeEnvironmentVariables = new EventEmitter(); + // Tracks the last-known project set so we can compute an added/removed delta + // whenever the underlying project manager reports a change (fix for #1599). + private previousProjects: readonly PythonProject[] = []; constructor( private readonly envManagers: EnvironmentManagers, @@ -73,6 +76,8 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { private readonly envVarManager: EnvVarManager, private readonly disposables: Disposable[] = [], ) { + this.previousProjects = this.projectManager.getProjects(); + this.disposables.push( this._onDidChangeEnvironment, this._onDidChangeEnvironments, @@ -87,6 +92,22 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { ); }), this.envVarManager.onDidChangeEnvironmentVariables((e) => this._onDidChangeEnvironmentVariables.fire(e)), + this.projectManager.onDidChangeProjects(() => { + const current = this.projectManager.getProjects(); + const added = current.filter( + (c) => !this.previousProjects.some((p) => p.uri.toString() === c.uri.toString()), + ); + const removed = this.previousProjects.filter( + (p) => !current.some((c) => c.uri.toString() === p.uri.toString()), + ); + this.previousProjects = current; + if (added.length > 0 || removed.length > 0) { + traceInfo( + `Python API: Projects changed. Added: ${added.length}, Removed: ${removed.length}`, + ); + this._onDidChangePythonProjects.fire({ added, removed }); + } + }), ); } diff --git a/src/test/features/pythonApi.unit.test.ts b/src/test/features/pythonApi.unit.test.ts new file mode 100644 index 00000000..2f7c748b --- /dev/null +++ b/src/test/features/pythonApi.unit.test.ts @@ -0,0 +1,55 @@ +import * as assert from 'assert'; +import { EventEmitter, Uri } from 'vscode'; +import { PythonProject } from '../../api'; +import { PythonEnvironmentApiImpl } from '../../features/pythonApi'; +import { PythonProjectManager } from '../../internal.api'; + +suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { + test('Fires event with correct added and removed projects', async () => { + // 1. Create a mock EventEmitter to simulate the internal project manager + const onDidChangeProjectsEmitter = new EventEmitter(); + + // 2. Mock the PythonProjectManager + let currentProjects: PythonProject[] = []; + const mockProjectManager = { + getProjects: () => currentProjects, + onDidChangeProjects: onDidChangeProjectsEmitter.event, + } as unknown as PythonProjectManager; + + // 3. Mock the other required constructor arguments using ConstructorParameters + type ApiArgs = ConstructorParameters; + + const mockEnvManagers = { onDidChangeActiveEnvironment: new EventEmitter().event } as unknown as ApiArgs[0]; + const mockProjectCreators = {} as unknown as ApiArgs[2]; + const mockTerminalManager = {} as unknown as ApiArgs[3]; + const mockEnvVarManager = { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4]; + + // 4. Initialize the API instance + const api = new PythonEnvironmentApiImpl( + mockEnvManagers, + mockProjectManager, + mockProjectCreators, + mockTerminalManager, + mockEnvVarManager + ); + + // 5. Listen to the public event we are testing + let firedEventPayload: unknown = null; + api.onDidChangePythonProjects((e: unknown) => { + firedEventPayload = e; + }); + + // 6. Simulate adding a project + const newProject = { uri: Uri.file('/fake/path') } as unknown as PythonProject; + currentProjects = [newProject]; // Update the mock's state + + // Fire the internal event + onDidChangeProjectsEmitter.fire(); + + // 7. Assert the public event fired with the correct delta + assert.ok(firedEventPayload, 'Event should have fired'); + assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 1, 'Should have 1 added project'); + assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added[0].uri.fsPath, newProject.uri.fsPath); + assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 0, 'Should have 0 removed projects'); + }); +}); \ No newline at end of file