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([]);