diff --git a/src/extension.ts b/src/extension.ts index c4c2e2a9c..299f0e903 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -523,13 +523,15 @@ export async function activate(context: ExtensionContext): Promise { - await setActivateMenuButtonContext(e.terminal, e.environment, e.activated); + if (activeTerminal() === e.terminal) { + await setActivateMenuButtonContext(e.terminal, e.environment, e.activated); + } }), onDidChangeActiveTerminal(async (t) => { if (t) { const env = terminalActivation.getEnvironment(t) ?? (await getEnvironmentForTerminal(api, t)); - if (env) { - await setActivateMenuButtonContext(t, env, terminalActivation.isActivated(t)); + if (activeTerminal() === t) { + await setActivateMenuButtonContext(t, env, env ? terminalActivation.isActivated(t) : false); } } }), diff --git a/src/features/terminal/activateMenuButton.ts b/src/features/terminal/activateMenuButton.ts index b271a057a..4fdc843c5 100644 --- a/src/features/terminal/activateMenuButton.ts +++ b/src/features/terminal/activateMenuButton.ts @@ -6,13 +6,13 @@ import { isTaskTerminal } from './utils'; export async function setActivateMenuButtonContext( terminal: Terminal, - env: PythonEnvironment, + env: PythonEnvironment | undefined, activated?: boolean, ): Promise { - const activatable = !isTaskTerminal(terminal) && isActivatableEnvironment(env); + const activatable = !!env && !isTaskTerminal(terminal) && isActivatableEnvironment(env); await executeCommand('setContext', 'pythonTerminalActivation', activatable); - if (activated !== undefined) { - await executeCommand('setContext', 'pythonTerminalActivated', activated); + if (!env || activated !== undefined) { + await executeCommand('setContext', 'pythonTerminalActivated', env ? activated : false); } } diff --git a/src/features/terminal/utils.ts b/src/features/terminal/utils.ts index 241b19b74..f4320d13c 100644 --- a/src/features/terminal/utils.ts +++ b/src/features/terminal/utils.ts @@ -1,7 +1,14 @@ import * as path from 'path'; import { Disposable, env, ExtensionTerminalOptions, tasks, Terminal, TerminalOptions, Uri } from 'vscode'; -import { PythonEnvironment, PythonProject, PythonProjectEnvironmentApi, PythonProjectGetterApi } from '../../api'; -import { traceVerbose } from '../../common/logging'; +import { + PythonEnvironment, + PythonEnvironmentApi, + PythonProject, + PythonProjectEnvironmentApi, + PythonProjectGetterApi, +} from '../../api'; +import { VENV_MANAGER_ID } from '../../common/constants'; +import { traceError, traceVerbose } from '../../common/logging'; import { timeout } from '../../common/utils/asyncUtils'; import { createSimpleDebounce } from '../../common/utils/debounce'; import { onDidChangeTerminalShellIntegration, onDidWriteTerminalData } from '../../common/window.apis'; @@ -10,6 +17,7 @@ import { identifyTerminalShell } from '../common/shellDetector'; import { shellIntegrationSupportedShells } from './shells/common/shellUtils'; export const SHELL_INTEGRATION_TIMEOUT = 500; // 0.5 seconds +const LOCAL_VENV_LOOKUP_TIMEOUT_MS = 1000; /** * Use `terminal.integrated.shellIntegration.timeout` setting if available. @@ -191,13 +199,152 @@ async function getDistinctProjectEnvs( return envs; } +function isSameOrParentPath(parentPath: string, candidatePath: string): boolean { + const relative = path.relative(path.resolve(parentPath), path.resolve(candidatePath)); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function getProjectForCwd(projects: readonly PythonProject[], cwd: string): PythonProject | undefined { + return [...projects] + .filter((project) => isSameOrParentPath(project.uri.fsPath, cwd)) + .sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length)[0]; +} + +function getLocalVenvOwner(environment: PythonEnvironment): string | undefined { + if ( + environment.envId.managerId !== VENV_MANAGER_ID || + environment.error || + !path.isAbsolute(environment.sysPrefix) + ) { + return undefined; + } + return path.dirname(path.resolve(environment.sysPrefix)); +} + +function isSiblingVenv(environment: PythonEnvironment, project: PythonProject, cwd: string): boolean { + const owner = getLocalVenvOwner(environment); + return ( + !!owner && + isSameOrParentPath(project.uri.fsPath, owner) && + !isSameOrParentPath(owner, cwd) && + !isSameOrParentPath(cwd, owner) + ); +} + +async function getLocalVenvForCwd( + api: PythonEnvironmentApi, + project: PythonProject, + cwd: string, +): Promise { + const environmentLookup = api + .getEnvironments('all') + .then((environments) => ({ environments })) + .catch((error) => { + traceError('Failed to get environments for terminal cwd resolution', error); + return { environments: undefined }; + }); + let timeoutHandle: ReturnType | undefined; + const lookupTimeout = new Promise<{ environments: undefined }>((resolve) => { + timeoutHandle = setTimeout(() => { + traceVerbose(`Timed out looking for a local virtual environment for terminal cwd: ${cwd}`); + resolve({ environments: undefined }); + }, LOCAL_VENV_LOOKUP_TIMEOUT_MS); + }); + const lookupResult = await Promise.race([environmentLookup, lookupTimeout]).finally(() => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + }); + if (!lookupResult.environments) { + return undefined; + } + + const candidates = lookupResult.environments + .map((environment) => ({ environment, owner: getLocalVenvOwner(environment) })) + .filter( + (candidate): candidate is { environment: PythonEnvironment; owner: string } => + candidate.owner !== undefined && + isSameOrParentPath(project.uri.fsPath, candidate.owner) && + isSameOrParentPath(candidate.owner, cwd), + ) + .map((candidate) => ({ + ...candidate, + distance: path + .relative(path.resolve(candidate.owner), path.resolve(cwd)) + .split(path.sep) + .filter(Boolean).length, + })); + + if (candidates.length === 0) { + return undefined; + } + + const nearestDistance = Math.min(...candidates.map((candidate) => candidate.distance)); + const nearest = candidates.filter((candidate) => candidate.distance === nearestDistance); + const unique = Array.from( + new Map(nearest.map((candidate) => [candidate.environment.envId.id, candidate.environment])).values(), + ); + + if (unique.length !== 1) { + traceVerbose(`Multiple local virtual environments match terminal cwd: ${cwd}`); + return undefined; + } + + return unique[0]; +} + +interface CwdEnvironmentResolution { + environment?: PythonEnvironment; + stopFallback: boolean; +} + +async function getEnvironmentForCwd( + api: PythonEnvironmentApi, + projects: readonly PythonProject[], + cwd: string, +): Promise { + const project = getProjectForCwd(projects, cwd); + if (!project) { + return { stopFallback: false }; + } + + const projectEnvironment = await api.getEnvironment(project.uri); + if (projectEnvironment && !isSiblingVenv(projectEnvironment, project, cwd)) { + return { environment: projectEnvironment, stopFallback: true }; + } + + if (!projectEnvironment) { + return { stopFallback: true }; + } + + traceVerbose(`Ignoring virtual environment outside terminal cwd: ${projectEnvironment.environmentPath.fsPath}`); + + const localEnvironment = await getLocalVenvForCwd(api, project, cwd); + if (localEnvironment) { + traceVerbose(`Using local virtual environment for terminal cwd: ${localEnvironment.environmentPath.fsPath}`); + return { environment: localEnvironment, stopFallback: true }; + } + + return { + stopFallback: true, + }; +} + export async function getEnvironmentForTerminal( - api: PythonProjectGetterApi & PythonProjectEnvironmentApi, + api: PythonEnvironmentApi, terminal?: Terminal, ): Promise { let env: PythonEnvironment | undefined; const projects = api.getPythonProjects(); + const terminalCwd = terminal ? getTerminalCwd(terminal) : undefined; + if (terminalCwd) { + const cwdResolution = await getEnvironmentForCwd(api, projects, terminalCwd); + if (cwdResolution.environment || cwdResolution.stopFallback) { + return cwdResolution.environment; + } + } + if (projects.length === 0) { env = await api.getEnvironment(undefined); } else if (projects.length === 1) { @@ -217,10 +364,9 @@ export async function getEnvironmentForTerminal( if (env) { return env; } - // This is a heuristic approach to attempt to find the environment for this terminal. // This is not guaranteed to work, but is better than nothing. - const terminalCwd = terminal ? getTerminalCwd(terminal) : undefined; + // This is not guaranteed to work, but is better than nothing. if (terminalCwd) { env = await api.getEnvironment(Uri.file(path.resolve(terminalCwd))); } else { diff --git a/src/test/features/terminal/activateMenuButton.unit.test.ts b/src/test/features/terminal/activateMenuButton.unit.test.ts index 07c3fb842..15e81b9b4 100644 --- a/src/test/features/terminal/activateMenuButton.unit.test.ts +++ b/src/test/features/terminal/activateMenuButton.unit.test.ts @@ -54,4 +54,19 @@ suite('Terminal - Activate Menu Button', () => { 'Should set pythonTerminalActivation to false for task terminal', ); }); + + test('should clear activation contexts when no environment is resolved', async () => { + await setActivateMenuButtonContext(mockTerminal, undefined); + + assert.ok( + executeCommandStub.calledWith('setContext', 'pythonTerminalActivation', false), + 'Should hide the activation button', + ); + assert.ok( + executeCommandStub.calledWith('setContext', 'pythonTerminalActivated', false), + 'Should clear the activated state', + ); + assert.ok(isTaskTerminalStub.notCalled, 'Should not inspect terminal type without an environment'); + assert.ok(isActivatableEnvironmentStub.notCalled, 'Should not inspect an undefined environment'); + }); }); diff --git a/src/test/features/terminal/utils.unit.test.ts b/src/test/features/terminal/utils.unit.test.ts index c95533527..ff890a569 100644 --- a/src/test/features/terminal/utils.unit.test.ts +++ b/src/test/features/terminal/utils.unit.test.ts @@ -1,6 +1,9 @@ import * as assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; -import { ExtensionTerminalOptions, Terminal, TerminalOptions } from 'vscode'; +import { ExtensionTerminalOptions, Terminal, TerminalOptions, Uri } from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; +import { VENV_MANAGER_ID } from '../../../common/constants'; import * as windowApis from '../../../common/window.apis'; import * as workspaceApis from '../../../common/workspace.apis'; import * as shellDetector from '../../../features/common/shellDetector'; @@ -10,10 +13,12 @@ import { ACT_TYPE_SHELL, AutoActivationType, getAutoActivationType, + getEnvironmentForTerminal, shouldActivateInCurrentTerminal, shouldSkipTerminalActivation, waitForShellIntegration, } from '../../../features/terminal/utils'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; interface MockWorkspaceConfig { get: sinon.SinonStub; @@ -21,6 +26,287 @@ interface MockWorkspaceConfig { update: sinon.SinonStub; } +suite('Terminal Utils - getEnvironmentForTerminal', () => { + const workspaceRoot = path.resolve('terminal-cwd-workspace'); + + teardown(() => { + sinon.restore(); + }); + + function createProject(name: string, fsPath: string): PythonProject { + return { name, uri: Uri.file(fsPath) } as PythonProject; + } + + function createVenv(projectPath: string, id: string, directory: string = '.venv'): PythonEnvironment { + const sysPrefix = path.join(projectPath, directory); + return createMockPythonEnvironment({ + name: directory, + id, + managerId: VENV_MANAGER_ID, + envPath: path.join(sysPrefix, 'bin', 'python'), + sysPrefix, + }); + } + + function createExternalEnvironment(id: string): PythonEnvironment { + return createMockPythonEnvironment({ + name: id, + id, + managerId: 'ms-python.python:conda', + envPath: path.resolve('external-environments', id, 'python'), + }); + } + + function createTerminal(cwd?: string, shellCwd?: string): Terminal { + return { + creationOptions: cwd ? ({ cwd } as TerminalOptions) : ({} as TerminalOptions), + shellIntegration: shellCwd ? ({ cwd: Uri.file(shellCwd) } as Terminal['shellIntegration']) : undefined, + } as Terminal; + } + + function createApi( + projects: PythonProject[], + environmentsByProject: Map, + environments: PythonEnvironment[], + globalEnvironment?: PythonEnvironment, + ): PythonEnvironmentApi { + return { + getPythonProjects: sinon.stub().returns(projects), + getEnvironment: sinon.stub().callsFake(async (scope: Uri | undefined) => { + if (!scope) { + return globalEnvironment; + } + return environmentsByProject.get(scope.toString()); + }), + getEnvironments: sinon.stub().resolves(environments), + } as unknown as PythonEnvironmentApi; + } + + test('preserves a workspace-root venv for terminals in project subdirectories', async () => { + const project = createProject('workspace', workspaceRoot); + const environment = createVenv(workspaceRoot, 'root'); + const api = createApi([project], new Map([[project.uri.toString(), environment]]), [environment]); + + const result = await getEnvironmentForTerminal(api, createTerminal(path.join(workspaceRoot, 'src'))); + + assert.strictEqual(result, environment); + sinon.assert.notCalled(api.getEnvironments as sinon.SinonStub); + }); + + test('uses the venv associated with terminal cwd instead of a sibling project venv', async () => { + const projectA = path.join(workspaceRoot, 'ProjectA'); + const projectB = path.join(workspaceRoot, 'ProjectB'); + const projectC = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const envA = createVenv(projectA, 'a'); + const envB = createVenv(projectB, 'b'); + const envC = createVenv(projectC, 'c'); + const api = createApi( + [rootProject], + new Map([[rootProject.uri.toString(), envA]]), + [envA, envB, envC], + ); + + const result = await getEnvironmentForTerminal(api, createTerminal(projectC)); + + assert.strictEqual(result, envC); + sinon.assert.calledOnceWithExactly(api.getEnvironments as sinon.SinonStub, 'all'); + }); + + test('prefers shell integration cwd over terminal creation cwd', async () => { + const projectA = path.join(workspaceRoot, 'ProjectA'); + const projectC = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const envA = createVenv(projectA, 'a'); + const envC = createVenv(projectC, 'c'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), envA]]), [envA, envC]); + + const result = await getEnvironmentForTerminal(api, createTerminal(projectA, projectC)); + + assert.strictEqual(result, envC); + }); + + test('returns undefined when multiple venvs are equally close to terminal cwd', async () => { + const projectA = path.join(workspaceRoot, 'ProjectA'); + const projectC = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const envA = createVenv(projectA, 'a'); + const envC1 = createVenv(projectC, 'c-1', '.venv'); + const envC2 = createVenv(projectC, 'c-2', 'venv'); + const api = createApi( + [rootProject], + new Map([[rootProject.uri.toString(), envA]]), + [envA, envC1, envC2], + ); + + const result = await getEnvironmentForTerminal(api, createTerminal(projectC)); + + assert.strictEqual(result, undefined); + }); + + test('returns undefined rather than using a sibling venv when cwd has no local venv', async () => { + const projectA = path.join(workspaceRoot, 'ProjectA'); + const projectC = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const envA = createVenv(projectA, 'a'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), envA]]), [envA]); + + const result = await getEnvironmentForTerminal(api, createTerminal(projectC)); + + assert.strictEqual(result, undefined); + }); + + test('does not treat a global venv as local to terminal cwd', async () => { + const projectA = path.join(workspaceRoot, 'ProjectA'); + const projectC = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const envA = createVenv(projectA, 'a'); + const globalEnv = createVenv(path.parse(workspaceRoot).root, 'global'); + const api = createApi( + [rootProject], + new Map([[rootProject.uri.toString(), envA]]), + [envA, globalEnv], + ); + + const result = await getEnvironmentForTerminal(api, createTerminal(projectC)); + + assert.strictEqual(result, undefined); + }); + + test('preserves a selected descendant venv when terminal cwd is the workspace root', async () => { + const projectA = path.join(workspaceRoot, 'ProjectA'); + const rootProject = createProject('workspace', workspaceRoot); + const envA = createVenv(projectA, 'a'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), envA]]), [envA]); + + const result = await getEnvironmentForTerminal(api, createTerminal(workspaceRoot)); + + assert.strictEqual(result, envA); + sinon.assert.notCalled(api.getEnvironments as sinon.SinonStub); + }); + + test('does not infer a venv when the containing project has no selected environment', async () => { + const projectC = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const envC = createVenv(projectC, 'c'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), undefined]]), [envC]); + + const result = await getEnvironmentForTerminal(api, createTerminal(projectC)); + + assert.strictEqual(result, undefined); + sinon.assert.notCalled(api.getEnvironments as sinon.SinonStub); + }); + + test('does not use another project environment when the cwd project has no selection', async () => { + const projectAPath = path.join(workspaceRoot, 'ProjectA'); + const projectCPath = path.join(workspaceRoot, 'ProjectC'); + const projectA = createProject('ProjectA', projectAPath); + const projectC = createProject('ProjectC', projectCPath); + const envA = createVenv(projectAPath, 'a'); + const api = createApi( + [projectA, projectC], + new Map([ + [projectA.uri.toString(), envA], + [projectC.uri.toString(), undefined], + ]), + [envA], + ); + + const result = await getEnvironmentForTerminal(api, createTerminal(projectCPath)); + + assert.strictEqual(result, undefined); + sinon.assert.notCalled(api.getEnvironments as sinon.SinonStub); + }); + + test('fails closed when listing environments rejects', async () => { + const projectA = path.join(workspaceRoot, 'ProjectA'); + const projectC = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const envA = createVenv(projectA, 'a'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), envA]]), [envA]); + (api.getEnvironments as sinon.SinonStub).rejects(new Error('lookup failed')); + + const result = await getEnvironmentForTerminal(api, createTerminal(projectC)); + + assert.strictEqual(result, undefined); + }); + + test('fails closed when listing environments times out', async () => { + const clock = sinon.useFakeTimers(); + const projectA = path.join(workspaceRoot, 'ProjectA'); + const projectC = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const envA = createVenv(projectA, 'a'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), envA]]), [envA]); + (api.getEnvironments as sinon.SinonStub).returns(new Promise(() => undefined)); + + const resultPromise = getEnvironmentForTerminal(api, createTerminal(projectC)); + await clock.tickAsync(1000); + + assert.strictEqual(await resultPromise, undefined); + }); + + test('preserves an external environment selected for the containing project', async () => { + const rootProject = createProject('workspace', workspaceRoot); + const environment = createExternalEnvironment('external'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), environment]]), []); + + const result = await getEnvironmentForTerminal( + api, + createTerminal(path.join(workspaceRoot, 'ProjectC')), + ); + + assert.strictEqual(result, environment); + sinon.assert.notCalled(api.getEnvironments as sinon.SinonStub); + }); + + test('preserves the selected environment for the most specific registered project', async () => { + const projectCPath = path.join(workspaceRoot, 'ProjectC'); + const rootProject = createProject('workspace', workspaceRoot); + const projectC = createProject('ProjectC', projectCPath); + const rootEnvironment = createVenv(path.join(workspaceRoot, 'ProjectA'), 'a'); + const projectEnvironment = createExternalEnvironment('project-c'); + const api = createApi( + [rootProject, projectC], + new Map([ + [rootProject.uri.toString(), rootEnvironment], + [projectC.uri.toString(), projectEnvironment], + ]), + [rootEnvironment], + ); + + const result = await getEnvironmentForTerminal( + api, + createTerminal(path.join(projectCPath, 'src')), + ); + + assert.strictEqual(result, projectEnvironment); + sinon.assert.notCalled(api.getEnvironments as sinon.SinonStub); + }); + + test('preserves the existing project fallback when terminal cwd is outside the workspace', async () => { + const rootProject = createProject('workspace', workspaceRoot); + const environment = createVenv(path.join(workspaceRoot, 'ProjectA'), 'a'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), environment]]), [environment]); + + const result = await getEnvironmentForTerminal(api, createTerminal(path.resolve('outside-workspace'))); + + assert.strictEqual(result, environment); + sinon.assert.notCalled(api.getEnvironments as sinon.SinonStub); + }); + + test('preserves the existing single-project behavior when terminal cwd is unavailable', async () => { + const rootProject = createProject('workspace', workspaceRoot); + const environment = createVenv(path.join(workspaceRoot, 'ProjectA'), 'a'); + const api = createApi([rootProject], new Map([[rootProject.uri.toString(), environment]]), [environment]); + + const result = await getEnvironmentForTerminal(api, createTerminal()); + + assert.strictEqual(result, environment); + sinon.assert.notCalled(api.getEnvironments as sinon.SinonStub); + }); +}); + suite('Terminal Utils - getAutoActivationType', () => { let mockGetConfiguration: sinon.SinonStub; let pyEnvsConfig: MockWorkspaceConfig;