diff --git a/src/common/localize.ts b/src/common/localize.ts index a81f8042..16c73138 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -126,3 +126,15 @@ export namespace CondaStrings { export const condaRemoveFailed = l10n.t('Failed to remove conda environment'); export const condaExists = l10n.t('Environment already exists'); } + +export namespace ProjectCreatorString { + export const addExistingProjects = l10n.t('Add Existing Projects'); + export const autoFindProjects = l10n.t('Auto Find Projects'); + export const selectProjects = l10n.t('Select Python projects'); + export const selectFilesOrFolders = l10n.t('Select Project folders or Python files'); + export const autoFindProjectsDescription = l10n.t( + 'Automatically find folders with `pyproject.toml` or `setup.py` files.', + ); + + export const noProjectsFound = l10n.t('No projects found'); +} diff --git a/src/common/utils/asyncUtils.ts b/src/common/utils/asyncUtils.ts new file mode 100644 index 00000000..4bb79f84 --- /dev/null +++ b/src/common/utils/asyncUtils.ts @@ -0,0 +1,3 @@ +export async function sleep(milliseconds: number) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/extension.ts b/src/extension.ts index 91bbc528..c23ea9d3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,11 +30,7 @@ import { getPythonApi, setPythonApi } from './features/pythonApi'; import { setPersistentState } from './common/persistentState'; import { createNativePythonFinder, NativePythonFinder } from './managers/common/nativePythonFinder'; import { PythonEnvironmentApi } from './api'; -import { - ProjectCreatorsImpl, - registerAutoProjectProvider, - registerExistingProjectProvider, -} from './features/projectCreators'; +import { ProjectCreatorsImpl } from './features/creators/projectCreators'; import { ProjectView } from './features/views/projectView'; import { registerCompletionProvider } from './features/settings/settingCompletions'; import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; @@ -56,6 +52,8 @@ import { StopWatch } from './common/stopWatch'; import { sendTelemetryEvent } from './common/telemetry/sender'; import { EventNames } from './common/telemetry/constants'; import { ensureCorrectVersion } from './common/extVersion'; +import { ExistingProjects } from './features/creators/existingProjects'; +import { AutoFindProjects } from './features/creators/autoFindProjects'; export async function activate(context: ExtensionContext): Promise { const start = new StopWatch(); @@ -87,8 +85,8 @@ export async function activate(context: ExtensionContext): Promise uri.fsPath).sort(); + const dirs: Map = new Map(); + files.forEach((file) => { + const dir = path.dirname(file); + if (dirs.has(dir)) { + return; + } + dirs.set(dir, file); + }); + return Array.from(dirs.entries()) + .map(([dir, file]) => ({ + label: path.basename(dir), + description: file, + uri: Uri.file(dir), + })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +async function pickProjects(uris: Uri[]): Promise { + const items = getUniqueUri(uris); + + const selected = await showQuickPickWithButtons(items, { + canPickMany: true, + ignoreFocusOut: true, + placeHolder: ProjectCreatorString.selectProjects, + showBackButton: true, + }); + + if (Array.isArray(selected)) { + return selected.map((s) => s.uri); + } else if (selected) { + return [selected.uri]; + } + + return undefined; +} + +export class AutoFindProjects implements PythonProjectCreator { + public readonly name = 'autoProjects'; + public readonly displayName = ProjectCreatorString.autoFindProjects; + public readonly description = ProjectCreatorString.autoFindProjectsDescription; + + constructor(private readonly pm: PythonProjectManager) {} + + async create(_options?: PythonProjectCreatorOptions): Promise { + const files = await findFiles('**/{pyproject.toml,setup.py}'); + if (!files || files.length === 0) { + setImmediate(() => { + showErrorMessage('No projects found'); + }); + return; + } + + const filtered = files.filter((uri) => { + const p = this.pm.get(uri); + if (p) { + // If there ia already a project with the same path, skip it. + // If there is a project with the same parent path, skip it. + const np = path.normalize(p.uri.fsPath); + const nf = path.normalize(uri.fsPath); + const nfp = path.dirname(nf); + return np !== nf && np !== nfp; + } + return true; + }); + + if (filtered.length === 0) { + return; + } + + const projects = await pickProjects(filtered); + if (!projects || projects.length === 0) { + return; + } + + return projects.map((uri) => ({ + name: path.basename(uri.fsPath), + uri, + })); + } +} diff --git a/src/features/creators/existingProjects.ts b/src/features/creators/existingProjects.ts new file mode 100644 index 00000000..25c8b152 --- /dev/null +++ b/src/features/creators/existingProjects.ts @@ -0,0 +1,30 @@ +import * as path from 'path'; +import { PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from '../../api'; +import { ProjectCreatorString } from '../../common/localize'; +import { showOpenDialog } from '../../common/window.apis'; + +export class ExistingProjects implements PythonProjectCreator { + public readonly name = 'existingProjects'; + public readonly displayName = ProjectCreatorString.addExistingProjects; + + async create(_options?: PythonProjectCreatorOptions): Promise { + const results = await showOpenDialog({ + canSelectFiles: true, + canSelectFolders: true, + canSelectMany: true, + filters: { + python: ['py'], + }, + title: ProjectCreatorString.selectFilesOrFolders, + }); + + if (!results || results.length === 0) { + return; + } + + return results.map((r) => ({ + name: path.basename(r.fsPath), + uri: r, + })); + } +} diff --git a/src/features/creators/projectCreators.ts b/src/features/creators/projectCreators.ts new file mode 100644 index 00000000..0c52d43e --- /dev/null +++ b/src/features/creators/projectCreators.ts @@ -0,0 +1,21 @@ +import { Disposable } from 'vscode'; +import { PythonProjectCreator } from '../../api'; +import { ProjectCreators } from '../../internal.api'; + +export class ProjectCreatorsImpl implements ProjectCreators { + private _creators: PythonProjectCreator[] = []; + + registerPythonProjectCreator(creator: PythonProjectCreator): Disposable { + this._creators.push(creator); + return new Disposable(() => { + this._creators = this._creators.filter((item) => item !== creator); + }); + } + getProjectCreators(): PythonProjectCreator[] { + return this._creators; + } + + dispose() { + this._creators = []; + } +} diff --git a/src/features/projectCreators.ts b/src/features/projectCreators.ts deleted file mode 100644 index f2550d42..00000000 --- a/src/features/projectCreators.ts +++ /dev/null @@ -1,123 +0,0 @@ -import * as path from 'path'; -import { Disposable, Uri } from 'vscode'; -import { PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from '../api'; -import { ProjectCreators } from '../internal.api'; -import { showErrorMessage } from '../common/errors/utils'; -import { findFiles } from '../common/workspace.apis'; -import { showOpenDialog, showQuickPickWithButtons } from '../common/window.apis'; - -export class ProjectCreatorsImpl implements ProjectCreators { - private _creators: PythonProjectCreator[] = []; - - registerPythonProjectCreator(creator: PythonProjectCreator): Disposable { - this._creators.push(creator); - return new Disposable(() => { - this._creators = this._creators.filter((item) => item !== creator); - }); - } - getProjectCreators(): PythonProjectCreator[] { - return this._creators; - } - - dispose() { - this._creators = []; - } -} - -export function registerExistingProjectProvider(pc: ProjectCreators): Disposable { - return pc.registerPythonProjectCreator({ - name: 'existingProjects', - displayName: 'Add Existing Projects', - - async create(_options?: PythonProjectCreatorOptions): Promise { - const results = await showOpenDialog({ - canSelectFiles: true, - canSelectFolders: true, - canSelectMany: true, - filters: { - python: ['py'], - }, - title: 'Select a file(s) or folder(s) to add as Python projects', - }); - - if (!results || results.length === 0) { - return; - } - - return results.map((r) => ({ - name: path.basename(r.fsPath), - uri: r, - })); - }, - }); -} - -function getUniqueUri(uris: Uri[]): { - label: string; - description: string; - uri: Uri; -}[] { - const files = uris.map((uri) => uri.fsPath).sort(); - const dirs: Map = new Map(); - files.forEach((file) => { - const dir = path.dirname(file); - if (dirs.has(dir)) { - return; - } - dirs.set(dir, file); - }); - return Array.from(dirs.entries()) - .map(([dir, file]) => ({ - label: path.basename(dir), - description: file, - uri: Uri.file(dir), - })) - .sort((a, b) => a.label.localeCompare(b.label)); -} - -async function pickProjects(uris: Uri[]): Promise { - const items = getUniqueUri(uris); - - const selected = await showQuickPickWithButtons(items, { - canPickMany: true, - ignoreFocusOut: true, - placeHolder: 'Select the folders to add as Python projects', - showBackButton: true, - }); - - if (Array.isArray(selected)) { - return selected.map((s) => s.uri); - } else if (selected) { - return [selected.uri]; - } - - return undefined; -} - -export function registerAutoProjectProvider(pc: ProjectCreators): Disposable { - return pc.registerPythonProjectCreator({ - name: 'autoProjects', - displayName: 'Auto Find Projects', - description: 'Automatically find folders with `pyproject.toml` or `setup.py` files.', - - async create(_options?: PythonProjectCreatorOptions): Promise { - const files = await findFiles('**/{pyproject.toml,setup.py}'); - if (!files || files.length === 0) { - setImmediate(() => { - showErrorMessage('No projects found'); - }); - return; - } - - const projects = await pickProjects(files); - if (!projects || projects.length === 0) { - return; - } - - return projects.map((uri) => ({ - name: path.basename(uri.fsPath), - uri, - })); - }, - }); -} diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts new file mode 100644 index 00000000..1c45c7b8 --- /dev/null +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -0,0 +1,276 @@ +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as typmoq from 'typemoq'; +import * as wapi from '../../../common/workspace.apis'; +import * as eapi from '../../../common/errors/utils'; +import * as winapi from '../../../common/window.apis'; +import { PythonProjectManager } from '../../../internal.api'; +import { createDeferred } from '../../../common/utils/deferred'; +import { AutoFindProjects } from '../../../features/creators/autoFindProjects'; +import assert from 'assert'; +import { Uri } from 'vscode'; +import { PythonProject } from '../../../api'; +import { sleep } from '../../../common/utils/asyncUtils'; + +suite('Auto Find Project tests', () => { + let findFilesStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + let showQuickPickWithButtonsStub: sinon.SinonStub; + let projectManager: typmoq.IMock; + + setup(() => { + findFilesStub = sinon.stub(wapi, 'findFiles'); + showErrorMessageStub = sinon.stub(eapi, 'showErrorMessage'); + + showQuickPickWithButtonsStub = sinon.stub(winapi, 'showQuickPickWithButtons'); + showQuickPickWithButtonsStub.callsFake((items) => items); + + projectManager = typmoq.Mock.ofType(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('No projects found', async () => { + findFilesStub.resolves([]); + + const deferred = createDeferred(); + + let errorShown = false; + showErrorMessageStub.callsFake(() => { + errorShown = true; + deferred.resolve(); + }); + + const autoFindProjects = new AutoFindProjects(projectManager.object); + const result = await autoFindProjects.create(); + assert.equal(result, undefined, 'Result should be undefined'); + + await Promise.race([deferred.promise, sleep(100)]); + assert.ok(errorShown, 'Error message should have been shown'); + }); + + test('No projects found (undefined)', async () => { + findFilesStub.resolves(undefined); + + const deferred = createDeferred(); + + let errorShown = false; + showErrorMessageStub.callsFake(() => { + errorShown = true; + deferred.resolve(); + }); + + const autoFindProjects = new AutoFindProjects(projectManager.object); + const result = await autoFindProjects.create(); + assert.equal(result, undefined, 'Result should be undefined'); + + await Promise.race([deferred.promise, sleep(100)]); + assert.ok(errorShown, 'Error message should have been shown'); + }); + + test('Projects found', async () => { + findFilesStub.resolves([ + Uri.file('/usr/home/root/a/pyproject.toml'), + Uri.file('/usr/home/root/b/pyproject.toml'), + ]); + + projectManager.setup((pm) => pm.get(typmoq.It.isAny())).returns(() => undefined); + + const autoFindProjects = new AutoFindProjects(projectManager.object); + const result = await autoFindProjects.create(); + + const expected: PythonProject[] = [ + { + name: 'a', + uri: Uri.file('/usr/home/root/a'), + }, + { + name: 'b', + uri: Uri.file('/usr/home/root/b'), + }, + ]; + + assert.ok(Array.isArray(result), 'Result should be an array'); + assert.equal(result.length, expected.length, `Result should have ${expected.length} items`); + + expected.forEach((item) => { + assert.ok( + result.some((r) => r.name === item.name && r.uri.fsPath === item.uri.fsPath), + 'Item not found in result', + ); + }); + result.forEach((item) => { + assert.ok( + expected.some((r) => r.name === item.name && r.uri.fsPath === item.uri.fsPath), + 'Item not found in expected', + ); + }); + }); + + test('Projects found (with duplicates)', async () => { + findFilesStub.resolves([ + Uri.file('/usr/home/root/a/pyproject.toml'), + Uri.file('/usr/home/root/b/pyproject.toml'), + Uri.file('/usr/home/root/c/pyproject.toml'), + Uri.file('/usr/home/root/d/pyproject.toml'), + ]); + + projectManager + .setup((pm) => pm.get(typmoq.It.isAny())) + .returns((uri) => { + const basename = path.basename(uri.fsPath); + if (basename === 'pyproject.toml') { + const parent = path.dirname(uri.fsPath); + const name = path.basename(parent); + if (name === 'a' || name === 'd') { + return { name, uri: Uri.file(parent) }; + } + } + }); + + const autoFindProjects = new AutoFindProjects(projectManager.object); + const result = await autoFindProjects.create(); + + const expected: PythonProject[] = [ + { + name: 'b', + uri: Uri.file('/usr/home/root/b'), + }, + { + name: 'c', + uri: Uri.file('/usr/home/root/c'), + }, + ]; + + assert.ok(Array.isArray(result), 'Result should be an array'); + assert.equal(result.length, expected.length, `Result should have ${expected.length} items`); + + expected.forEach((item) => { + assert.ok( + result.some((r) => r.name === item.name && r.uri.fsPath === item.uri.fsPath), + 'Item not found in result', + ); + }); + result.forEach((item) => { + assert.ok( + expected.some((r) => r.name === item.name && r.uri.fsPath === item.uri.fsPath), + 'Item not found in expected', + ); + }); + }); + + test('Projects found (with all duplicates)', async () => { + findFilesStub.resolves([ + Uri.file('/usr/home/root/a/pyproject.toml'), + Uri.file('/usr/home/root/b/pyproject.toml'), + Uri.file('/usr/home/root/c/pyproject.toml'), + Uri.file('/usr/home/root/d/pyproject.toml'), + ]); + + projectManager + .setup((pm) => pm.get(typmoq.It.isAny())) + .returns((uri) => { + const basename = path.basename(uri.fsPath); + if (basename === 'pyproject.toml') { + const parent = path.dirname(uri.fsPath); + const name = path.basename(parent); + return { name, uri: Uri.file(parent) }; + } + }); + + const autoFindProjects = new AutoFindProjects(projectManager.object); + const result = await autoFindProjects.create(); + + assert.equal(result, undefined, 'Result should be undefined'); + }); + + test('Projects found no selection', async () => { + findFilesStub.resolves([ + Uri.file('/usr/home/root/a/pyproject.toml'), + Uri.file('/usr/home/root/b/pyproject.toml'), + ]); + + projectManager.setup((pm) => pm.get(typmoq.It.isAny())).returns(() => undefined); + + showQuickPickWithButtonsStub.callsFake(() => []); + + const autoFindProjects = new AutoFindProjects(projectManager.object); + const result = await autoFindProjects.create(); + + assert.equal(result, undefined, 'Result should be undefined'); + }); + + test('Projects found with no selection (user hit escape in picker)', async () => { + findFilesStub.resolves([ + Uri.file('/usr/home/root/a/pyproject.toml'), + Uri.file('/usr/home/root/b/pyproject.toml'), + ]); + + projectManager.setup((pm) => pm.get(typmoq.It.isAny())).returns(() => undefined); + + showQuickPickWithButtonsStub.callsFake(() => undefined); + + const autoFindProjects = new AutoFindProjects(projectManager.object); + const result = await autoFindProjects.create(); + + assert.equal(result, undefined, 'Result should be undefined'); + }); + + test('Projects found with selection', async () => { + findFilesStub.resolves([ + Uri.file('/usr/home/root/a/pyproject.toml'), + Uri.file('/usr/home/root/b/pyproject.toml'), + Uri.file('/usr/home/root/c/pyproject.toml'), + Uri.file('/usr/home/root/d/pyproject.toml'), + ]); + + projectManager + .setup((pm) => pm.get(typmoq.It.isAny())) + .returns((uri) => { + const basename = path.basename(uri.fsPath); + if (basename === 'pyproject.toml') { + const parent = path.dirname(uri.fsPath); + const name = path.basename(parent); + if (name === 'c') { + return { name, uri: Uri.file(parent) }; + } + } + }); + + showQuickPickWithButtonsStub.callsFake((items) => { + return [items[0], items[2]]; + }); + + const expected: PythonProject[] = [ + { + name: 'a', + uri: Uri.file('/usr/home/root/a'), + }, + { + name: 'd', + uri: Uri.file('/usr/home/root/d'), + }, + ]; + + const autoFindProjects = new AutoFindProjects(projectManager.object); + const result = await autoFindProjects.create(); + + assert.ok(Array.isArray(result), 'Result should be an array'); + assert.equal(result.length, expected.length, `Result should have ${expected.length} items`); + + expected.forEach((item) => { + assert.ok( + result.some((r) => r.name === item.name && r.uri.fsPath === item.uri.fsPath), + 'Item not found in result', + ); + }); + result.forEach((item) => { + assert.ok( + expected.some((r) => r.name === item.name && r.uri.fsPath === item.uri.fsPath), + 'Item not found in expected', + ); + }); + }); +});