diff --git a/libs/vscode/add-dependency/src/lib/dependency-versioning.ts b/libs/vscode/add-dependency/src/lib/dependency-versioning.ts new file mode 100644 index 0000000000..92d23d8ac7 --- /dev/null +++ b/libs/vscode/add-dependency/src/lib/dependency-versioning.ts @@ -0,0 +1,158 @@ +import { xhr } from 'request-light'; +import { gte, rcompare } from 'semver'; +import { QuickPickItem, QuickPickItemKind, window } from 'vscode'; + +type VersionMap = Record; + +export async function resolveDependencyVersioning( + depInput: string +): Promise<{ dep: string; version: string | undefined } | undefined> { + const match = depInput.match(/^(.+)@(.+)/); + if (match) { + const [_, dep, version] = match; + return { dep, version }; + } + let packageInfo: PackageInformationResponse; + try { + packageInfo = await getPackageInfo(depInput); + } catch (e) { + window.showErrorMessage( + `Package ${depInput} couldn't be found. Are you sure it exists?` + ); + return { dep: depInput, version: undefined }; + } + + const versionMap = createVersionMap(packageInfo); + const versionQuickPickOptions = createVersionQuickPickItems(versionMap); + + const version = await promptForVersion(versionQuickPickOptions, versionMap); + + return { dep: depInput, version }; +} + +async function promptForVersion( + versionQuickPickItems: QuickPickItem[], + versionMap: VersionMap +): Promise { + const selection = await new Promise((resolve) => { + const quickPick = window.createQuickPick(); + quickPick.canSelectMany = false; + + quickPick.items = versionQuickPickItems; + + quickPick.onDidChangeValue(() => { + quickPick.items = [ + ...versionQuickPickItems, + { + label: quickPick.value, + description: 'install specific version', + }, + ]; + }); + + quickPick.onDidAccept(() => { + resolve(quickPick.selectedItems[0]?.label); + quickPick.hide(); + }); + + quickPick.show(); + }); + const match = selection?.match(/^(\d+).x/); + if (match) { + const majorToSelect = match[1]; + const version = await window.showQuickPick(versionMap[majorToSelect].all, { + canPickMany: false, + }); + if (!version) { + return promptForVersion(versionQuickPickItems, versionMap); + } + return version; + } + return selection; +} + +/** + * Create a map that tracks the latest version and an array of all versions per major version + */ +function createVersionMap(packageInfo: PackageInformationResponse): VersionMap { + const versionMap: VersionMap = {}; + Object.entries(packageInfo.versions).forEach(([versionNum, versionInfo]) => { + if (versionInfo.deprecated) { + return; + } + const major = versionNum.split('.')[0]; + if (!versionMap[major]) { + versionMap[major] = { latest: versionNum, all: [] }; + } + versionMap[major].all.push(versionNum); + if (gte(versionNum, versionMap[major].latest)) { + versionMap[major].latest = versionNum; + } + }); + return versionMap; +} + +/** + * For each major version, add the following options to the quickpick: + * - the latest version of that major + * - the patch before the latest version of that major + * - the minor before the latest version of that major + * - the option to select a specific version of that major + */ +function createVersionQuickPickItems(versionMap: VersionMap): QuickPickItem[] { + return Object.entries(versionMap) + .sort( + ( + a: [keyof VersionMap, VersionMap[keyof VersionMap]], + b: [keyof VersionMap, VersionMap[keyof VersionMap]] + ) => (parseInt(a[0]) < parseInt(b[0]) ? 1 : -1) + ) + .flatMap(([major, { latest, all }], index) => { + const allSorted = all.sort(rcompare); + const quickPickOptions = []; + quickPickOptions.push({ + label: `Version ${major}.x`, + kind: QuickPickItemKind.Separator, + }); + quickPickOptions.push({ + label: latest, + description: index === 0 ? 'latest' : '', + }); + if (allSorted.length > 1) { + quickPickOptions.push({ label: allSorted[1] }); + } + const minorBefore = allSorted.find( + (v) => + v.split('.')[1] === (parseInt(latest.split('.')[1]) - 1).toString() + ); + if (minorBefore && minorBefore !== allSorted[1]) { + quickPickOptions.push({ label: minorBefore }); + } + if (allSorted.length > 2) { + quickPickOptions.push({ + label: `${major}.x`, + description: 'select specific version', + }); + } + return quickPickOptions; + }); +} + +type PackageInformationResponse = { + versions: Record; +}; +function getPackageInfo(dep: string): Promise { + const headers = { + 'Accept-Encoding': 'gzip, deflate', + Accept: 'application/vnd.npm.install-v1+json', + }; + + // https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md + return xhr({ + url: `https://registry.npmjs.org/${dep}`, + headers, + }).then( + (res) => JSON.parse(res.responseText), + (error) => Promise.reject(error) + ); +} diff --git a/libs/vscode/add-dependency/src/lib/vscode-add-dependency.ts b/libs/vscode/add-dependency/src/lib/vscode-add-dependency.ts index bd57515497..7b85edd448 100644 --- a/libs/vscode/add-dependency/src/lib/vscode-add-dependency.ts +++ b/libs/vscode/add-dependency/src/lib/vscode-add-dependency.ts @@ -16,12 +16,16 @@ import { commands, ExtensionContext, QuickInput, + QuickPickItem, + QuickPickItemKind, ShellExecution, Task, tasks, TaskScope, window, } from 'vscode'; +import { gte, major, rcompare } from 'semver'; +import { resolveDependencyVersioning } from './dependency-versioning'; export const ADD_DEPENDENCY_COMMAND = 'nxConsole.addDependency'; export const ADD_DEV_DEPENDENCY_COMMAND = 'nxConsole.addDevDependency'; @@ -48,12 +52,23 @@ function vscodeAddDependencyCommand(installAsDevDependency: boolean) { ); pkgManager = detectPackageManager(workspacePath); - const dep = await promptForDependencyName(); + const depInput = await promptForDependencyInput(); + + if (!depInput) { + return; + } + const depVersioningInfo = await resolveDependencyVersioning(depInput); + + if (!depVersioningInfo?.version) { + return; + } + + const { dep, version } = depVersioningInfo; if (dep) { const quickInput = showLoadingQuickInput(dep); getTelemetry().featureUsed('add-dependency'); - addDependency(dep, installAsDevDependency); + addDependency(dep, version, installAsDevDependency); const disposable = tasks.onDidEndTaskProcess((taskEndEvent) => { if ( taskEndEvent.execution.task.definition.type === 'nxconsole-add-dep' @@ -67,13 +82,14 @@ function vscodeAddDependencyCommand(installAsDevDependency: boolean) { }; } -async function promptForDependencyName(): Promise { +async function promptForDependencyInput(): Promise { const packageSuggestions = (await getDependencySuggestions()).map((pkg) => ({ label: pkg.name, description: pkg.description, })); const dep = await new Promise((resolve) => { const quickPick = window.createQuickPick(); + quickPick.title = 'Select Dependency'; quickPick.items = packageSuggestions; quickPick.placeholder = 'The name of the dependency you want to add. Can be anything on the npm registry.'; @@ -112,11 +128,15 @@ function showLoadingQuickInput(dependency: string): QuickInput { return quickInput; } -function addDependency(dependency: string, installAsDevDependency: boolean) { +function addDependency( + dependency: string, + version: string, + installAsDevDependency: boolean +) { const pkgManagerCommands = getPackageManagerCommand(pkgManager); const command = `${ installAsDevDependency ? pkgManagerCommands.addDev : pkgManagerCommands.add - } ${dependency}`; + } ${dependency}@${version}`; const task = new Task( { type: 'nxconsole-add-dep', @@ -138,11 +158,6 @@ async function executeInitGenerator( includeNgAdd: true, }); - // get the dependency's name if it came with a version - if (dependency.lastIndexOf('@') > 0) { - dependency = dependency.substring(0, dependency.lastIndexOf('@')); - } - let initGeneratorName = `${dependency}:init`; let initGenerator = generators.find((g) => g.name === initGeneratorName); if (!initGenerator) { diff --git a/package.json b/package.json index 017c59a2fe..d52bb0ea34 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "jsonc-parser": "^3.0.0", "request-light": "^0.5.8", "rxjs": "7.5.6", + "semver": "^7.3.7", "tslib": "^2.0.0", "vscode-json-languageservice": "^5.1.0", "vscode-languageclient": "^8.0.2", @@ -67,6 +68,7 @@ "@types/find-cache-dir": "^3.2.1", "@types/jest": "27.4.1", "@types/node": "18.7.1", + "@types/semver": "^7.3.12", "@types/universal-analytics": "0.4.5", "@types/uuid": "^8.3.4", "@types/vscode": "1.71.0", diff --git a/yarn.lock b/yarn.lock index ad7c19eff8..816eb02a7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7199,6 +7199,11 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== +"@types/semver@^7.3.12": + version "7.3.12" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.12.tgz#920447fdd78d76b19de0438b7f60df3c4a80bf1c" + integrity sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A== + "@types/serve-index@^1.9.1": version "1.9.1" resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278"