diff --git a/README.md b/README.md index 2740c30..e137359 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ | `npmx.diagnostics.deprecation` | Show warnings for deprecated packages | `boolean` | `true` | | `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` | | `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` | +| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `true` | diff --git a/package.json b/package.json index f312372..669f17a 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,11 @@ "type": "boolean", "default": true, "description": "Show warnings for packages with known vulnerabilities" + }, + "npmx.versionLens.enabled": { + "type": "boolean", + "default": true, + "description": "Show version lens (CodeLens) for package dependencies" } } }, @@ -101,6 +106,11 @@ "command": "npmx.openFileInNpmx", "title": "Open file on npmx.dev", "category": "npmx" + }, + { + "command": "npmx.updateVersion", + "title": "Update package version", + "category": "npmx" } ], "menus": { diff --git a/src/commands/update-version.ts b/src/commands/update-version.ts new file mode 100644 index 0000000..27e724d --- /dev/null +++ b/src/commands/update-version.ts @@ -0,0 +1,13 @@ +import type { Range, Uri } from 'vscode' +import { debounce } from 'perfect-debounce' +import { commands, workspace, WorkspaceEdit } from 'vscode' + +export const updateVersion = debounce(async (uri?: Uri, range?: Range, newVersion?: string) => { + if (!uri || !range || !newVersion) + return + + const edit = new WorkspaceEdit() + edit.replace(uri, range, newVersion) + await workspace.applyEdit(edit) + commands.executeCommand('editor.action.codeLens.refresh') +}, 300, { leading: true, trailing: false }) diff --git a/src/index.ts b/src/index.ts index 048a7d3..00b92db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,10 +9,12 @@ import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' import { CodeActionKind, Disposable, languages } from 'vscode' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' +import { updateVersion } from './commands/update-version' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' import { UpgradeProvider } from './providers/code-actions/upgrade' +import { VersionCodeLensProvider } from './providers/code-lens/version' import { VersionCompletionItemProvider } from './providers/completion-item/version' import { registerDiagnosticCollection } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' @@ -76,6 +78,24 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => disposable.dispose()) }) + watchEffect((onCleanup) => { + if (!config.versionLens.enabled) + return + + const disposables = [ + languages.registerCodeLensProvider( + { pattern: PACKAGE_JSON_PATTERN }, + new VersionCodeLensProvider(packageJsonExtractor), + ), + languages.registerCodeLensProvider( + { pattern: PNPM_WORKSPACE_PATTERN }, + new VersionCodeLensProvider(pnpmWorkspaceYamlExtractor), + ), + ] + + onCleanup(() => Disposable.from(...disposables).dispose()) + }) + registerDiagnosticCollection({ [PACKAGE_JSON_BASENAME]: packageJsonExtractor, [PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor, @@ -84,5 +104,6 @@ export const { activate, deactivate } = defineExtension(() => { useCommands({ [commands.openInBrowser]: openInBrowser, [commands.openFileInNpmx]: openFileInNpmx, + [commands.updateVersion]: updateVersion, }) }) diff --git a/src/providers/code-lens/version.ts b/src/providers/code-lens/version.ts new file mode 100644 index 0000000..741c075 --- /dev/null +++ b/src/providers/code-lens/version.ts @@ -0,0 +1,92 @@ +import type { DependencyInfo, Extractor } from '#types/extractor' +import type { CodeLensProvider, Range, TextDocument } from 'vscode' +import { getPackageInfo } from '#utils/api/package' +import { getUpdateType } from '#utils/semver' +import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/version' +import { debounce } from 'perfect-debounce' +import { CodeLens, EventEmitter } from 'vscode' +import { commands } from '../../generated-meta' + +interface LensData { + dep: DependencyInfo + versionRange: Range + uri: TextDocument['uri'] +} + +const dataMap = new WeakMap() + +export class VersionCodeLensProvider implements CodeLensProvider { + extractor: T + private readonly onDidChangeCodeLensesEmitter = new EventEmitter() + readonly onDidChangeCodeLenses = this.onDidChangeCodeLensesEmitter.event + private readonly scheduleRefresh = debounce(() => { + this.onDidChangeCodeLensesEmitter.fire() + }, 100, { leading: false, trailing: true }) + + constructor(extractor: T) { + this.extractor = extractor + } + + provideCodeLenses(document: TextDocument): CodeLens[] { + const root = this.extractor.parse(document) + if (!root) + return [] + + const deps = this.extractor.getDependenciesInfo(root) + const lenses: CodeLens[] = [] + + for (const dep of deps) { + const parsed = parseVersion(dep.version) + if (!parsed || !isSupportedProtocol(parsed.protocol)) + continue + + const versionRange = this.extractor.getNodeRange(document, dep.versionNode) + const lens = new CodeLens(versionRange) + dataMap.set(lens, { dep, versionRange, uri: document.uri }) + lenses.push(lens) + } + + return lenses + } + + resolveCodeLens(lens: CodeLens) { + const data = dataMap.get(lens) + if (!data) + return lens + + const { dep, versionRange, uri } = data + const parsed = parseVersion(dep.version) + if (!parsed) { + lens.command = { title: '$(question) unknown', command: '' } + return lens + } + + const pkg = getPackageInfo(dep.name) + if (pkg instanceof Promise) { + lens.command = { title: '$(sync~spin) checking...', command: '' } + pkg.finally(() => this.scheduleRefresh()) + return lens + } + + const latest = pkg?.distTags.latest + if (!latest) { + lens.command = { title: '$(question) unknown', command: '' } + return lens + } + + const updateType = getUpdateType(parsed.semver, latest) + + if (updateType === 'none') { + lens.command = { title: '$(check) latest', command: '' } + } else { + const newVersion = formatVersion({ ...parsed, semver: latest }) + lens.command = { + title: `$(arrow-up) ${newVersion} (${updateType})`, + command: commands.updateVersion, + arguments: [uri, versionRange, newVersion], + } + } + + return lens + } +} diff --git a/src/utils/api/package.ts b/src/utils/api/package.ts index b4fd30e..c05ddb6 100644 --- a/src/utils/api/package.ts +++ b/src/utils/api/package.ts @@ -18,16 +18,14 @@ export const getPackageInfo = memoize>(async const pkg = await getVersions(name, { metadata: true, throw: false, + retry: 3, }) if ('error' in pkg) { logger.warn(`Fetching package info for ${name} error: ${JSON.stringify(pkg)}`) // Return null to trigger a cache hit - if (pkg.status === 404) - return null - - throw pkg + return null } logger.info(`Fetched package info for ${name}`) diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts index c699b48..3d956c5 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -13,7 +13,7 @@ interface MemoizeEntry { expiresAt?: number } -type MemoizeReturn = R extends Promise ? Promise : R | undefined +type MemoizeReturn = R extends Promise ? Promise | V | undefined : R | undefined export function memoize(fn: (params: P) => V, options: MemoizeOptions

= {}): (params: P) => MemoizeReturn { const { diff --git a/src/utils/semver.ts b/src/utils/semver.ts new file mode 100644 index 0000000..c4b6c27 --- /dev/null +++ b/src/utils/semver.ts @@ -0,0 +1,39 @@ +export type SemverTuple = [number, number, number] + +export function parseSemverTuple(version: string): SemverTuple | null { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)/) + if (!match) + return null + + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +export type UpdateType = 'major' | 'minor' | 'patch' | 'prerelease' | 'none' + +export function getUpdateType(current: string, latest: string): UpdateType { + const cur = parseSemverTuple(current) + const lat = parseSemverTuple(latest) + + if (!cur || !lat) + return 'none' + + if (lat[0] > cur[0]) + return 'major' + if (lat[0] < cur[0]) + return 'none' + + if (lat[1] > cur[1]) + return 'minor' + if (lat[1] < cur[1]) + return 'none' + + if (lat[2] > cur[2]) + return 'patch' + if (lat[2] < cur[2]) + return 'none' + + if (current !== latest && current.includes('-') && !latest.includes('-')) + return 'prerelease' + + return 'none' +}