Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ There are a few ways to add a Python Project from the Python Environments panel:
| Name | Description |
| ------------ | ---------------------------------------------------------------------- |
| Add Existing | Allows you to add an existing folder from the file explorer. |
| Auto find | Searches for folders that contain `pyproject.toml` or `setup.py` files |
| Discovered projects | Folders with `pyproject.toml` or `setup.py` are listed in the add-project picker when any are found. |
| Create New | Creates a new project from a template. |

#### Create New Project from Template
Expand Down
17 changes: 9 additions & 8 deletions docs/managing-python-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,18 @@ Alternatively, right-click any folder or Python file in the Explorer and select
Alt text: VS Code Explorer context menu with "Add as Python Project" highlighted
Caption: Right-click any folder or file to add it as a Python project -->

### Method 2: Auto-find projects
### Method 2: Add discovered projects

Use this to quickly discover all Python projects in your workspace based on common project markers.
Use this to add Python projects the extension finds from common project markers.

1. Open the Python Environments panel.
2. Click the **+** button in the Python Projects section.
3. Select **Auto Find**.
4. The extension searches for folders containing `pyproject.toml` or `setup.py` files.
5. Select which discovered projects to add from the list.
3. The extension searches for folders containing `pyproject.toml` or `setup.py` that are not already projects.
4. Those folders are listed in the same quick pick as **Select Existing** and **Create New**. Select one to add it. When more than one is found, **Add all discovered projects** adds every listed folder.

> **Tip**: Auto-find is especially useful when you clone a mono-repo and want to quickly identify all its Python projects.
If that search finds nothing new, the quick pick does not include Auto Find.

> **Tip**: This is especially useful when you clone a mono-repo and want to identify its Python projects without a separate search step.

### Method 3: Create a new project from a template

