Skip to content

New package Flow #324

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
May 19, 2025
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
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@
"category": "Python Envs",
"icon": "$(terminal)"
},
{
"command": "python-envs.createNewProjectFromTemplate",
"title": "%python-envs.createNewProjectFromTemplate.title%",
"category": "Python Envs",
"icon": "$(play)"
},
{
"command": "python-envs.runAsTask",
"title": "%python-envs.runAsTask.title%",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"python-envs.runInTerminal.title": "Run in Terminal",
"python-envs.createTerminal.title": "Create Python Terminal",
"python-envs.runAsTask.title": "Run as Task",
"python-envs.createNewProjectFromTemplate.title": "Create New Project from Template",
"python-envs.terminal.activate.title": "Activate Environment in Current Terminal",
"python-envs.terminal.deactivate.title": "Deactivate Environment in Current Terminal",
"python-envs.uninstallPackage.title": "Uninstall Package"
Expand Down
2 changes: 2 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ export const KNOWN_FILES = [
];

export const KNOWN_TEMPLATE_ENDINGS = ['.j2', '.jinja2'];

export const NEW_PROJECT_TEMPLATES_FOLDER = path.join(EXTENSION_ROOT_DIR, 'src', 'features', 'creators', 'templates');
69 changes: 50 additions & 19 deletions src/common/pickers/managers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { QuickPickItem, QuickPickItemKind } from 'vscode';
import { commands, QuickInputButtons, QuickPickItem, QuickPickItemKind } from 'vscode';
import { PythonProjectCreator } from '../../api';
import { InternalEnvironmentManager, InternalPackageManager } from '../../internal.api';
import { Common, Pickers } from '../localize';
import { showQuickPick, showQuickPickWithButtons } from '../window.apis';
import { showQuickPickWithButtons } from '../window.apis';

function getDescription(mgr: InternalEnvironmentManager | InternalPackageManager): string | undefined {
if (mgr.description) {
Expand Down Expand Up @@ -137,7 +137,6 @@ export async function pickCreator(creators: PythonProjectCreator[]): Promise<Pyt
// First level menu
const autoFindCreator = creators.find((c) => c.name === 'autoProjects');
const existingProjectsCreator = creators.find((c) => c.name === 'existingProjects');
const otherCreators = creators.filter((c) => c.name !== 'autoProjects' && c.name !== 'existingProjects');

const items: QuickPickItem[] = [
{
Expand All @@ -154,7 +153,7 @@ export async function pickCreator(creators: PythonProjectCreator[]): Promise<Pyt
},
];

const selected = await showQuickPick(items, {
const selected = await showQuickPickWithButtons(items, {
placeHolder: Pickers.Managers.selectProjectCreator,
ignoreFocusOut: true,
});
Expand All @@ -164,27 +163,59 @@ export async function pickCreator(creators: PythonProjectCreator[]): Promise<Pyt
}

// Return appropriate creator based on selection
switch (selected.label) {
// Handle case where selected could be an array (should not happen, but for type safety)
const selectedItem = Array.isArray(selected) ? selected[0] : selected;
if (!selectedItem) {
return undefined;
}
switch (selectedItem.label) {
case 'Auto Find':
return autoFindCreator;
case 'Select Existing':
return existingProjectsCreator;
case 'Create New':
// Show second level menu for other creators
if (otherCreators.length === 0) {
return undefined;
}
const newItems: (QuickPickItem & { c: PythonProjectCreator })[] = otherCreators.map((c) => ({
label: c.displayName ?? c.name,
description: c.description,
c: c,
}));
const newSelected = await showQuickPick(newItems, {
placeHolder: 'Select project type for new project',
ignoreFocusOut: true,
});
return newSelected?.c;
return newProjectSelection(creators);
}

return undefined;
}

export async function newProjectSelection(creators: PythonProjectCreator[]): Promise<PythonProjectCreator | undefined> {
const otherCreators = creators.filter((c) => c.name !== 'autoProjects' && c.name !== 'existingProjects');

// Show second level menu for other creators
if (otherCreators.length === 0) {
return undefined;
}
const newItems: (QuickPickItem & { c: PythonProjectCreator })[] = otherCreators.map((c) => ({
label: c.displayName ?? c.name,
description: c.description,
c: c,
}));
try {
const newSelected = await showQuickPickWithButtons(newItems, {
placeHolder: 'Select project type for new project',
ignoreFocusOut: true,
showBackButton: true,
});

if (!newSelected) {
// User cancelled the picker
return undefined;
}
// Handle back button
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((newSelected as any)?.kind === -1 || (newSelected as any)?.back === true) {
// User pressed the back button, re-show the first menu
return pickCreator(creators);
}

// Handle case where newSelected could be an array (should not happen, but for type safety)
const selectedCreator = Array.isArray(newSelected) ? newSelected[0] : newSelected;
return selectedCreator?.c;
} catch (ex) {
if (ex === QuickInputButtons.Back) {
await commands.executeCommand('python-envs.addPythonProject');
}
}
}
9 changes: 8 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ensureCorrectVersion } from './common/extVersion';
import { registerTools } from './common/lm.apis';
import { registerLogger, traceError, traceInfo } from './common/logging';
import { setPersistentState } from './common/persistentState';
import { newProjectSelection } from './common/pickers/managers';
import { StopWatch } from './common/stopWatch';
import { EventNames } from './common/telemetry/constants';
import { sendManagerSelectionTelemetry } from './common/telemetry/helpers';
Expand Down Expand Up @@ -109,7 +110,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
projectCreators,
projectCreators.registerPythonProjectCreator(new ExistingProjects(projectManager)),
projectCreators.registerPythonProjectCreator(new AutoFindProjects(projectManager)),
projectCreators.registerPythonProjectCreator(new NewPackageProject()),
projectCreators.registerPythonProjectCreator(new NewPackageProject(envManagers, projectManager)),
projectCreators.registerPythonProjectCreator(new NewScriptProject()),
);

Expand Down Expand Up @@ -235,6 +236,12 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
await terminalManager.deactivate(terminal);
}
}),
commands.registerCommand('python-envs.createNewProjectFromTemplate', async () => {
const selected = await newProjectSelection(projectCreators.getProjectCreators());
if (selected) {
await selected.create();
}
}),
terminalActivation.onDidChangeTerminalActivationState(async (e) => {
await setActivateMenuButtonContext(e.terminal, e.environment, e.activated);
}),
Expand Down
199 changes: 199 additions & 0 deletions src/features/creators/creationHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { extensions, l10n, QuickInputButtons, Uri, window } from 'vscode';
import { CreateEnvironmentOptions } from '../../api';
import { traceError, traceVerbose } from '../../common/logging';
import { showQuickPickWithButtons } from '../../common/window.apis';
import { EnvironmentManagers, InternalEnvironmentManager } from '../../internal.api';

