Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,13 +523,15 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
}
}),
terminalActivation.onDidChangeTerminalActivationState(async (e) => {
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);
}
}
}),
Expand Down
8 changes: 4 additions & 4 deletions src/features/terminal/activateMenuButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import { isTaskTerminal } from './utils';

export async function setActivateMenuButtonContext(
terminal: Terminal,
env: PythonEnvironment,
env: PythonEnvironment | undefined,
activated?: boolean,
): Promise<void> {
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);
}
}
156 changes: 151 additions & 5 deletions src/features/terminal/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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<PythonEnvironment | undefined> {
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<typeof setTimeout> | 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(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, a new terminal could activate a sibling project’s venv because workspace-level selection was checked before terminal cwd. The resolver now uses cwd, rejects sibling venvs, and selects the nearest unambiguous local venv, or activates nothing if unsafe.

api: PythonEnvironmentApi,
projects: readonly PythonProject[],
cwd: string,
): Promise<CwdEnvironmentResolution> {
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<PythonEnvironment | undefined> {
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) {
Expand All @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions src/test/features/terminal/activateMenuButton.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading
Loading