Expand Down Expand Up @@ -298,8 +299,8 @@ Access these via the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`):
You have a repository with multiple Python services, each needing isolated dependencies.

1. Open the root folder in VS Code.
2. Click **+** in Python Projects and select **Auto Find** to discover all services with `pyproject.toml`.
3. Select the services you want to track.
2. Click **+** in Python Projects. Services with `pyproject.toml` or `setup.py` that are not already projects appear in that list.
3. Select the services you want to track, or choose **Add all discovered projects**.
4. For each service, create a virtual environment by right-clicking and selecting **Create Environment**.
5. Each service now runs with its own isolated environment.

Expand Down
5 changes: 5 additions & 0 deletions src/common/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ export namespace ProjectCreatorString {
export const autoFindProjectsDescription = l10n.t(
'Automatically find folders with `pyproject.toml` or `setup.py` files.',
);
export const selectProject = l10n.t('Select a project');
export const discoveredProjects = l10n.t('Discovered projects');
export const addAllDiscoveredProjects = l10n.t('Add all discovered projects');
export const addAllDiscoveredProjectsDescription = l10n.t('Add every project found in this workspace');
export const findingProjects = l10n.t('Finding Python projects');

export const noProjectsFound = l10n.t('No projects found');
}
Expand Down
132 changes: 99 additions & 33 deletions src/common/pickers/managers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
QuickPickItem,
QuickPickItemKind,
ThemeIcon,
Uri,
workspace,
WorkspaceFolder,
} from 'vscode';
Expand All @@ -12,7 +13,7 @@ import type {
InternalPackageManager,
} from '../../managers/common/registeredManagers';
import { executeCommand } from '../command.api';
import { Common, Interpreter, Pickers } from '../localize';
import { Common, Interpreter, Pickers, ProjectCreatorString } from '../localize';
import { showQuickPickWithButtons } from '../window.apis';

export const ENTER_INTERPRETER_PATH_ID = 'EnterInterpreterPath';
Expand Down Expand Up @@ -182,63 +183,127 @@ export async function pickWorkspaceFolder(showBackButton = true): Promise<Worksp
const selectedItem = Array.isArray(selected) ? selected[0] : selected;
return selectedItem?.folder;
}
export async function pickCreator(creators: PythonProjectCreator[]): Promise<PythonProjectCreator | undefined> {
if (creators.length === 0) {
/** A project directory found from pyproject.toml or setup.py before the quick pick opens. */
export interface DiscoveredProjectOption {
readonly label: string;
readonly description?: string;
readonly uri: Uri;
}

export type ProjectCreatorPick =
| { readonly kind: 'creator'; readonly creator: PythonProjectCreator }
| { readonly kind: 'discovered'; readonly uris: readonly Uri[] };

interface ProjectAdditionQuickPickItem extends QuickPickItem {
pickId?: 'discovered' | 'discovered-all' | 'existing' | 'new';
uri?: Uri;
}

export async function pickCreator(
creators: PythonProjectCreator[],
discovered: readonly DiscoveredProjectOption[] = [],
): Promise<ProjectCreatorPick | undefined> {
if (creators.length === 0 && discovered.length === 0) {
return;
}

if (creators.length === 1) {
return creators[0];
// A lone non-discovery creator still skips the menu. Auto Find is not that creator:
// with nothing to add it would only report that no projects were found.
if (discovered.length === 0 && creators.length === 1) {
if (creators[0].name === 'autoProjects') {
return undefined;
}
return { kind: 'creator', creator: creators[0] };
}

// 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[] = [
{
label: 'Auto Find',
description: autoFindCreator?.description ?? 'Automatically find Python projects',
},
{
// List discoveries in this quick pick. Omit Auto Find entirely: when the search
// finds nothing, that action only produced a "No projects found" error.
const items: ProjectAdditionQuickPickItem[] = [];
if (discovered.length > 0) {
items.push({
label: ProjectCreatorString.discoveredProjects,
kind: QuickPickItemKind.Separator,
});
for (const project of discovered) {
items.push({
label: project.label,
description: project.description,
pickId: 'discovered',
uri: project.uri,
});
}
if (discovered.length > 1) {
items.push({
label: ProjectCreatorString.addAllDiscoveredProjects,
description: ProjectCreatorString.addAllDiscoveredProjectsDescription,
pickId: 'discovered-all',
});
}
}

const actions: ProjectAdditionQuickPickItem[] = [];
if (existingProjectsCreator) {
actions.push({
label: 'Select Existing',
description: existingProjectsCreator?.description ?? 'Select existing Python projects',
},
{
description: existingProjectsCreator.description ?? 'Select existing Python projects',
pickId: 'existing',
});
}
if (otherCreators.length > 0) {
actions.push({
label: 'Create New',
description: 'Create a Python project from a template',
},
];
pickId: 'new',
});
}
if (discovered.length > 0 && actions.length > 0) {
items.push({ label: '', kind: QuickPickItemKind.Separator });
}
items.push(...actions);

if (!items.some((item) => item.kind !== QuickPickItemKind.Separator)) {
return undefined;
}

const selected = await showQuickPickWithButtons(items, {
placeHolder: Pickers.Managers.selectProjectCreator,
placeHolder:
discovered.length > 0 ? ProjectCreatorString.selectProject : Pickers.Managers.selectProjectCreator,
ignoreFocusOut: true,
showBackButton: true,
matchOnDescription: discovered.length > 0,
});

if (!selected) {
return undefined;
}

// Return appropriate creator based on selection
// 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) {
if (!selectedItem || selectedItem.kind === QuickPickItemKind.Separator) {
return undefined;
}
switch (selectedItem.label) {
case 'Auto Find':
return autoFindCreator;
case 'Select Existing':
return existingProjectsCreator;
case 'Create New':
return newProjectSelection(creators);
switch (selectedItem.pickId) {
case 'discovered':
return selectedItem.uri ? { kind: 'discovered', uris: [selectedItem.uri] } : undefined;
case 'discovered-all':
return { kind: 'discovered', uris: discovered.map((project) => project.uri) };
case 'existing':
return existingProjectsCreator ? { kind: 'creator', creator: existingProjectsCreator } : undefined;
case 'new': {
const creator = await newProjectSelection(creators, discovered);
return creator ? { kind: 'creator', creator } : undefined;
}
default:
return undefined;
}

return undefined;
}

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

// Show second level menu for other creators
Expand Down Expand Up @@ -267,7 +332,8 @@ export async function newProjectSelection(creators: PythonProjectCreator[]): Pro
(newSelected.kind === QuickPickItemKind.Separator || ('back' in newSelected && newSelected.back === true))
) {
// User pressed the back button, re-show the first menu
return pickCreator(creators);
const next = await pickCreator(creators, discovered);
return next?.kind === 'creator' ? next.creator : undefined;
}

// Handle case where newSelected could be an array (should not happen, but for type safety)
Expand Down
104 changes: 66 additions & 38 deletions src/features/creators/autoFindProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import {
} from '../projectManager';
import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils';

function getUniqueUri(uris: Uri[]): {
label: string;
description: string;
uri: Uri;
}[] {
export interface DiscoveredProject {
readonly label: string;
readonly description: string;
readonly uri: Uri;
}

function getUniqueUri(uris: Uri[]): DiscoveredProject[] {
const files = uris.map((uri) => uri.fsPath).sort();
const dirs: Map<string, string> = new Map();
files.forEach((file) => {
Expand All @@ -35,6 +37,21 @@ function getUniqueUri(uris: Uri[]): {
.sort((a, b) => a.label.localeCompare(b.label));
}

/**
* A marker is already registered when its path, or the directory that contains it,
* is the project the manager resolves for that file.
*/
function isAlreadyRegistered(pm: PythonProjectManager, uri: Uri): boolean {
const project = pm.get(uri);
if (!project) {
return false;
}
const registeredPath = normalizePath(project.uri.fsPath);
const markerPath = normalizePath(uri.fsPath);
const markerDirectory = path.dirname(markerPath);
return registeredPath === markerPath || registeredPath === markerDirectory;
}

async function pickProjects(uris: Uri[]): Promise<Uri[] | undefined> {
const items = getUniqueUri(uris);

Expand Down Expand Up @@ -90,43 +107,59 @@ export class AutoFindProjects implements PythonProjectCreator {
return prefixes.filter((prefix): prefix is string => !!prefix);
}

async create(_options?: PythonProjectCreatorOptions): Promise<PythonProject | PythonProject[] | undefined> {
/**
* Marker files Auto Find would consider, excluding those inside a selected environment.
* Does not prompt or notify.
*/
private async discoverMarkerFiles(): Promise<Uri[]> {
const found = await findFiles('**/{pyproject.toml,setup.py}', '**/.venv/**');
// Exclude markers inside selected environments (e.g. installed packages in a custom-named venv).
const folders = found && found.length > 0 ? getWorkspaceFolders() ?? [] : [];
const prefixes = await this.getSelectedEnvironmentPrefixes(folders);
const files = found?.filter(
(uri) =>
!prefixes.some(
(prefix) =>
isSameOrParentPath(prefix, uri.fsPath) &&
!folders.some(
(folder) =>
isSameOrParentPath(prefix, folder.uri.fsPath) &&
isSameOrParentPath(folder.uri.fsPath, uri.fsPath),
),
),
return (
found?.filter(
(uri) =>
!prefixes.some(
(prefix) =>
isSameOrParentPath(prefix, uri.fsPath) &&
!folders.some(
(folder) =>
isSameOrParentPath(prefix, folder.uri.fsPath) &&
isSameOrParentPath(folder.uri.fsPath, uri.fsPath),
),
),
) ?? []
);
if (!files || files.length === 0) {
}

/**
* Project directories that are not already registered.
* Does not prompt or notify, including when nothing is found.
*/
async findNewProjects(): Promise<DiscoveredProject[]> {
const files = await this.discoverMarkerFiles();
return getUniqueUri(files.filter((uri) => !isAlreadyRegistered(this.pm, uri)));
}

/**
* Registers project directories previously returned by {@link findNewProjects}.
*/
addDiscoveredProjects(uris: readonly Uri[]): PythonProject[] {
const projects = uris.map((uri) => new PythonProjectsImpl(path.basename(uri.fsPath), uri)) as PythonProject[];
this.pm.add(projects);
return projects;
}

async create(_options?: PythonProjectCreatorOptions): Promise<PythonProject | PythonProject[] | undefined> {
const files = await this.discoverMarkerFiles();
if (files.length === 0) {
setImmediate(() => {
showErrorMessage('No projects found');
showErrorMessage(ProjectCreatorString.noProjectsFound);
});
return;
}

const filtered = files.filter((uri) => {
const p = this.pm.get(uri);
if (p) {
// Skip this project if:
// 1. There's already a project registered with exactly the same path
// 2. There's already a project registered with this project's parent directory path
const np = normalizePath(p.uri.fsPath);
const nf = normalizePath(uri.fsPath);
const nfp = path.dirname(nf);
return np !== nf && np !== nfp;
}
return true;
});
const filtered = files.filter((uri) => !isAlreadyRegistered(this.pm, uri));

if (filtered.length === 0) {
// No new projects found that are not already in the project manager
Expand All @@ -153,11 +186,6 @@ export class AutoFindProjects implements PythonProjectCreator {
traceInfo('User cancelled project selection.');
return;
}
const projects = projectUris.map(
(uri) => new PythonProjectsImpl(path.basename(uri.fsPath), uri),
) as PythonProject[];
// Add the projects to the project manager
this.pm.add(projects);
return projects;
return this.addDiscoveredProjects(projectUris);
}
}
Loading