From 1d2994a354b2ccb4410fc524247b313597c0c0f4 Mon Sep 17 00:00:00 2001 From: Yashraj Jangra <84060578+Yashraj-Jangra@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:32:15 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fire=20onDidChangePythonProjects?= =?UTF-8?q?=20event=20on=20project=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/pythonApi.ts | 17 ++++- src/test/features/pythonApi.unit.test.ts | 74 +++++++++++++++++++ .../pythonProjects.integration.test.ts | 50 +++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/test/features/pythonApi.unit.test.ts diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 0544c4fd9..b7337b440 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -58,12 +58,13 @@ 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(); + private _previousProjects: PythonProject[] = []; constructor( private readonly envManagers: EnvironmentManagers, @@ -73,6 +74,7 @@ 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 +89,19 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { ); }), this.envVarManager.onDidChangeEnvironmentVariables((e) => this._onDidChangeEnvironmentVariables.fire(e)), + this.projectManager.onDidChangeProjects((currentProjects) => { + const current = currentProjects ?? []; + const added = current.filter( + (p) => !this._previousProjects.some((prev) => prev.uri.toString() === p.uri.toString()), + ); + const removed = this._previousProjects.filter( + (prev) => !current.some((p) => p.uri.toString() === prev.uri.toString()), + ); + this._previousProjects = [...current]; + if (added.length > 0 || removed.length > 0) { + 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 000000000..ab7d03c84 --- /dev/null +++ b/src/test/features/pythonApi.unit.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { EventEmitter, Uri } from 'vscode'; +import { PythonProject } from '../../api'; +import { PythonEnvironmentApiImpl } from '../../features/pythonApi'; +import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from '../../internal.api'; +import { EnvVarManager } from '../../features/execution/envVariableManager'; +import { TerminalManager } from '../../features/terminal/terminalManager'; + +suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { + let sandbox: sinon.SinonSandbox; + let mockEnvManagers: sinon.SinonStubbedInstance; + let mockProjectManager: sinon.SinonStubbedInstance; + let mockProjectCreators: sinon.SinonStubbedInstance; + let mockTerminalManager: sinon.SinonStubbedInstance; + let mockEnvVarManager: sinon.SinonStubbedInstance; + let onDidChangeProjectsEmitter: EventEmitter; + + setup(() => { + sandbox = sinon.createSandbox(); + onDidChangeProjectsEmitter = new EventEmitter(); + + mockEnvManagers = { + onDidChangeActiveEnvironment: new EventEmitter().event, + } as any; + mockProjectCreators = {} as any; + mockTerminalManager = {} as any; + mockEnvVarManager = { + onDidChangeEnvironmentVariables: new EventEmitter().event, + } as any; + + mockProjectManager = { + getProjects: sandbox.stub().returns([]), + onDidChangeProjects: onDidChangeProjectsEmitter.event, + } as any; + }); + + teardown(() => { + sandbox.restore(); + onDidChangeProjectsEmitter.dispose(); + }); + + test('Fires onDidChangePythonProjects with added/removed projects when projects change', () => { + const p1: PythonProject = { name: 'Proj1', uri: Uri.file('/path/p1') }; + const p2: PythonProject = { name: 'Proj2', uri: Uri.file('/path/p2') }; + const p3: PythonProject = { name: 'Proj3', uri: Uri.file('/path/p3') }; + + // Initially we return p1 and p2 + mockProjectManager.getProjects.returns([p1, p2]); + + const api = new PythonEnvironmentApiImpl( + mockEnvManagers as any, + mockProjectManager as any, + mockProjectCreators as any, + mockTerminalManager as any, + mockEnvVarManager as any, + ); + + const events: { added: PythonProject[]; removed: PythonProject[] }[] = []; + api.onDidChangePythonProjects((e) => { + events.push(e); + }); + + // Add p3, remove p1 -> current projects are p2, p3 + onDidChangeProjectsEmitter.fire([p2, p3]); + + assert.strictEqual(events.length, 1); + assert.deepStrictEqual(events[0].added.map(p => p.uri.toString()), [p3.uri.toString()]); + assert.deepStrictEqual(events[0].removed.map(p => p.uri.toString()), [p1.uri.toString()]); + }); +}); diff --git a/src/test/integration/pythonProjects.integration.test.ts b/src/test/integration/pythonProjects.integration.test.ts index 10ac7289a..79239ae08 100644 --- a/src/test/integration/pythonProjects.integration.test.ts +++ b/src/test/integration/pythonProjects.integration.test.ts @@ -373,4 +373,54 @@ suite('Integration: Python Projects', function () { assert.ok(fileEnv, 'File should get environment from project'); assert.strictEqual(fileEnv.envId.id, env.envId.id, 'File should use project environment'); }); + + /** + * Test: onDidChangePythonProjects fires when a project is added or removed + */ + test('onDidChangePythonProjects fires when a project is added or removed', async function () { + const testUri = vscode.Uri.file('/tmp/test-project-' + Math.random().toString(36).substring(2)); + const testProject = { + name: 'Test Project', + uri: testUri, + }; + + const addedPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + sub.dispose(); + reject(new Error('onDidChangePythonProjects did not fire for added project within 5s')); + }, 5000); + + const sub = api.onDidChangePythonProjects((e) => { + if (e.added.some((p) => p.uri.toString() === testUri.toString())) { + clearTimeout(timeout); + sub.dispose(); + resolve(); + } + }); + }); + + // Add project + api.addPythonProject(testProject); + await addedPromise; + + const removedPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + sub.dispose(); + reject(new Error('onDidChangePythonProjects did not fire for removed project within 5s')); + }, 5000); + + const sub = api.onDidChangePythonProjects((e) => { + if (e.removed.some((p) => p.uri.toString() === testUri.toString())) { + clearTimeout(timeout); + sub.dispose(); + resolve(); + } + }); + }); + + // Remove project + api.removePythonProject(testProject); + await removedPromise; + }); }); +