Skip to content

Commit 7bf0208

Browse files
authored
New package Flow (#324)
Implement the flow to allow for "New Package" support
1 parent a60b2b1 commit 7bf0208

File tree

15 files changed

+468
-160
lines changed

15 files changed

+468
-160
lines changed

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@
209209
"category": "Python Envs",
210210
"icon": "$(terminal)"
211211
},
212+
{
213+
"command": "python-envs.createNewProjectFromTemplate",
214+
"title": "%python-envs.createNewProjectFromTemplate.title%",
215+
"category": "Python Envs",
216+
"icon": "$(play)"
217+
},
212218
{
213219
"command": "python-envs.runAsTask",
214220
"title": "%python-envs.runAsTask.title%",

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"python-envs.runInTerminal.title": "Run in Terminal",
3232
"python-envs.createTerminal.title": "Create Python Terminal",
3333
"python-envs.runAsTask.title": "Run as Task",
34+
"python-envs.createNewProjectFromTemplate.title": "Create New Project from Template",
3435
"python-envs.terminal.activate.title": "Activate Environment in Current Terminal",
3536
"python-envs.terminal.deactivate.title": "Deactivate Environment in Current Terminal",
3637
"python-envs.uninstallPackage.title": "Uninstall Package"

src/common/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ export const KNOWN_FILES = [
2525
];
2626

2727
export const KNOWN_TEMPLATE_ENDINGS = ['.j2', '.jinja2'];
28+
29+
export const NEW_PROJECT_TEMPLATES_FOLDER = path.join(EXTENSION_ROOT_DIR, 'src', 'features', 'creators', 'templates');

src/common/pickers/managers.ts

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { QuickPickItem, QuickPickItemKind } from 'vscode';
1+
import { commands, QuickInputButtons, QuickPickItem, QuickPickItemKind } from 'vscode';
22
import { PythonProjectCreator } from '../../api';
33
import { InternalEnvironmentManager, InternalPackageManager } from '../../internal.api';
44
import { Common, Pickers } from '../localize';
5-
import { showQuickPick, showQuickPickWithButtons } from '../window.apis';
5+
import { showQuickPickWithButtons } from '../window.apis';
66

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

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

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

166165
// Return appropriate creator based on selection
167-
switch (selected.label) {
166+
// Handle case where selected could be an array (should not happen, but for type safety)
167+
const selectedItem = Array.isArray(selected) ? selected[0] : selected;
168+
if (!selectedItem) {
169+
return undefined;
170+
}
171+
switch (selectedItem.label) {
168172
case 'Auto Find':
169173
return autoFindCreator;
170174
case 'Select Existing':
171175
return existingProjectsCreator;
172176
case 'Create New':
173-
// Show second level menu for other creators
174-
if (otherCreators.length === 0) {
175-
return undefined;
176-
}
177-
const newItems: (QuickPickItem & { c: PythonProjectCreator })[] = otherCreators.map((c) => ({
178-
label: c.displayName ?? c.name,
179-
description: c.description,
180-
c: c,
181-
}));
182-
const newSelected = await showQuickPick(newItems, {
183-
placeHolder: 'Select project type for new project',
184-
ignoreFocusOut: true,
185-
});
186-
return newSelected?.c;
177+
return newProjectSelection(creators);
187178
}
188179

189180
return undefined;
190181
}
182+
183+
export async function newProjectSelection(creators: PythonProjectCreator[]): Promise<PythonProjectCreator | undefined> {
184+
const otherCreators = creators.filter((c) => c.name !== 'autoProjects' && c.name !== 'existingProjects');
185+
186+
// Show second level menu for other creators
187+
if (otherCreators.length === 0) {
188+
return undefined;
189+
}
190+
const newItems: (QuickPickItem & { c: PythonProjectCreator })[] = otherCreators.map((c) => ({
191+
label: c.displayName ?? c.name,
192+
description: c.description,
193+
c: c,
194+
}));
195+
try {
196+
const newSelected = await showQuickPickWithButtons(newItems, {
197+
placeHolder: 'Select project type for new project',
198+
ignoreFocusOut: true,
199+
showBackButton: true,
200+
});
201+
202+
if (!newSelected) {
203+
// User cancelled the picker
204+
return undefined;
205+
}
206+
// Handle back button
207+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
208+
if ((newSelected as any)?.kind === -1 || (newSelected as any)?.back === true) {
209+
// User pressed the back button, re-show the first menu
210+
return pickCreator(creators);
211+
}
212+
213+
// Handle case where newSelected could be an array (should not happen, but for type safety)
214+
const selectedCreator = Array.isArray(newSelected) ? newSelected[0] : newSelected;
215+
return selectedCreator?.c;
216+
} catch (ex) {
217+
if (ex === QuickInputButtons.Back) {
218+
await commands.executeCommand('python-envs.addPythonProject');
219+
}
220+
}
221+
}

src/extension.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ensureCorrectVersion } from './common/extVersion';
44
import { registerTools } from './common/lm.apis';
55
import { registerLogger, traceError, traceInfo } from './common/logging';
66
import { setPersistentState } from './common/persistentState';
7+
import { newProjectSelection } from './common/pickers/managers';
78
import { StopWatch } from './common/stopWatch';
89
import { EventNames } from './common/telemetry/constants';
910
import { sendManagerSelectionTelemetry } from './common/telemetry/helpers';
@@ -109,7 +110,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
109110
projectCreators,
110111
projectCreators.registerPythonProjectCreator(new ExistingProjects(projectManager)),
111112
projectCreators.registerPythonProjectCreator(new AutoFindProjects(projectManager)),
112-
projectCreators.registerPythonProjectCreator(new NewPackageProject()),
113+
projectCreators.registerPythonProjectCreator(new NewPackageProject(envManagers, projectManager)),
113114
projectCreators.registerPythonProjectCreator(new NewScriptProject()),
114115
);
115116