/**
* Prompts the user to choose whether to create a new virtual environment (venv) for a project, with a clearer return and early exit.
* @returns {Promise<boolean | undefined>} Resolves to true if 'Yes' is selected, false if 'No', or undefined if cancelled.
*/
export async function promptForVenv(callback: () => void): Promise<boolean | undefined> {
try {
const venvChoice = await showQuickPickWithButtons([{ label: l10n.t('Yes') }, { label: l10n.t('No') }], {
Copy link
Member

Choose a reason for hiding this comment

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

We have localized versions of this in localize.ts. You can address this later.

placeHolder: l10n.t('Would you like to create a new virtual environment for this project?'),
ignoreFocusOut: true,
showBackButton: true,
});
if (!venvChoice) {
return undefined;
}
if (Array.isArray(venvChoice)) {
// Should not happen for single selection, but handle just in case
return venvChoice.some((item) => item.label === 'Yes');
}
return venvChoice.label === 'Yes';
} catch (ex) {
if (ex === QuickInputButtons.Back) {
callback();
}
}
}

/**
* Checks if the GitHub Copilot extension is installed in the current VS Code environment.
* @returns {boolean} True if Copilot is installed, false otherwise.
*/
export function isCopilotInstalled(): boolean {
return !!extensions.getExtension('GitHub.copilot');
}

/**
* Prompts the user to choose whether to create a Copilot instructions file, only if Copilot is installed.
* @returns {Promise<boolean | undefined>} Resolves to true if 'Yes' is selected, false if 'No', or undefined if cancelled or Copilot is not installed.
*/
export async function promptForCopilotInstructions(): Promise<boolean | undefined> {
if (!isCopilotInstalled()) {
return undefined;
}
const copilotChoice = await showQuickPickWithButtons([{ label: 'Yes' }, { label: 'No' }], {
placeHolder: 'Would you like to create a Copilot instructions file?',
ignoreFocusOut: true,
showBackButton: true,
});
if (!copilotChoice) {
return undefined;
}
if (Array.isArray(copilotChoice)) {
// Should not happen for single selection, but handle just in case
return copilotChoice.some((item) => item.label === 'Yes');
}
return copilotChoice.label === 'Yes';
}

/**
* Quickly creates a new Python virtual environment (venv) in the specified destination folder using the available environment managers.
* Attempts to use the venv manager if available, otherwise falls back to any manager that supports environment creation.
* @param envManagers - The collection of available environment managers.
* @param destFolder - The absolute path to the destination folder where the environment should be created.
* @returns {Promise<void>} Resolves when the environment is created or an error is shown.
*/
export async function quickCreateNewVenv(envManagers: EnvironmentManagers, destFolder: string) {
// get the environment manager for venv, should always exist
const envManager: InternalEnvironmentManager | undefined = envManagers.managers.find(
(m) => m.id === 'ms-python.python:venv',
);
const destinationUri = Uri.parse(destFolder);
if (envManager?.supportsQuickCreate) {
// with quickCreate enabled, user will not be prompted when creating the environment
const options: CreateEnvironmentOptions = { quickCreate: false };
if (envManager.supportsQuickCreate) {
options.quickCreate = true;
}
const pyEnv = await envManager.create(destinationUri, options);
// TODO: do I need to update to say this is the env for the file? Like set it?
if (!pyEnv) {
// comes back as undefined if this doesn't work
window.showErrorMessage(`Failed to create virtual environment, please create it manually.`);
} else {
traceVerbose(`Created venv at: ${pyEnv?.environmentPath}`);
}
} else {
window.showErrorMessage(`Failed to quick create virtual environment, please create it manually.`);
}
}

