diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 0544c4fd..7156cc5f 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,26 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { ); }), this.envVarManager.onDidChangeEnvironmentVariables((e) => this._onDidChangeEnvironmentVariables.fire(e)), + this.projectManager.onDidChangeProjects(() => { + const current = this.projectManager.getProjects(); + const currentByUri = new Map(current.map((p) => [p.uri.toString(), p] as const)); + const previousByUri = new Map(this.previousProjects.map((p) => [p.uri.toString(), p] as const)); + + const added = [...currentByUri.entries()] + .filter(([uri]) => !previousByUri.has(uri)) + .map(([, project]) => project); + const removed = [...previousByUri.entries()] + .filter(([uri]) => !currentByUri.has(uri)) + .map(([, project]) => project); + + 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..0287828e --- /dev/null +++ b/src/test/features/pythonApi.unit.test.ts @@ -0,0 +1,65 @@ +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.joinPath(Uri.file(process.cwd()), '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'); + + // 8. Simulate removing the project + firedEventPayload = null; + currentProjects = []; + onDidChangeProjectsEmitter.fire(); + + assert.ok(firedEventPayload, 'Event should have fired'); + assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 0, 'Should have 0 added projects'); + assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 1, 'Should have 1 removed project'); + assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed[0].uri.fsPath, newProject.uri.fsPath); + }); +}); \ No newline at end of file