@@ -235,6 +236,12 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
235236
await terminalManager.deactivate(terminal);
236237
}
237238
}),
239+
commands.registerCommand('python-envs.createNewProjectFromTemplate', async () => {
240+
const selected = await newProjectSelection(projectCreators.getProjectCreators());
241+
if (selected) {
242+
await selected.create();
243+
}
244+
}),
238245
terminalActivation.onDidChangeTerminalActivationState(async (e) => {
239246
await setActivateMenuButtonContext(e.terminal, e.environment, e.activated);
240247
}),
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import * as fs from 'fs-extra';
2+
import * as path from 'path';
3+
import { extensions, l10n, QuickInputButtons, Uri, window } from 'vscode';
4+
import { CreateEnvironmentOptions } from '../../api';
5+
import { traceError, traceVerbose } from '../../common/logging';
6+
import { showQuickPickWithButtons } from '../../common/window.apis';
7+
import { EnvironmentManagers, InternalEnvironmentManager } from '../../internal.api';
8+
9+
/**
10+
* Prompts the user to choose whether to create a new virtual environment (venv) for a project, with a clearer return and early exit.
11+
* @returns {Promise<boolean | undefined>} Resolves to true if 'Yes' is selected, false if 'No', or undefined if cancelled.
12+
*/
13+
export async function promptForVenv(callback: () => void): Promise<boolean | undefined> {
14+
try {
15+
const venvChoice = await showQuickPickWithButtons([{ label: l10n.t('Yes') }, { label: l10n.t('No') }], {
16+
placeHolder: l10n.t('Would you like to create a new virtual environment for this project?'),
17+
ignoreFocusOut: true,
18+
showBackButton: true,
19+
});
20+
if (!venvChoice) {
21+
return undefined;
22+
}
23+
if (Array.isArray(venvChoice)) {
24+
// Should not happen for single selection, but handle just in case
25+
return venvChoice.some((item) => item.label === 'Yes');
26+
}
27+
return venvChoice.label === 'Yes';
28+
} catch (ex) {
29+
if (ex === QuickInputButtons.Back) {
30+
callback();
31+
}
32+
}
33+
}
34+
35+
/**
36+
* Checks if the GitHub Copilot extension is installed in the current VS Code environment.
37+
* @returns {boolean} True if Copilot is installed, false otherwise.
38+
*/
39+
export function isCopilotInstalled(): boolean {
40+
return !!extensions.getExtension('GitHub.copilot');
41+
}
42+
43+
/**
44+
* Prompts the user to choose whether to create a Copilot instructions file, only if Copilot is installed.
45+
* @returns {Promise<boolean | undefined>} Resolves to true if 'Yes' is selected, false if 'No', or undefined if cancelled or Copilot is not installed.
46+
*/
47+
export async function promptForCopilotInstructions(): Promise<boolean | undefined> {
48+
if (!isCopilotInstalled()) {
49+
return undefined;
50+
}
51+
const copilotChoice = await showQuickPickWithButtons([{ label: 'Yes' }, { label: 'No' }], {
52+
placeHolder: 'Would you like to create a Copilot instructions file?',
53+
ignoreFocusOut: true,
54+
showBackButton: true,
55+
});
56+
if (!copilotChoice) {
57+
return undefined;
58+
}
59+
if (Array.isArray(copilotChoice)) {
60+
// Should not happen for single selection, but handle just in case
61+
return copilotChoice.some((item) => item.label === 'Yes');
62+
}
63+
return copilotChoice.label === 'Yes';
64+
}
65+
66+
/**
67+
* Quickly creates a new Python virtual environment (venv) in the specified destination folder using the available environment managers.
68+
* Attempts to use the venv manager if available, otherwise falls back to any manager that supports environment creation.
69+
* @param envManagers - The collection of available environment managers.
70+
* @param destFolder - The absolute path to the destination folder where the environment should be created.
71+
* @returns {Promise<void>} Resolves when the environment is created or an error is shown.
72+
*/
73+
export async function quickCreateNewVenv(envManagers: EnvironmentManagers, destFolder: string) {
74+
// get the environment manager for venv, should always exist
75+
const envManager: InternalEnvironmentManager | undefined = envManagers.managers.find(
76+
(m) => m.id === 'ms-python.python:venv',
77+
);
78+
const destinationUri = Uri.parse(destFolder);
79+
if (envManager?.supportsQuickCreate) {
80+
// with quickCreate enabled, user will not be prompted when creating the environment
81+
const options: CreateEnvironmentOptions = { quickCreate: false };
82+
if (envManager.supportsQuickCreate) {
83+
options.quickCreate = true;
84+
}
85+
const pyEnv = await envManager.create(destinationUri, options);
86+
// TODO: do I need to update to say this is the env for the file? Like set it?
87+
if (!pyEnv) {
88+
// comes back as undefined if this doesn't work
89+
window.showErrorMessage(`Failed to create virtual environment, please create it manually.`);
90+
} else {
91+
traceVerbose(`Created venv at: ${pyEnv?.environmentPath}`);
92+
}
93+
} else {
94+
window.showErrorMessage(`Failed to quick create virtual environment, please create it manually.`);
95+
}
96+
}
97+
98+
/**
99+
* Recursively replaces all occurrences of a string in file and folder names, as well as file contents, within a directory tree.
100+
* @param dir - The root directory to start the replacement from.
101+
* @param searchValue - The string to search for in names and contents.
102+
* @param replaceValue - The string to replace with.
103+
* @returns {Promise<void>} Resolves when all replacements are complete.
104+
*/
105+
export async function replaceInFilesAndNames(dir: string, searchValue: string, replaceValue: string) {
106+
const entries = await fs.readdir(dir, { withFileTypes: true });
107+
for (const entry of entries) {
108+
let entryName = entry.name;
109+
let fullPath = path.join(dir, entryName);
110+
let newFullPath = fullPath;
111+
// If the file or folder name contains searchValue, rename it
112+
if (entryName.includes(searchValue)) {
113+
const newName = entryName.replace(new RegExp(searchValue, 'g'), replaceValue);
114+
newFullPath = path.join(dir, newName);
115+
await fs.rename(fullPath, newFullPath);
116+
entryName = newName;
117+
}
118+
if (entry.isDirectory()) {
119+
await replaceInFilesAndNames(newFullPath, searchValue, replaceValue);
120+
} else {
121+
let content = await fs.readFile(newFullPath, 'utf8');
122+
if (content.includes(searchValue)) {
123+
content = content.replace(new RegExp(searchValue, 'g'), replaceValue);
124+
await fs.writeFile(newFullPath, content, 'utf8');
125+
}
126+
}
127+
}
128+
}
129+
130+
/**
131+
* Ensures the .github/copilot-instructions.md file exists at the given root, creating or appending as needed.
132+
* @param destinationRootPath - The root directory where the .github folder should exist.
133+
* @param instructionsText - The text to write or append to the copilot-instructions.md file.
134+
*/
135+
export async function manageCopilotInstructionsFile(
136+
destinationRootPath: string,
137+
packageName: string,
138+
instructionsFilePath: string,
139+
) {
140+
const instructionsText = `\n \n` + (await fs.readFile(instructionsFilePath, 'utf-8'));
141+
const githubFolderPath = path.join(destinationRootPath, '.github');
142+
const customInstructionsPath = path.join(githubFolderPath, 'copilot-instructions.md');
143+
if (!(await fs.pathExists(githubFolderPath))) {
144+
// make the .github folder if it doesn't exist
145+
await fs.mkdir(githubFolderPath);
146+
}
147+
const customInstructions = await fs.pathExists(customInstructionsPath);
148+
if (customInstructions) {
149+
// Append to the existing file
150+
await fs.appendFile(customInstructionsPath, instructionsText.replace(/<package_name>/g, packageName));
151+
} else {
152+
// Create the file if it doesn't exist
153+
await fs.writeFile(customInstructionsPath, instructionsText.replace(/<package_name>/g, packageName));
154+
}
155+
}
156+
157+
/**
158+
* Appends a configuration object to the configurations array in a launch.json file.
159+
* @param launchJsonPath - The absolute path to the launch.json file.
160+
* @param projectLaunchConfig - The stringified JSON config to append.
161+
*/
162+
async function appendToJsonConfigs(launchJsonPath: string, projectLaunchConfig: string) {
163+
let content = await fs.readFile(launchJsonPath, 'utf8');
164+
const json = JSON.parse(content);
165+
// If it's a VS Code launch config, append to configurations array
166+
if (json && Array.isArray(json.configurations)) {
167+
const configObj = JSON.parse(projectLaunchConfig);
168+
json.configurations.push(configObj);
169+
await fs.writeFile(launchJsonPath, JSON.stringify(json, null, 4), 'utf8');
170+
} else {
171+
traceError('Failed to add Project Launch Config to launch.json.');
172+
return;
173+
}
174+
}
175+
176+
/**
177+
* Updates the launch.json file in the .vscode folder to include the provided project launch configuration.
178+
* @param destinationRootPath - The root directory where the .vscode folder should exist.
179+
* @param projectLaunchConfig - The stringified JSON config to append.
180+
*/
181+
export async function manageLaunchJsonFile(destinationRootPath: string, projectLaunchConfig: string) {
182+
const vscodeFolderPath = path.join(destinationRootPath, '.vscode');
183+
const launchJsonPath = path.join(vscodeFolderPath, 'launch.json');
184+
if (!(await fs.pathExists(vscodeFolderPath))) {
185+
await fs.mkdir(vscodeFolderPath);
186+
}
187+
const launchJsonExists = await fs.pathExists(launchJsonPath);
188+
if (launchJsonExists) {
189+
// Try to parse and append to existing launch.json
190+
await appendToJsonConfigs(launchJsonPath, projectLaunchConfig);
191+
} else {
192+
// Create a new launch.json with the provided config
193+
const launchJson = {
194+
version: '0.2.0',
195+
configurations: [JSON.parse(projectLaunchConfig)],
196+
};
197+
await fs.writeFile(launchJsonPath, JSON.stringify(launchJson, null, 4), 'utf8');
198+
}
199+
}

0 commit comments

Comments
 (0)