/**
* Recursively replaces all occurrences of a string in file and folder names, as well as file contents, within a directory tree.
* @param dir - The root directory to start the replacement from.
* @param searchValue - The string to search for in names and contents.
* @param replaceValue - The string to replace with.
* @returns {Promise<void>} Resolves when all replacements are complete.
*/
export async function replaceInFilesAndNames(dir: string, searchValue: string, replaceValue: string) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
let entryName = entry.name;
let fullPath = path.join(dir, entryName);
let newFullPath = fullPath;
// If the file or folder name contains searchValue, rename it
if (entryName.includes(searchValue)) {
const newName = entryName.replace(new RegExp(searchValue, 'g'), replaceValue);
newFullPath = path.join(dir, newName);
await fs.rename(fullPath, newFullPath);
entryName = newName;
}
if (entry.isDirectory()) {
await replaceInFilesAndNames(newFullPath, searchValue, replaceValue);
} else {
let content = await fs.readFile(newFullPath, 'utf8');
if (content.includes(searchValue)) {
content = content.replace(new RegExp(searchValue, 'g'), replaceValue);
await fs.writeFile(newFullPath, content, 'utf8');
}
}
}
}

/**
* Ensures the .github/copilot-instructions.md file exists at the given root, creating or appending as needed.
* @param destinationRootPath - The root directory where the .github folder should exist.
* @param instructionsText - The text to write or append to the copilot-instructions.md file.
*/
export async function manageCopilotInstructionsFile(
destinationRootPath: string,
packageName: string,
instructionsFilePath: string,
) {
const instructionsText = `\n \n` + (await fs.readFile(instructionsFilePath, 'utf-8'));
const githubFolderPath = path.join(destinationRootPath, '.github');
const customInstructionsPath = path.join(githubFolderPath, 'copilot-instructions.md');
if (!(await fs.pathExists(githubFolderPath))) {
// make the .github folder if it doesn't exist
await fs.mkdir(githubFolderPath);
}
const customInstructions = await fs.pathExists(customInstructionsPath);
if (customInstructions) {
// Append to the existing file
await fs.appendFile(customInstructionsPath, instructionsText.replace(/<package_name>/g, packageName));
} else {
// Create the file if it doesn't exist
await fs.writeFile(customInstructionsPath, instructionsText.replace(/<package_name>/g, packageName));
}
}

/**
* Appends a configuration object to the configurations array in a launch.json file.
* @param launchJsonPath - The absolute path to the launch.json file.
* @param projectLaunchConfig - The stringified JSON config to append.
*/
async function appendToJsonConfigs(launchJsonPath: string, projectLaunchConfig: string) {
let content = await fs.readFile(launchJsonPath, 'utf8');
const json = JSON.parse(content);
// If it's a VS Code launch config, append to configurations array
if (json && Array.isArray(json.configurations)) {
const configObj = JSON.parse(projectLaunchConfig);
json.configurations.push(configObj);
await fs.writeFile(launchJsonPath, JSON.stringify(json, null, 4), 'utf8');
} else {
traceError('Failed to add Project Launch Config to launch.json.');
return;
}
}

/**
* Updates the launch.json file in the .vscode folder to include the provided project launch configuration.
* @param destinationRootPath - The root directory where the .vscode folder should exist.
* @param projectLaunchConfig - The stringified JSON config to append.
*/
export async function manageLaunchJsonFile(destinationRootPath: string, projectLaunchConfig: string) {
const vscodeFolderPath = path.join(destinationRootPath, '.vscode');
const launchJsonPath = path.join(vscodeFolderPath, 'launch.json');
if (!(await fs.pathExists(vscodeFolderPath))) {
await fs.mkdir(vscodeFolderPath);
}
const launchJsonExists = await fs.pathExists(launchJsonPath);
if (launchJsonExists) {
// Try to parse and append to existing launch.json
await appendToJsonConfigs(launchJsonPath, projectLaunchConfig);
} else {
// Create a new launch.json with the provided config
const launchJson = {
version: '0.2.0',
configurations: [JSON.parse(projectLaunchConfig)],
};
await fs.writeFile(launchJsonPath, JSON.stringify(launchJson, null, 4), 'utf8');
}
}
Loading