diff --git a/src/common/pickers/managers.ts b/src/common/pickers/managers.ts index 4b0b9aa4..ad37e1e4 100644 --- a/src/common/pickers/managers.ts +++ b/src/common/pickers/managers.ts @@ -181,6 +181,7 @@ export async function pickCreator(creators: PythonProjectCreator[]): Promise { try { - const petPath = await getNativePythonToolsPath(); - - // Show quick pick menu for PET operation selection - const selectedOption = await window.showQuickPick( - [ - { - label: 'Find All Environments', - description: 'Finds all environments and reports them to the standard output', - detail: 'Runs: pet find --verbose', - }, - { - label: 'Resolve Environment...', - description: 'Resolves & reports the details of the environment to the standard output', - detail: 'Runs: pet resolve ', - }, - ], - { - placeHolder: 'Select a Python Environment Tool (PET) operation', - ignoreFocusOut: true, - }, - ); - - if (!selectedOption) { - return; // User cancelled - } - - const terminal = createTerminal({ - name: 'Python Environment Tool (PET)', - }); - terminal.show(); - - if (selectedOption.label === 'Find All Environments') { - // Run pet find --verbose - terminal.sendText(`"${petPath}" find --verbose`, true); - traceInfo(`Running PET find command: ${petPath} find --verbose`); - } else if (selectedOption.label === 'Resolve Environment...') { - // Show input box for path - const placeholder = isWindows() ? 'C:\\path\\to\\python\\executable' : '/path/to/python/executable'; - const inputPath = await window.showInputBox({ - prompt: 'Enter the path to the Python executable to resolve', - placeHolder: placeholder, - ignoreFocusOut: true, - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return 'Please enter a valid path'; - } - return null; - }, - }); - - if (!inputPath) { - return; // User cancelled - } - - // Run pet resolve with the provided path - terminal.sendText(`"${petPath}" resolve "${inputPath.trim()}"`, true); - traceInfo(`Running PET resolve command: ${petPath} resolve "${inputPath.trim()}"`); - } + await runPetInTerminalImpl(); } catch (error) { traceError('Error running PET in terminal', error); window.showErrorMessage(`Failed to run Python Environment Tool: ${error}`); diff --git a/src/helpers.ts b/src/helpers.ts index baff94e8..3a5992fa 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,11 +1,13 @@ -import { ExtensionContext, extensions, Uri, workspace } from 'vscode'; +import { ExtensionContext, extensions, QuickInputButtons, Uri, window, workspace } from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi } from './api'; import { traceError, traceInfo, traceWarn } from './common/logging'; import { normalizePath } from './common/utils/pathUtils'; +import { isWindows } from './common/utils/platformUtils'; +import { createTerminal, showInputBoxWithButtons } from './common/window.apis'; import { getConfiguration } from './common/workspace.apis'; import { getAutoActivationType } from './features/terminal/utils'; import { EnvironmentManagers, PythonProjectManager } from './internal.api'; -import { NativeEnvInfo, NativePythonFinder } from './managers/common/nativePythonFinder'; +import { getNativePythonToolsPath, NativeEnvInfo, NativePythonFinder } from './managers/common/nativePythonFinder'; /** * Collects relevant Python environment information for issue reporting @@ -137,6 +139,90 @@ export function getUserConfiguredSetting(section: string, key: string): T | u return undefined; } +/** + * Runs the Python Environment Tool (PET) in a terminal window, allowing users to + * execute various PET commands like finding all Python environments or resolving + * the details of a specific environment. + * + * + * @returns A Promise that resolves when the PET command has been executed or cancelled + */ +export async function runPetInTerminalImpl(): Promise { + const petPath = await getNativePythonToolsPath(); + + // Show quick pick menu for PET operation selection + const selectedOption = await window.showQuickPick( + [ + { + label: 'Find All Environments', + description: 'Finds all environments and reports them to the standard output', + detail: 'Runs: pet find --verbose', + }, + { + label: 'Resolve Environment...', + description: 'Resolves & reports the details of the environment to the standard output', + detail: 'Runs: pet resolve ', + }, + ], + { + placeHolder: 'Select a Python Environment Tool (PET) operation', + ignoreFocusOut: true, + }, + ); + + if (!selectedOption) { + return; // User cancelled + } + + if (selectedOption.label === 'Find All Environments') { + // Create and show terminal immediately for 'Find All Environments' option + const terminal = createTerminal({ + name: 'Python Environment Tool (PET)', + }); + terminal.show(); + + // Run pet find --verbose + terminal.sendText(`"${petPath}" find --verbose`, true); + traceInfo(`Running PET find command: ${petPath} find --verbose`); + } else if (selectedOption.label === 'Resolve Environment...') { + try { + // Show input box for path with back button + const placeholder = isWindows() ? 'C:\\path\\to\\python\\executable' : '/path/to/python/executable'; + const inputPath = await showInputBoxWithButtons({ + prompt: 'Enter the path to the Python executable to resolve', + placeHolder: placeholder, + ignoreFocusOut: true, + showBackButton: true, + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return 'Please enter a valid path'; + } + return null; + }, + }); + + if (inputPath) { + // Only create and show terminal after path has been entered + const terminal = createTerminal({ + name: 'Python Environment Tool (PET)', + }); + terminal.show(); + + // Run pet resolve with the provided path + terminal.sendText(`"${petPath}" resolve "${inputPath.trim()}"`, true); + traceInfo(`Running PET resolve command: ${petPath} resolve "${inputPath.trim()}"`); + } + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // If back button was clicked, restart the flow + await runPetInTerminalImpl(); + return; + } + throw ex; // Re-throw other errors + } + } +} + /** * Sets the default Python interpreter for the workspace if the user has not explicitly set 'defaultEnvManager'. * @param nativeFinder - used to resolve interpreter paths. diff --git a/src/managers/builtin/venvStepBasedFlow.ts b/src/managers/builtin/venvStepBasedFlow.ts new file mode 100644 index 00000000..b679cd62 --- /dev/null +++ b/src/managers/builtin/venvStepBasedFlow.ts @@ -0,0 +1,400 @@ +import * as fse from 'fs-extra'; +import * as path from 'path'; +import { LogOutputChannel, QuickInputButtons, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; +import { Pickers, VenvManagerStrings } from '../../common/localize'; +import { EventNames } from '../../common/telemetry/constants'; +import { sendTelemetryEvent } from '../../common/telemetry/sender'; +import { showInputBoxWithButtons, showQuickPickWithButtons } from '../../common/window.apis'; +import { NativePythonFinder } from '../common/nativePythonFinder'; +import { getWorkspacePackagesToInstall, PipPackages } from './pipUtils'; +import { CreateEnvironmentResult, createWithProgress, ensureGlobalEnv } from './venvUtils'; + +/** + * State interface for the venv creation flow. + * + * This keeps track of all user selections throughout the flow, + * allowing the wizard to maintain context when navigating backwards. + * Each property represents a piece of data collected during a step in the workflow. + */ +interface VenvCreationState { + // Base Python environment to use for creating the venv + basePython?: PythonEnvironment; + + // Whether to use quick create or custom create + isQuickCreate?: boolean; + + // Name for the venv + venvName?: string; + + // Packages to install in the venv + // undefined = not yet set, null = user canceled during package selection + packages?: PipPackages | null; + + // Store the sorted environments to avoid re-sorting when navigating back + sortedEnvs?: PythonEnvironment[]; + + // Tracks whether user completed the package selection step + // undefined = not yet reached, true = completed, false = canceled + packageSelectionCompleted?: boolean; + + // References to API and project needed for package selection + api?: PythonEnvironmentApi; + project?: PythonProject[]; + + // Root directory where venv will be created (used for path validation) + venvRoot?: Uri; +} +/** + * Type definition for step functions in the wizard-like flow. + * + * Each step function: + * 1. Takes the current state as input + * 2. Interacts with the user through UI + * 3. Updates the state with new data + * 4. Returns the next step function to execute or null if flow is complete + * + * This pattern enables proper back navigation between steps without losing context. + */ +type StepFunction = (state: VenvCreationState) => Promise; + +/** + * Step 1: Select quick create or custom create + */ +async function selectCreateType(state: VenvCreationState): Promise { + try { + if (!state.sortedEnvs || state.sortedEnvs.length === 0) { + return null; + } + + // Show the quick/custom selection dialog with descriptive options + const selection = await showQuickPickWithButtons( + [ + { + label: VenvManagerStrings.quickCreate, + description: VenvManagerStrings.quickCreateDescription, + detail: `Uses Python version ${state.sortedEnvs[0].version} and installs workspace dependencies.`, + }, + { + label: VenvManagerStrings.customize, + description: VenvManagerStrings.customizeDescription, + }, + ], + { + placeHolder: VenvManagerStrings.selectQuickOrCustomize, + ignoreFocusOut: true, + showBackButton: true, + }, + ); + + // Handle cancellation - return null to exit the flow + if (!selection || Array.isArray(selection)) { + return null; // Exit the flow without creating an environment + } + + if (selection.label === VenvManagerStrings.quickCreate) { + // For quick create, use the first Python environment and proceed to completion + state.isQuickCreate = true; + state.basePython = state.sortedEnvs[0]; + // Quick create is complete - no more steps needed + return null; + } else { + // For custom create, move to Python selection step + state.isQuickCreate = false; + // Next step: select base Python version + return selectBasePython; + } + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // This is the first step, so return null to exit the flow + return null; + } + throw ex; + } +} + +/** + * Step 2: Select base Python interpreter to use for venv creation + */ +async function selectBasePython(state: VenvCreationState): Promise { + try { + if (!state.sortedEnvs || state.sortedEnvs.length === 0) { + return null; + } + + // Create items for each available Python environment with descriptive labels + const items = state.sortedEnvs.map((e) => { + const pathDescription = e.displayPath; + const description = + e.description && e.description.trim() ? `${e.description} (${pathDescription})` : pathDescription; + + return { + label: e.displayName ?? e.name, + description: description, + e: e, + }; + }); + + // Show Python environment selection dialog with back button + const selected = await showQuickPickWithButtons(items, { + placeHolder: Pickers.Environments.selectEnvironment, + ignoreFocusOut: true, + showBackButton: true, + }); + + // Handle cancellation (Escape key or dialog close) + if (!selected || Array.isArray(selected)) { + return null; // Exit the flow without creating an environment + } + + // Update state with selected Python environment + const basePython = (selected as { e: PythonEnvironment }).e; + if (!basePython || !basePython.execInfo) { + // Invalid selection + return null; + } + + state.basePython = basePython; + + // Next step: input venv name + return enterEnvironmentName; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // Go back to create type selection if we came from there + if (state.isQuickCreate !== undefined) { + return selectCreateType; + } + return null; + } + throw ex; + } +} + +/** + * Step 3: Enter environment name + */ +async function enterEnvironmentName(state: VenvCreationState): Promise { + try { + // Show input box for venv name with back button + const name = await showInputBoxWithButtons({ + prompt: VenvManagerStrings.venvName, + value: '.venv', // Default name + ignoreFocusOut: true, + showBackButton: true, + validateInput: async (value) => { + if (!value) { + return VenvManagerStrings.venvNameErrorEmpty; + } + + // Validate that the path doesn't already exist + if (state.venvRoot) { + try { + const fullPath = path.join(state.venvRoot.fsPath, value); + if (await fse.pathExists(fullPath)) { + return VenvManagerStrings.venvNameErrorExists; + } + } catch (_) { + // Ignore file system errors during validation + } + } + return null; + }, + }); + + // Handle cancellation (Escape key or dialog close) + if (!name) { + return null; // Exit the flow without creating an environment + } + + state.venvName = name; + + // Next step: select packages + return selectPackages; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // Go back to base Python selection + return selectBasePython; + } + throw ex; + } +} + +/** + * Step 4: Select packages to install + */ +async function selectPackages(state: VenvCreationState): Promise { + try { + // Show package selection UI using existing function from pipUtils + + // Create packages structure with empty array and showing the skip option + const packagesOptions = { + showSkipOption: true, + install: [], + }; + + // Use existing getWorkspacePackagesToInstall that will show the UI with all options + // The function already handles showing workspace deps, PyPI options, and skip + if (state.api) { + const result = await getWorkspacePackagesToInstall( + state.api, + packagesOptions, + state.project, // Use project from state if available + undefined, // No environment yet since we're creating it + ); + + if (result !== undefined) { + // User made a selection or clicked Skip + state.packageSelectionCompleted = true; + state.packages = result; + } else { + // User pressed Escape or closed the dialog + state.packageSelectionCompleted = false; + state.packages = null; // Explicitly mark as canceled + } + } else { + // No API, can't show package selection + state.packageSelectionCompleted = true; + state.packages = { + install: [], + uninstall: [], + }; + } + + // Final step - no more steps after this + return null; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // Go back to environment name input + return enterEnvironmentName; + } + throw ex; + } +} + +/** + * Main entry point for the step-based venv creation flow. + * + * This function implements a step-based wizard pattern for creating Python virtual + * environments. The user can navigate through steps and also cancel at any point + * by pressing Escape or closing any dialog. + * + * @param nativeFinder Python finder for resolving Python paths + * @param api Python Environment API + * @param log Logger for recording operations + * @param manager Environment manager + * @param basePythons Available Python environments + * @param venvRoot Root directory where the venv will be created + * @param options Configuration options + * @returns The result of environment creation or undefined if cancelled at any point + */ +export async function createStepBasedVenvFlow( + nativeFinder: NativePythonFinder, + api: PythonEnvironmentApi, + log: LogOutputChannel, + manager: EnvironmentManager, + basePythons: PythonEnvironment[], + venvRoot: Uri, + options: { showQuickAndCustomOptions: boolean; additionalPackages?: string[] }, +): Promise { + // Sort and filter available Python environments + const sortedEnvs = ensureGlobalEnv(basePythons, log); + if (sortedEnvs.length === 0) { + return { + envCreationErr: 'No suitable Python environments found', + }; + } + + // Initialize the state object that will track user selections + const state: VenvCreationState = { + sortedEnvs, // Store sorted environments in state to avoid re-sorting + api, // Store API reference for package selection + project: [api.getPythonProject(venvRoot)].filter(Boolean) as PythonProject[], // Get project for venvRoot + venvRoot, // Store venvRoot for path validation + }; + + try { + // Determine the first step based on options + let currentStep: StepFunction | null = options.showQuickAndCustomOptions ? selectCreateType : selectBasePython; + + // Execute steps until completion or cancellation + // When a step returns null, it means either: + // 1. The step has completed successfully and there are no more steps + // 2. The user cancelled the step (pressed Escape or closed the dialog) + while (currentStep !== null) { + currentStep = await currentStep(state); + } + + // After workflow completes, check if we have all required data + + // Case 1: Quick create flow + if (state.isQuickCreate && state.basePython) { + // Use quick create flow + sendTelemetryEvent(EventNames.VENV_CREATION, undefined, { creationType: 'quick' }); + // Use the default .venv name for quick create + const quickEnvPath = path.join(venvRoot.fsPath, '.venv'); + return await createWithProgress(nativeFinder, api, log, manager, state.basePython, venvRoot, quickEnvPath, { + install: options.additionalPackages || [], + uninstall: [], + }); + } + // Case 2: Custom create flow + // Note: requires selectPackage step completed + else if ( + !state.isQuickCreate && + state.basePython && + state.venvName && + // The user went through all steps without cancellation + // (specifically checking that package selection wasn't canceled) + state.packageSelectionCompleted !== false + ) { + sendTelemetryEvent(EventNames.VENV_CREATION, undefined, { creationType: 'custom' }); + + const project = api.getPythonProject(venvRoot); + const envPath = path.join(venvRoot.fsPath, state.venvName); + + // Get packages to install - if the selectPackages step was completed, state.packages might already be set + // If not, we'll fetch packages here to ensure proper package detection + let packages = state.packages; + if (!packages) { + packages = await getWorkspacePackagesToInstall( + api, + { showSkipOption: true, install: [] }, + project ? [project] : undefined, + undefined, + log, + ); + } + + // Combine packages from multiple sources + const allPackages: string[] = []; + + // 1. User-selected packages from workspace dependencies or PyPI during the wizard flow + // (may be undefined if user skipped package selection or canceled) + if (packages?.install) { + allPackages.push(...packages.install); + } + + // 2. Additional packages provided by the caller of createStepBasedVenvFlow + // (e.g., packages required by the extension itself) + if (options.additionalPackages) { + allPackages.push(...options.additionalPackages); + } + + return await createWithProgress(nativeFinder, api, log, manager, state.basePython, venvRoot, envPath, { + install: allPackages, + uninstall: [], + }); + } + + // If we get here, the flow was cancelled (e.g., user pressed Escape) + // Return undefined to indicate no environment was created + return undefined; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // This should not happen as back navigation is handled within each step + // But if it does, restart the flow + return await createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options); + } + throw ex; // Re-throw other errors + } +} diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index ebcd630a..2af8056f 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -7,12 +7,10 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { Common, VenvManagerStrings } from '../../common/localize'; import { traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; -import { pickEnvironmentFrom } from '../../common/pickers/environments'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { showErrorMessage, - showInputBox, showOpenDialog, showQuickPick, showWarningMessage, @@ -27,8 +25,9 @@ import { } from '../common/nativePythonFinder'; import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils'; import { isUvInstalled, runPython, runUV } from './helpers'; -import { getProjectInstallable, getWorkspacePackagesToInstall, PipPackages } from './pipUtils'; +import { getProjectInstallable, PipPackages } from './pipUtils'; import { resolveSystemPythonEnvironmentPath } from './utils'; +import { createStepBasedVenvFlow } from './venvStepBasedFlow'; export const VENV_WORKSPACE_KEY = `${ENVS_EXTENSION_ID}:venv:WORKSPACE_SELECTED`; export const VENV_GLOBAL_KEY = `${ENVS_EXTENSION_ID}:venv:GLOBAL_SELECTED`; @@ -265,34 +264,7 @@ export async function getGlobalVenvLocation(): Promise { return undefined; } -async function createWithCustomization(version: string): Promise { - const selection: QuickPickItem | undefined = await showQuickPick( - [ - { - label: VenvManagerStrings.quickCreate, - description: VenvManagerStrings.quickCreateDescription, - detail: l10n.t('Uses Python version {0} and installs workspace dependencies.', version), - }, - { - label: VenvManagerStrings.customize, - description: VenvManagerStrings.customizeDescription, - }, - ], - { - placeHolder: VenvManagerStrings.selectQuickOrCustomize, - ignoreFocusOut: true, - }, - ); - - if (selection === undefined) { - return undefined; - } else if (selection.label === VenvManagerStrings.quickCreate) { - return false; - } - return true; -} - -async function createWithProgress( +export async function createWithProgress( nativeFinder: NativePythonFinder, api: PythonEnvironmentApi, log: LogOutputChannel, @@ -432,66 +404,7 @@ export async function createPythonVenv( venvRoot: Uri, options: { showQuickAndCustomOptions: boolean; additionalPackages?: string[] }, ): Promise { - const sortedEnvs = ensureGlobalEnv(basePythons, log); - - let customize: boolean | undefined = true; - if (options.showQuickAndCustomOptions) { - customize = await createWithCustomization(sortedEnvs[0].version); - } - - if (customize === undefined) { - return; - } else if (customize === false) { - return quickCreateVenv(nativeFinder, api, log, manager, sortedEnvs[0], venvRoot, options.additionalPackages); - } else { - sendTelemetryEvent(EventNames.VENV_CREATION, undefined, { creationType: 'custom' }); - } - const project = api.getPythonProject(venvRoot); - - const basePython = await pickEnvironmentFrom(sortedEnvs); - if (!basePython || !basePython.execInfo) { - log.error('No base python selected, cannot create virtual environment.'); - return { - envCreationErr: 'No base python selected, cannot create virtual environment.', - }; - } - - const name = await showInputBox({ - prompt: VenvManagerStrings.venvName, - value: '.venv', - ignoreFocusOut: true, - validateInput: async (value) => { - if (!value) { - return VenvManagerStrings.venvNameErrorEmpty; - } - if (await fsapi.pathExists(path.join(venvRoot.fsPath, value))) { - return VenvManagerStrings.venvNameErrorExists; - } - }, - }); - if (!name) { - log.error('No name entered, cannot create virtual environment.'); - return { - envCreationErr: 'No name entered, cannot create virtual environment.', - }; - } - - const envPath = path.join(venvRoot.fsPath, name); - - const packages = await getWorkspacePackagesToInstall( - api, - { showSkipOption: true, install: [] }, - project ? [project] : undefined, - undefined, - log, - ); - const allPackages = []; - allPackages.push(...(packages?.install ?? []), ...(options.additionalPackages ?? [])); - - return await createWithProgress(nativeFinder, api, log, manager, basePython, venvRoot, envPath, { - install: allPackages, - uninstall: [], - }); + return createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options); } export async function removeVenv(environment: PythonEnvironment, log: LogOutputChannel): Promise { diff --git a/src/managers/conda/condaStepBasedFlow.ts b/src/managers/conda/condaStepBasedFlow.ts new file mode 100644 index 00000000..6cf6ce80 --- /dev/null +++ b/src/managers/conda/condaStepBasedFlow.ts @@ -0,0 +1,334 @@ +import * as fse from 'fs-extra'; +import * as path from 'path'; +import { l10n, LogOutputChannel, QuickInputButtons, QuickPickItem, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { CondaStrings } from '../../common/localize'; +import { showInputBoxWithButtons, showQuickPickWithButtons } from '../../common/window.apis'; +import { + createNamedCondaEnvironment, + createPrefixCondaEnvironment, + getLocation, + getName, + trimVersionToMajorMinor, +} from './condaUtils'; + +// Recommended Python version for Conda environments +const RECOMMENDED_CONDA_PYTHON = '3.11.11'; + +/** + * State interface for the Conda environment creation flow. + * + * This keeps track of all user selections throughout the flow, + * allowing the wizard to maintain context when navigating backwards. + */ +interface CondaCreationState { + // Type of Conda environment to create (named or prefix) + envType?: string; + + // Python version to install in the environment + pythonVersion?: string; + + // For named environments + envName?: string; + + // For prefix environments + prefix?: string; + fsPath?: string; + + // Additional context + uris?: Uri[]; + + // API reference for Python environment operations + api: PythonEnvironmentApi; +} + +/** + * Type definition for step functions in the wizard-like flow. + * + * Each step function: + * 1. Takes the current state as input + * 2. Interacts with the user through UI + * 3. Updates the state with new data + * 4. Returns the next step function to execute or null if flow is complete + */ +type StepFunction = (state: CondaCreationState) => Promise; + +/** + * Step 1: Select environment type (named or prefix) + */ +async function selectEnvironmentType(state: CondaCreationState): Promise { + try { + // Skip this step if we have multiple URIs (force named environment) + if (state.uris && state.uris.length > 1) { + state.envType = 'Named'; + return selectPythonVersion; + } + + const selection = (await showQuickPickWithButtons( + [ + { label: CondaStrings.condaNamed, description: CondaStrings.condaNamedDescription }, + { label: CondaStrings.condaPrefix, description: CondaStrings.condaPrefixDescription }, + ], + { + placeHolder: CondaStrings.condaSelectEnvType, + ignoreFocusOut: true, + showBackButton: true, + }, + )) as QuickPickItem | undefined; + + if (!selection) { + return null; + } + + state.envType = selection.label; + + // Next step: select Python version + return selectPythonVersion; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // This is the first step, so return null to exit the flow + return null; + } + throw ex; + } +} + +/** + * Step 2: Select Python version + */ +async function selectPythonVersion(state: CondaCreationState): Promise { + try { + const api = state.api; + if (!api) { + return null; + } + + const envs = await api.getEnvironments('global'); + let versions = Array.from( + new Set( + envs + .map((env: PythonEnvironment) => env.version) + .filter(Boolean) + .map((v: string) => trimVersionToMajorMinor(v)), // cut to 3 digits + ), + ); + + // Sort versions by major version (descending), ignoring minor/patch for simplicity + const parseMajorMinor = (v: string) => { + const m = v.match(/^(\\d+)(?:\\.(\\d+))?/); + return { major: m ? Number(m[1]) : 0, minor: m && m[2] ? Number(m[2]) : 0 }; + }; + + versions = versions.sort((a, b) => { + const pa = parseMajorMinor(a as string); + const pb = parseMajorMinor(b as string); + if (pa.major !== pb.major) { + return pb.major - pa.major; + } // desc by major + return pb.minor - pa.minor; // desc by minor + }); + + if (!versions || versions.length === 0) { + versions = ['3.13', '3.12', '3.11', '3.10', '3.9']; + } + + const items: QuickPickItem[] = versions.map((v: unknown) => ({ + label: v === RECOMMENDED_CONDA_PYTHON ? `$(star-full) Python` : 'Python', + description: String(v), + })); + + const selection = await showQuickPickWithButtons(items, { + placeHolder: l10n.t('Select the version of Python to install in the environment'), + matchOnDescription: true, + ignoreFocusOut: true, + showBackButton: true, + }); + + if (!selection) { + return null; + } + + state.pythonVersion = (selection as QuickPickItem).description; + + // Next step depends on environment type + return state.envType === 'Named' ? enterEnvironmentName : selectLocation; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // Go back to environment type selection + return selectEnvironmentType; + } + throw ex; + } +} + +/** + * Step 3a: Enter environment name (for named environments) + */ +async function enterEnvironmentName(state: CondaCreationState): Promise { + try { + // Try to get a suggested name from project + const suggestedName = getName(state.api, state.uris); + + const name = await showInputBoxWithButtons({ + prompt: CondaStrings.condaNamedInput, + value: suggestedName, + ignoreFocusOut: true, + showBackButton: true, + }); + + if (!name) { + return null; + } + + state.envName = name; + + // Final step - proceed to create environment + return null; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // Go back to Python version selection + return selectPythonVersion; + } + throw ex; + } +} + +/** + * Step 3b: Select location (for prefix environments) + */ +async function selectLocation(state: CondaCreationState): Promise { + try { + // Get location using imported getLocation helper + const fsPath = await getLocation(state.api, state.uris || []); + + if (!fsPath) { + return null; + } + + state.fsPath = fsPath; + + // Next step: enter environment name + return enterPrefixName; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // Go back to Python version selection + return selectPythonVersion; + } + throw ex; + } +} + +/** + * Step 4: Enter prefix name (for prefix environments) + */ +async function enterPrefixName(state: CondaCreationState): Promise { + try { + if (!state.fsPath) { + return null; + } + + let name = './.conda'; + const defaultPathExists = await fse.pathExists(path.join(state.fsPath, '.conda')); + + // If default name exists, ask for a new name + if (defaultPathExists) { + const newName = await showInputBoxWithButtons({ + prompt: l10n.t('Environment "{0}" already exists. Enter a different name', name), + ignoreFocusOut: true, + showBackButton: true, + validateInput: async (value) => { + // Check if the proposed name already exists + if (!value) { + return l10n.t('Name cannot be empty'); + } + + // Get full path based on input + const fullPath = path.isAbsolute(value) ? value : path.join(state.fsPath!, value); + + // Check if path exists + try { + if (await fse.pathExists(fullPath)) { + return CondaStrings.condaExists; + } + } catch (_) { + // Ignore file system errors during validation + } + + return undefined; + }, + }); + + // If user cancels or presses escape + if (!newName) { + return null; + } + + name = newName; + } + + state.prefix = path.isAbsolute(name) ? name : path.join(state.fsPath, name); + + // Final step - proceed to create environment + return null; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // Go back to location selection + return selectLocation; + } + throw ex; + } +} + +/** + * Main entry point for the step-based Conda environment creation flow. + * + * This function implements a step-based wizard pattern for creating Conda + * environments. This implementation allows users to navigate back to the immediately + * previous step while preserving their selections. + * + * @param api Python Environment API + * @param log Logger for recording operations + * @param manager Environment manager + * @param uris Optional URIs for determining the environment location + * @returns The created environment or undefined if cancelled + */ +export async function createStepBasedCondaFlow( + api: PythonEnvironmentApi, + log: LogOutputChannel, + manager: EnvironmentManager, + uris?: Uri | Uri[], +): Promise { + // Initialize the state object that will track user selections + const state: CondaCreationState = { + api: api, + uris: Array.isArray(uris) ? uris : uris ? [uris] : [], + }; + + try { + // Start with the first step + let currentStep: StepFunction | null = selectEnvironmentType; + + // Execute steps until completion or cancellation + while (currentStep !== null) { + currentStep = await currentStep(state); + } + + // If we have all required data, create the environment + if (state.envType === CondaStrings.condaNamed && state.envName) { + return await createNamedCondaEnvironment(api, log, manager, state.envName, state.pythonVersion); + } else if (state.envType === CondaStrings.condaPrefix && state.prefix) { + // For prefix environments, we need to pass the fsPath where the environment will be created + return await createPrefixCondaEnvironment(api, log, manager, state.fsPath, state.pythonVersion); + } + + // If we get here, the flow was likely cancelled + return undefined; + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // This should not happen as back navigation is handled within each step + // But if it does, restart the flow + return await createStepBasedCondaFlow(api, log, manager, uris); + } + throw ex; // Re-throw other errors + } +} diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 418fb62e..5897930a 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -37,8 +37,7 @@ import { untildify } from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import { showErrorMessage, - showInputBox, - showQuickPick, + showInputBoxWithButtons, showQuickPickWithButtons, withProgress, } from '../../common/window.apis'; @@ -57,6 +56,7 @@ import { Installable } from '../common/types'; import { shortVersion, sortEnvironments } from '../common/utils'; import { CondaEnvManager } from './condaEnvManager'; import { getCondaHookPs1Path, getLocalActivationScript } from './condaSourcingUtils'; +import { createStepBasedCondaFlow } from './condaStepBasedFlow'; export const CONDA_PATH_KEY = `${ENVS_EXTENSION_ID}:conda:CONDA_PATH`; export const CONDA_PREFIXES_KEY = `${ENVS_EXTENSION_ID}:conda:CONDA_PREFIXES`; @@ -241,7 +241,11 @@ async function runConda(args: string[], log?: LogOutputChannel, token?: Cancella return await _runConda(conda, args, log, token); } -async function runCondaExecutable(args: string[], log?: LogOutputChannel, token?: CancellationToken): Promise { +export async function runCondaExecutable( + args: string[], + log?: LogOutputChannel, + token?: CancellationToken, +): Promise { const conda = await getCondaExecutable(undefined); return await _runConda(conda, args, log, token); } @@ -253,7 +257,7 @@ async function getCondaInfo(): Promise { } let prefixes: string[] | undefined; -async function getPrefixes(): Promise { +export async function getPrefixes(): Promise { if (prefixes) { return prefixes; } @@ -275,7 +279,7 @@ export async function getDefaultCondaPrefix(): Promise { return prefixes.length > 0 ? prefixes[0] : path.join(os.homedir(), '.conda', 'envs'); } -async function getVersion(root: string): Promise { +export async function getVersion(root: string): Promise { const files = await fse.readdir(path.join(root, 'conda-meta')); for (let file of files) { if (file.startsWith('python-3') && file.endsWith('.json')) { @@ -307,7 +311,7 @@ function isPrefixOf(roots: string[], e: string): boolean { * @param envManager The environment manager instance * @returns Promise resolving to a PythonEnvironmentInfo object */ -async function getNamedCondaPythonInfo( +export async function getNamedCondaPythonInfo( name: string, prefix: string, executable: string, @@ -351,7 +355,7 @@ async function getNamedCondaPythonInfo( * @param envManager The environment manager instance * @returns Promise resolving to a PythonEnvironmentInfo object */ -async function getPrefixesCondaPythonInfo( +export async function getPrefixesCondaPythonInfo( prefix: string, executable: string, version: string, @@ -710,7 +714,7 @@ export async function refreshCondaEnvs( return []; } -function getName(api: PythonEnvironmentApi, uris?: Uri | Uri[]): string | undefined { +export function getName(api: PythonEnvironmentApi, uris?: Uri | Uri[]): string | undefined { if (!uris) { return undefined; } @@ -720,7 +724,7 @@ function getName(api: PythonEnvironmentApi, uris?: Uri | Uri[]): string | undefi return api.getPythonProject(Array.isArray(uris) ? uris[0] : uris)?.name; } -async function getLocation(api: PythonEnvironmentApi, uris: Uri | Uri[]): Promise { +export async function getLocation(api: PythonEnvironmentApi, uris: Uri | Uri[]): Promise { if (!uris || (Array.isArray(uris) && (uris.length === 0 || uris.length > 1))) { const projects: PythonProject[] = []; if (Array.isArray(uris)) { @@ -740,7 +744,7 @@ async function getLocation(api: PythonEnvironmentApi, uris: Uri | Uri[]): Promis } const RECOMMENDED_CONDA_PYTHON = '3.11.11'; -function trimVersionToMajorMinor(version: string): string { +export function trimVersionToMajorMinor(version: string): string { const match = version.match(/^(\d+\.\d+\.\d+)/); return match ? match[1] : version; } @@ -804,46 +808,32 @@ export async function createCondaEnvironment( manager: EnvironmentManager, uris?: Uri | Uri[], ): Promise { - // step1 ask user for named or prefix environment - const envType = - Array.isArray(uris) && uris.length > 1 - ? 'Named' - : ( - await showQuickPick( - [ - { label: CondaStrings.condaNamed, description: CondaStrings.condaNamedDescription }, - { label: CondaStrings.condaPrefix, description: CondaStrings.condaPrefixDescription }, - ], - { - placeHolder: CondaStrings.condaSelectEnvType, - ignoreFocusOut: true, - }, - ) - )?.label; - - const pythonVersion = await pickPythonVersion(api); - if (envType) { - return envType === CondaStrings.condaNamed - ? await createNamedCondaEnvironment(api, log, manager, getName(api, uris ?? []), pythonVersion) - : await createPrefixCondaEnvironment(api, log, manager, await getLocation(api, uris ?? []), pythonVersion); - } - return undefined; + return createStepBasedCondaFlow(api, log, manager, uris); } -async function createNamedCondaEnvironment( +export async function createNamedCondaEnvironment( api: PythonEnvironmentApi, log: LogOutputChannel, manager: EnvironmentManager, name?: string, pythonVersion?: string, ): Promise { - name = await showInputBox({ - prompt: CondaStrings.condaNamedInput, - value: name, - ignoreFocusOut: true, - }); - if (!name) { - return; + try { + name = await showInputBoxWithButtons({ + prompt: CondaStrings.condaNamedInput, + value: name, + ignoreFocusOut: true, + showBackButton: true, + }); + if (!name) { + return; + } + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // If back button was pressed, go back to the environment type selection + return await createCondaEnvironment(api, log, manager); + } + throw ex; } const envName: string = name; @@ -897,76 +887,85 @@ async function createNamedCondaEnvironment( ); } -async function createPrefixCondaEnvironment( +export async function createPrefixCondaEnvironment( api: PythonEnvironmentApi, log: LogOutputChannel, manager: EnvironmentManager, fsPath?: string, pythonVersion?: string, ): Promise { - if (!fsPath) { - return; - } - - let name = `./.conda`; - if (await fse.pathExists(path.join(fsPath, '.conda'))) { - log.warn(`Environment "${path.join(fsPath, '.conda')}" already exists`); - const newName = await showInputBox({ - prompt: l10n.t('Environment "{0}" already exists. Enter a different name', name), - ignoreFocusOut: true, - validateInput: (value) => { - if (value === name) { - return CondaStrings.condaExists; - } - return undefined; - }, - }); - if (!newName) { + try { + if (!fsPath) { return; } - name = newName; - } - const prefix: string = path.isAbsolute(name) ? name : path.join(fsPath, name); + let name = `./.conda`; + if (await fse.pathExists(path.join(fsPath, '.conda'))) { + log.warn(`Environment "${path.join(fsPath, '.conda')}" already exists`); + const newName = await showInputBoxWithButtons({ + prompt: l10n.t('Environment "{0}" already exists. Enter a different name', name), + ignoreFocusOut: true, + showBackButton: true, + validateInput: (value) => { + if (value === name) { + return CondaStrings.condaExists; + } + return undefined; + }, + }); + if (!newName) { + return; + } + name = newName; + } - const runArgs = ['create', '--yes', '--prefix', prefix]; - if (pythonVersion) { - runArgs.push(`python=${pythonVersion}`); - } else { - runArgs.push('python'); - } + const prefix: string = path.isAbsolute(name) ? name : path.join(fsPath, name); - return await withProgress( - { - location: ProgressLocation.Notification, - title: `Creating conda environment: ${name}`, - }, - async () => { - try { - const bin = os.platform() === 'win32' ? 'python.exe' : 'python'; - const output = await runCondaExecutable(runArgs); - log.info(output); - const version = await getVersion(prefix); + const runArgs = ['create', '--yes', '--prefix', prefix]; + if (pythonVersion) { + runArgs.push(`python=${pythonVersion}`); + } else { + runArgs.push('python'); + } - const environment = api.createPythonEnvironmentItem( - await getPrefixesCondaPythonInfo( - prefix, - path.join(prefix, bin), - version, - await getConda(), + return await withProgress( + { + location: ProgressLocation.Notification, + title: `Creating conda environment: ${name}`, + }, + async () => { + try { + const bin = os.platform() === 'win32' ? 'python.exe' : 'python'; + const output = await runCondaExecutable(runArgs); + log.info(output); + const version = await getVersion(prefix); + + const environment = api.createPythonEnvironmentItem( + await getPrefixesCondaPythonInfo( + prefix, + path.join(prefix, bin), + version, + await getConda(), + manager, + ), manager, - ), - manager, - ); - return environment; - } catch (e) { - log.error('Failed to create conda environment', e); - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaCreateFailed, log); - }); - } - }, - ); + ); + return environment; + } catch (e) { + log.error('Failed to create conda environment', e); + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaCreateFailed, log); + }); + } + }, + ); + } catch (ex) { + if (ex === QuickInputButtons.Back) { + // If back button was pressed, go back to the environment type selection + return await createCondaEnvironment(api, log, manager); + } + throw ex; + } } export async function generateName(fsPath: string): Promise {