From d5d5c26d48fbff3c29ebb5acaff4a3b1704494ce Mon Sep 17 00:00:00 2001 From: Mohammad Malik <80257039+mohammad-malik@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:01:38 +0000 Subject: [PATCH] Show discovered projects in the add-project quick pick Search for pyproject.toml and setup.py before the picker opens, and list the projects that are not already registered. Add all of them from that same list when more than one is found. Leave Auto Find out when the search would find nothing, so it cannot end in a "No projects found" error. Co-authored-by: Mohammad Malik --- README.md | 2 +- docs/managing-python-projects.md | 17 +-- src/common/localize.ts | 5 + src/common/pickers/managers.ts | 132 +++++++++++++----- src/features/creators/autoFindProjects.ts | 104 +++++++++----- src/features/envCommands.ts | 23 ++- .../common/projectCreatorPicker.unit.test.ts | 117 +++++++++++++++- .../addPythonProject.quickpick.unit.test.ts | 109 +++++++++++++++ .../creators/autoFindProjects.unit.test.ts | 75 ++++++++++ 9 files changed, 498 insertions(+), 86 deletions(-) create mode 100644 src/test/features/addPythonProject.quickpick.unit.test.ts diff --git a/README.md b/README.md index 841fe718a..1136b7011 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/managing-python-projects.md b/docs/managing-python-projects.md index feaa3a81f..0f934f94d 100644 --- a/docs/managing-python-projects.md +++ b/docs/managing-python-projects.md @@ -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 @@ -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. diff --git a/src/common/localize.ts b/src/common/localize.ts index 19a3bcd84..61e4abc25 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -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'); } diff --git a/src/common/pickers/managers.ts b/src/common/pickers/managers.ts index ab2a3ba23..401fe2d67 100644 --- a/src/common/pickers/managers.ts +++ b/src/common/pickers/managers.ts @@ -3,6 +3,7 @@ import { QuickPickItem, QuickPickItemKind, ThemeIcon, + Uri, workspace, WorkspaceFolder, } from 'vscode'; @@ -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'; @@ -182,63 +183,127 @@ export async function pickWorkspaceFolder(showBackButton = true): Promise { - 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 { + 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 { +export async function newProjectSelection( + creators: PythonProjectCreator[], + discovered: readonly DiscoveredProjectOption[] = [], +): Promise { const otherCreators = creators.filter((c) => c.name !== 'autoProjects' && c.name !== 'existingProjects'); // Show second level menu for other creators @@ -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) diff --git a/src/features/creators/autoFindProjects.ts b/src/features/creators/autoFindProjects.ts index 94c732a03..8cc776282 100644 --- a/src/features/creators/autoFindProjects.ts +++ b/src/features/creators/autoFindProjects.ts @@ -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 = new Map(); files.forEach((file) => { @@ -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 { const items = getUniqueUri(uris); @@ -90,43 +107,59 @@ export class AutoFindProjects implements PythonProjectCreator { return prefixes.filter((prefix): prefix is string => !!prefix); } - async create(_options?: PythonProjectCreatorOptions): Promise { + /** + * Marker files Auto Find would consider, excluding those inside a selected environment. + * Does not prompt or notify. + */ + private async discoverMarkerFiles(): Promise { 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 { + 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 { + 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 @@ -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); } } diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 0117a2ff2..994cbb8b8 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -29,6 +29,7 @@ import { traceError, traceInfo, traceVerbose } from '../common/logging'; import * as persistentState from '../common/persistentState'; import type { InternalEnvironmentManager, InternalPackageManager } from '../managers/common/registeredManagers'; import { normalizePackageName } from '../managers/common/packageUtils'; +import { AutoFindProjects } from './creators/autoFindProjects'; import type { ProjectCreators } from './creators/projectCreators'; import type { EnvironmentManagers } from './envManagers'; import { selectPackageManagementEnvironment } from './nonGlobalPackageInstallationEnvironment'; @@ -39,7 +40,7 @@ import { valid as pep440Valid } from '@renovatebot/pep440'; import { executeCommand } from '../common/command.api'; import { INLINE_SCRIPT_ENVS_KEY, INLINE_SCRIPT_MANAGER_ID } from '../common/constants'; import { clipboardWriteText } from '../common/env.apis'; -import { Pickers } from '../common/localize'; +import { Pickers, ProjectCreatorString } from '../common/localize'; import { pickEnvironment } from '../common/pickers/environments'; import { ENTER_INTERPRETER_PATH_ID, @@ -681,11 +682,25 @@ export async function addPythonProjectCommand( } } - // If not a URI or existingProjectsCreator not found, fall back to picker - const creator: PythonProjectCreator | undefined = await pickCreator(pc.getProjectCreators()); - if (!creator) { + // Discover before the quick pick so found projects are items in that list. + // Auto Find is not offered when that search has nothing to add. + const creators = pc.getProjectCreators(); + const autoFind = creators.find((creator): creator is AutoFindProjects => creator instanceof AutoFindProjects); + const discovered = autoFind + ? await withProgress({ location: ProgressLocation.Window, title: ProjectCreatorString.findingProjects }, () => + autoFind.findNewProjects(), + ) + : []; + const selection = await pickCreator(creators, discovered); + if (!selection) { + return; + } + if (selection.kind === 'discovered') { + autoFind?.addDiscoveredProjects(selection.uris); + await Promise.all(em.managers.map((m) => m.refresh(options?.rootUri))); return; } + const creator: PythonProjectCreator = selection.creator; // if multiroot, prompt the user to select which workspace to create the project in const workspaceFolders = workspace.workspaceFolders; diff --git a/src/test/common/projectCreatorPicker.unit.test.ts b/src/test/common/projectCreatorPicker.unit.test.ts index f3a0851bb..bb7937bbe 100644 --- a/src/test/common/projectCreatorPicker.unit.test.ts +++ b/src/test/common/projectCreatorPicker.unit.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert'; import * as sinon from 'sinon'; -import { QuickInputButtons, QuickPickItemKind } from 'vscode'; +import { QuickInputButtons, QuickPickItem, QuickPickItemKind, Uri } from 'vscode'; import { PythonProjectCreator } from '../../api'; import * as commandApis from '../../common/command.api'; -import { newProjectSelection } from '../../common/pickers/managers'; +import { pickCreator, newProjectSelection, ProjectCreatorPick } from '../../common/pickers/managers'; import * as windowApis from '../../common/window.apis'; suite('Typed project creator picker', () => { @@ -62,3 +62,116 @@ suite('Typed project creator picker', () => { }); } }); + +suite('Add project quick pick', () => { + const existing: PythonProjectCreator = { + name: 'existingProjects', + description: 'Select existing Python projects', + create: async () => undefined, + }; + const packageCreator: PythonProjectCreator = { + name: 'newPackage', + displayName: 'Package', + create: async () => undefined, + }; + const autoFind: PythonProjectCreator = { + name: 'autoProjects', + description: 'Automatically find Python projects', + create: async () => undefined, + }; + const creators = [autoFind, existing, packageCreator]; + const api = Uri.file('/repo/api'); + const web = Uri.file('/repo/web'); + const discovered = [ + { label: 'api', description: '/repo/api/pyproject.toml', uri: api }, + { label: 'web', description: '/repo/web/setup.py', uri: web }, + ]; + + function selectableLabels(items: readonly QuickPickItem[]): string[] { + return items.filter((item) => item.kind !== QuickPickItemKind.Separator).map((item) => item.label); + } + + teardown(() => sinon.restore()); + + test('omits Auto Find when nothing was discovered', async () => { + const picker = sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items, options) => { + assert.deepStrictEqual(selectableLabels(items), ['Select Existing', 'Create New']); + assert.ok(!selectableLabels(items).includes('Auto Find')); + assert.strictEqual(options?.placeHolder, 'Select a project creator'); + assert.strictEqual(options?.matchOnDescription, false); + return undefined; + }); + + assert.strictEqual(await pickCreator(creators, []), undefined); + sinon.assert.calledOnce(picker); + }); + + test('lists discovered projects in the same quick pick', async () => { + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items, options) => { + assert.deepStrictEqual(selectableLabels(items), [ + 'api', + 'web', + 'Add all discovered projects', + 'Select Existing', + 'Create New', + ]); + assert.ok(!selectableLabels(items).includes('Auto Find')); + assert.strictEqual(options?.placeHolder, 'Select a project'); + assert.strictEqual(options?.matchOnDescription, true); + assert.strictEqual(options?.showBackButton, true); + return items.find((item) => item.label === 'api'); + }); + + const result = await pickCreator(creators, discovered); + assert.deepStrictEqual(projectPaths(result), [api.fsPath]); + }); + + test('does not offer add-all when only one project was discovered', async () => { + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items) => { + assert.deepStrictEqual(selectableLabels(items), ['api', 'Select Existing', 'Create New']); + return items.find((item) => item.label === 'api'); + }); + + const result = await pickCreator(creators, [discovered[0]]); + assert.deepStrictEqual(projectPaths(result), [api.fsPath]); + }); + + test('adds every discovered project from the same quick pick', async () => { + sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items) => + items.find((item) => item.label === 'Add all discovered projects'), + ); + + const result = await pickCreator(creators, discovered); + assert.deepStrictEqual(projectPaths(result), [api.fsPath, web.fsPath]); + }); + + test('still selects an existing project or a new template', async () => { + const picker = sinon.stub(windowApis, 'showQuickPickWithButtons'); + picker.onFirstCall().callsFake(async (items) => items.find((item) => item.label === 'Select Existing')); + assert.deepStrictEqual(await pickCreator(creators, discovered), { kind: 'creator', creator: existing }); + + picker.reset(); + picker.onFirstCall().callsFake(async (items) => items.find((item) => item.label === 'Create New')); + picker.onSecondCall().callsFake(async (items) => items[0]); + assert.deepStrictEqual(await pickCreator(creators, []), { kind: 'creator', creator: packageCreator }); + }); + + test('returns a single non-discovery creator without a quick pick', async () => { + const picker = sinon.stub(windowApis, 'showQuickPickWithButtons'); + + assert.deepStrictEqual(await pickCreator([packageCreator]), { kind: 'creator', creator: packageCreator }); + sinon.assert.notCalled(picker); + }); + + test('does not open a quick pick when Auto Find is the only creator and nothing was discovered', async () => { + const picker = sinon.stub(windowApis, 'showQuickPickWithButtons'); + + assert.strictEqual(await pickCreator([autoFind], []), undefined); + sinon.assert.notCalled(picker); + }); +}); + +function projectPaths(result: ProjectCreatorPick | undefined): string[] { + assert.ok(result?.kind === 'discovered'); + return result.uris.map((uri) => uri.fsPath); +} diff --git a/src/test/features/addPythonProject.quickpick.unit.test.ts b/src/test/features/addPythonProject.quickpick.unit.test.ts new file mode 100644 index 000000000..a97046dbe --- /dev/null +++ b/src/test/features/addPythonProject.quickpick.unit.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { ProgressLocation, Uri } from 'vscode'; +import { PythonProjectCreator } from '../../api'; +import { ProjectCreatorString } from '../../common/localize'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import { AutoFindProjects } from '../../features/creators/autoFindProjects'; +import { addPythonProjectCommand } from '../../features/envCommands'; +import type { EnvironmentManagers } from '../../features/envManagers'; +import type { ProjectCreators } from '../../features/creators/projectCreators'; +import type { PythonProjectManager } from '../../features/projectManager'; + +suite('Add Python project quick pick', () => { + let findFilesStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + let withProgressStub: sinon.SinonStub; + + setup(() => { + findFilesStub = sinon.stub(workspaceApis, 'findFiles'); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns(undefined); + showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage'); + sinon.stub(windowApis, 'showWarningMessage'); + withProgressStub = sinon + .stub(windowApis, 'withProgress') + .callsFake((_options, task) => task({ report: () => undefined }, {} as never)); + }); + + teardown(() => sinon.restore()); + + function creators(projectManager: PythonProjectManager, envManagers: EnvironmentManagers): PythonProjectCreator[] { + return [ + new AutoFindProjects(projectManager, envManagers), + { + name: 'existingProjects', + description: 'Select existing Python projects', + create: async () => undefined, + }, + { + name: 'newPackage', + displayName: 'Package', + create: async () => undefined, + }, + ]; + } + + test('adds a discovered project from the first quick pick without an error', async () => { + findFilesStub.resolves([Uri.file('/workspace/root/pkg/pyproject.toml')]); + const projectManager = typemoq.Mock.ofType(); + projectManager.setup((pm) => pm.get(typemoq.It.isAny())).returns(() => undefined); + projectManager + .setup((pm) => pm.getProjects()) + .returns(() => [{ name: 'root', uri: Uri.file('/workspace/root') }]); + projectManager.setup((pm) => pm.add(typemoq.It.isAny())).returns(() => Promise.resolve()); + const refresh = sinon.stub().resolves(); + const envManagers = { + getEnvironment: async () => undefined, + managers: [{ refresh }], + } as unknown as EnvironmentManagers; + const picker = sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items) => { + assert.ok(!items.some((item) => item.label === 'Auto Find')); + return items.find((item) => item.label === 'pkg'); + }); + + await addPythonProjectCommand(undefined, projectManager.object, envManagers, { + getProjectCreators: () => creators(projectManager.object, envManagers), + } as ProjectCreators); + + sinon.assert.calledOnce(picker); + sinon.assert.calledOnce(withProgressStub); + assert.deepStrictEqual(withProgressStub.firstCall.args[0], { + location: ProgressLocation.Window, + title: ProjectCreatorString.findingProjects, + }); + projectManager.verify((pm) => pm.add(typemoq.It.isAny()), typemoq.Times.once()); + assert.ok(refresh.calledOnce); + assert.ok(showErrorMessageStub.notCalled); + }); + + test('hides Auto Find and does not report no projects found when discovery is empty', async () => { + findFilesStub.resolves([]); + const projectManager = typemoq.Mock.ofType(); + projectManager + .setup((pm) => pm.getProjects()) + .returns(() => [{ name: 'root', uri: Uri.file('/workspace/root') }]); + const envManagers = { + getEnvironment: async () => undefined, + managers: [{ refresh: sinon.stub().resolves() }], + } as unknown as EnvironmentManagers; + const picker = sinon.stub(windowApis, 'showQuickPickWithButtons').callsFake(async (items, options) => { + assert.deepStrictEqual( + items.filter((item) => item.label).map((item) => item.label), + ['Select Existing', 'Create New'], + ); + assert.strictEqual(options?.title, undefined); + assert.ok(options?.placeHolder?.includes('project')); + return undefined; + }); + + await addPythonProjectCommand(undefined, projectManager.object, envManagers, { + getProjectCreators: () => creators(projectManager.object, envManagers), + } as ProjectCreators); + + sinon.assert.calledOnce(picker); + sinon.assert.calledOnce(withProgressStub); + assert.ok(showErrorMessageStub.notCalled); + }); +}); diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts index 1e4cdc6ee..a006570b5 100644 --- a/src/test/features/creators/autoFindProjects.unit.test.ts +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -38,6 +38,81 @@ suite('Auto Find Project tests', () => { sinon.restore(); }); + test('findNewProjects returns nothing and does not notify when no markers exist', async () => { + findFilesStub.resolves([]); + + const autoFindProjects = new AutoFindProjects(projectManager.object, envManagers.object); + assert.deepStrictEqual(await autoFindProjects.findNewProjects(), []); + assert.ok(showErrorMessageStub.notCalled); + assert.ok(showWarningMessageStub.notCalled); + assert.ok(showQuickPickWithButtonsStub.notCalled); + }); + + test('findNewProjects lists unregistered directories without prompting', async () => { + findFilesStub.resolves([ + Uri.file('/usr/home/root/a/pyproject.toml'), + Uri.file('/usr/home/root/a/setup.py'), + Uri.file('/usr/home/root/b/pyproject.toml'), + ]); + projectManager + .setup((pm) => pm.get(typmoq.It.isAny())) + .returns((uri) => { + if (path.basename(path.dirname(uri.fsPath)) === 'b') { + return { name: 'b', uri: Uri.file('/usr/home/root/b') }; + } + return undefined; + }); + + const autoFindProjects = new AutoFindProjects(projectManager.object, envManagers.object); + const found = await autoFindProjects.findNewProjects(); + + assert.deepStrictEqual( + found.map((project) => project.label), + ['a'], + ); + assert.strictEqual(found[0].uri.fsPath, Uri.file('/usr/home/root/a').fsPath); + assert.ok(showErrorMessageStub.notCalled); + assert.ok(showQuickPickWithButtonsStub.notCalled); + }); + + test('findNewProjects excludes markers inside a selected environment', async () => { + const root = Uri.file(path.join(path.parse(process.cwd()).root, 'usr', 'home', 'root')).fsPath; + const envPrefix = path.join(root, 'custom-env'); + getWorkspaceFoldersStub.returns([{ uri: Uri.file(root), name: 'root', index: 0 }]); + envManagers + .setup((em) => em.getEnvironment(typmoq.It.isAny())) + .returns( + async () => + ({ sysPrefix: envPrefix, envId: { id: envPrefix, managerId: VENV_MANAGER_ID } }) as never, + ); + findFilesStub.resolves([ + Uri.file(path.join(root, 'app', 'pyproject.toml')), + Uri.file(path.join(envPrefix, 'lib', 'pkg', 'setup.py')), + ]); + projectManager.setup((pm) => pm.get(typmoq.It.isAny())).returns(() => undefined); + + const autoFindProjects = new AutoFindProjects(projectManager.object, envManagers.object); + assert.deepStrictEqual( + (await autoFindProjects.findNewProjects()).map((project) => project.label), + ['app'], + ); + assert.ok(showQuickPickWithButtonsStub.notCalled); + }); + + test('addDiscoveredProjects registers the selected directories', async () => { + const uri = Uri.file('/usr/home/root/app'); + projectManager.setup((pm) => pm.add(typmoq.It.isAny())).returns(() => Promise.resolve()); + + const autoFindProjects = new AutoFindProjects(projectManager.object, envManagers.object); + const added = autoFindProjects.addDiscoveredProjects([uri]); + + assert.deepStrictEqual( + added.map((project) => ({ name: project.name, fsPath: project.uri.fsPath })), + [{ name: 'app', fsPath: uri.fsPath }], + ); + projectManager.verify((pm) => pm.add(typmoq.It.isAny()), typmoq.Times.once()); + }); + test('No projects found', async () => { findFilesStub.resolves([]);