diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 7575ecf8..01f8d853 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -814,6 +814,16 @@ export async function createTerminalCommand( tm: TerminalManager, ): Promise { const pythonProjects = api.getPythonProjects(); + if (context instanceof PythonEnvTreeItem) { + const pw = await pickProject(pythonProjects); + if (pythonProjects.length > 0 && !pw) { + return undefined; + } + const cwd = pw ? await findParentIfFile(pw.uri.fsPath) : undefined; + const terminal = await tm.create(context.environment, { cwd }); + terminal.show(); + return terminal; + } // If no context is provided, or there are multiple projects, prompt the user to select a project for the terminal's cwd if (context === undefined || pythonProjects.length > 0) { const pw = await pickProject(pythonProjects); @@ -848,15 +858,6 @@ export async function createTerminalCommand( terminal.show(); return terminal; } - } else if (context instanceof PythonEnvTreeItem) { - const view = context as PythonEnvTreeItem; - const pw = await pickProject(pythonProjects); - if (pw) { - const cwd = await findParentIfFile(pw.uri.fsPath); - const terminal = await tm.create(view.environment, { cwd }); - terminal.show(); - return terminal; - } } } diff --git a/src/features/terminal/terminalManager.ts b/src/features/terminal/terminalManager.ts index 76dea164..6fb35fd1 100644 --- a/src/features/terminal/terminalManager.ts +++ b/src/features/terminal/terminalManager.ts @@ -68,6 +68,7 @@ export class TerminalManagerImpl implements TerminalManager { private disposables: Disposable[] = []; private skipActivationOnOpen = new Set(); private shellSetup: Map = new Map(); + private pendingTerminalEnvironments = new WeakMap(); private onTerminalOpenedEmitter = new EventEmitter(); private onTerminalOpened = this.onTerminalOpenedEmitter.event; @@ -99,7 +100,8 @@ export class TerminalManagerImpl implements TerminalManager { if (this.skipActivationOnOpen.has(t) || shouldSkipTerminalActivation(t)) { return; } - let env = this.ta.getEnvironment(t); + let env = this.pendingTerminalEnvironments.get(t) ?? this.ta.getEnvironment(t); + this.pendingTerminalEnvironments.delete(t); if (!env) { const api = await getPythonApi(); env = await getEnvironmentForTerminal(api, t); @@ -296,6 +298,9 @@ export class TerminalManagerImpl implements TerminalManager { name, env: envVars, }); + if (autoActType === ACT_TYPE_SHELL) { + this.pendingTerminalEnvironments.set(newTerminal, environment); + } if (autoActType === ACT_TYPE_COMMAND) { if (options.disableActivation) { diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 40618b99..818739cf 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -14,6 +14,7 @@ import { clearEnvironmentCachesCommand, clearScriptEnvironmentCacheCommand, createAnyEnvironmentCommand, + createTerminalCommand, removeEnvironmentCommand, removePythonProject, revealEnvInManagerView, @@ -26,7 +27,12 @@ import * as shellProviders from '../../features/terminal/shells/providers'; import { ShellStartupScriptProvider } from '../../features/terminal/shells/startupProvider'; import { TerminalManager } from '../../features/terminal/terminalManager'; import { EnvManagerView } from '../../features/views/envManagersView'; -import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems'; +import { + EnvManagerTreeItem, + ProjectEnvironment, + ProjectItem, + PythonEnvTreeItem, +} from '../../features/views/treeViewItems'; import type { EnvironmentManagers } from '../../features/envManagers'; import type { PythonProjectManager } from '../../features/projectManager'; import { InternalEnvironmentManager } from '../../managers/common/registeredManagers'; @@ -94,6 +100,104 @@ suite('Environment removal command ownership', () => { }); }); +suite('Create Terminal Command Tests', () => { + teardown(() => { + sinon.restore(); + }); + + function createEnvironmentItem(environment: PythonEnvironment): PythonEnvTreeItem { + const manager = { + id: environment.envId.managerId, + name: 'venv', + displayName: 'Venv', + } as InternalEnvironmentManager; + return new PythonEnvTreeItem(environment, new EnvManagerTreeItem(manager)); + } + + function createTerminalManager() { + const terminal = { show: sinon.stub() } as unknown as Terminal; + const create = sinon.stub().resolves(terminal); + return { terminal, create, manager: { create } as unknown as TerminalManager }; + } + + test('creates a terminal for the clicked environment without a project', async () => { + const environment = createMockPythonEnvironment({ + managerId: 'ms-python.python:venv', + envPath: path.join(process.cwd(), '.venv', 'python'), + }); + const getEnvironment = sinon.stub().resolves(undefined); + const api = { + getPythonProjects: sinon.stub().returns([]), + getEnvironment, + } as unknown as PythonEnvironmentApi; + const terminalManager = createTerminalManager(); + + const result = await createTerminalCommand(createEnvironmentItem(environment), api, terminalManager.manager); + + assert.strictEqual(result, terminalManager.terminal); + sinon.assert.calledOnceWithExactly(terminalManager.create, environment, { cwd: undefined }); + sinon.assert.calledOnce(terminalManager.terminal.show as sinon.SinonStub); + sinon.assert.notCalled(getEnvironment); + }); + + test('does not create a terminal when project selection is cancelled', async () => { + const environment = createMockPythonEnvironment({ + managerId: 'ms-python.python:venv', + envPath: path.join(process.cwd(), '.venv', 'python'), + }); + const projects: PythonProject[] = [ + { name: 'project-one', uri: Uri.file(path.join(process.cwd(), 'project-one')) }, + { name: 'project-two', uri: Uri.file(path.join(process.cwd(), 'project-two')) }, + ]; + const api = { + getPythonProjects: sinon.stub().returns(projects), + } as unknown as PythonEnvironmentApi; + const terminalManager = createTerminalManager(); + sinon.stub(projectApi, 'pickProject').resolves(undefined); + + const result = await createTerminalCommand(createEnvironmentItem(environment), api, terminalManager.manager); + + assert.strictEqual(result, undefined); + sinon.assert.notCalled(terminalManager.create); + }); + + for (const projectEnvironment of [ + undefined, + createMockPythonEnvironment({ + name: 'project-environment', + managerId: 'ms-python.python:venv', + envPath: path.join(process.cwd(), 'project-environment', 'python'), + }), + ]) { + const projectEnvironmentDescription = projectEnvironment ? 'a different environment' : 'no environment'; + + test(`creates a terminal for the clicked environment when the project has ${projectEnvironmentDescription}`, async () => { + const environment = createMockPythonEnvironment({ + name: 'clicked-environment', + managerId: 'ms-python.python:venv', + envPath: path.join(process.cwd(), 'clicked-environment', 'python'), + }); + const project: PythonProject = { + name: 'project', + uri: Uri.file(process.cwd()), + }; + const getEnvironment = sinon.stub().resolves(projectEnvironment); + const api = { + getPythonProjects: sinon.stub().returns([project]), + getEnvironment, + } as unknown as PythonEnvironmentApi; + const terminalManager = createTerminalManager(); + + const result = await createTerminalCommand(createEnvironmentItem(environment), api, terminalManager.manager); + + assert.strictEqual(result, terminalManager.terminal); + sinon.assert.calledOnceWithExactly(terminalManager.create, environment, { cwd: project.uri.fsPath }); + sinon.assert.calledOnce(terminalManager.terminal.show as sinon.SinonStub); + sinon.assert.notCalled(getEnvironment); + }); + } +}); + suite('Create Any Environment Command Tests', () => { let em: typeMoq.IMock; let pm: typeMoq.IMock; diff --git a/src/test/features/terminal/terminalManager.unit.test.ts b/src/test/features/terminal/terminalManager.unit.test.ts index 0f5c42c4..6e6ef360 100644 --- a/src/test/features/terminal/terminalManager.unit.test.ts +++ b/src/test/features/terminal/terminalManager.unit.test.ts @@ -18,9 +18,10 @@ import { Uri, WorkspaceConfiguration, } from 'vscode'; -import { PythonEnvironment } from '../../../api'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; import * as windowApis from '../../../common/window.apis'; import * as workspaceApis from '../../../common/workspace.apis'; +import * as extensionApi from '../../../extensionApi'; import * as activationUtils from '../../../features/common/activation'; import * as shellDetector from '../../../features/common/shellDetector'; import * as shellUtils from '../../../features/terminal/shells/common/shellUtils'; @@ -44,6 +45,7 @@ class TestTerminalActivation implements TerminalActivationInternal { public callOrder: string[] = []; public activateCalls = 0; public deactivateCalls = 0; + public activatedEnvironment: PythonEnvironment | undefined; private onDidChangeEmitter = new EventEmitter(); public onDidChangeTerminalActivationState: Event = @@ -53,8 +55,9 @@ class TestTerminalActivation implements TerminalActivationInternal { return false; } - async activate(_terminal: Terminal, _environment: PythonEnvironment): Promise { + async activate(_terminal: Terminal, environment: PythonEnvironment): Promise { this.activateCalls += 1; + this.activatedEnvironment = environment; this.callOrder.push('activate'); } @@ -80,6 +83,7 @@ suite('TerminalManager - create()', () => { let mockGetAutoActivationType: sinon.SinonStub; let terminalManager: TerminalManagerImpl; let mockTerminal: Partial & { show: sinon.SinonStub }; + let openTerminal: (terminal: Terminal) => unknown; const createMockEnvironment = (): PythonEnvironment => ({ envId: { id: 'test-env-id', managerId: 'test-manager' }, @@ -115,7 +119,10 @@ suite('TerminalManager - create()', () => { sinon.stub(shellDetector, 'identifyTerminalShell').returns('bash'); sinon.stub(windowApis, 'createTerminal').returns(mockTerminal as Terminal); - sinon.stub(windowApis, 'onDidOpenTerminal').returns(new Disposable(() => {})); + sinon.stub(windowApis, 'onDidOpenTerminal').callsFake((listener) => { + openTerminal = listener; + return new Disposable(() => {}); + }); sinon.stub(windowApis, 'onDidCloseTerminal').returns(new Disposable(() => {})); sinon.stub(windowApis, 'onDidChangeWindowState').returns(new Disposable(() => {})); sinon.stub(windowApis, 'terminals').returns([]); @@ -177,6 +184,28 @@ suite('TerminalManager - create()', () => { assert.strictEqual(callOrder.includes('activate'), false, 'No command activation for shell startup mode'); }); + test('ACT_TYPE_SHELL: command fallback activates the environment requested during creation', async () => { + mockGetAutoActivationType.returns(terminalUtils.ACT_TYPE_SHELL); + terminalManager = createTerminalManager(); + const requestedEnvironment = createMockEnvironment(); + const projectEnvironment = { + ...createMockEnvironment(), + envId: { id: 'project-env-id', managerId: 'test-manager' }, + }; + const cwd = Uri.file(process.cwd()).fsPath; + Object.defineProperty(mockTerminal, 'creationOptions', { value: { cwd } }); + sinon.stub(extensionApi, 'getPythonApi').resolves({ + getPythonProjects: () => [{ name: 'Project', uri: Uri.file(cwd) }], + getEnvironment: async () => projectEnvironment, + } as unknown as PythonEnvironmentApi); + + const terminal = await terminalManager.create(requestedEnvironment, { cwd }); + openTerminal(terminal); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(terminalActivation.activatedEnvironment, requestedEnvironment); + }); + test('ACT_TYPE_OFF: does not call show() since create() returns immediately and caller handles visibility', async () => { mockGetAutoActivationType.returns(terminalUtils.ACT_TYPE_OFF); terminalManager = createTerminalManager();