From 9383e13e5f4a70475156303d84dc9e851235b6e8 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 27 Feb 2026 23:46:40 +0800 Subject: [PATCH 1/4] refactor: extract `resolveUpgradeTargetVersion` --- src/providers/diagnostics/rules/upgrade.ts | 44 +++++----------------- src/utils/upgrade.ts | 32 ++++++++++++++++ 2 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 src/utils/upgrade.ts diff --git a/src/providers/diagnostics/rules/upgrade.ts b/src/providers/diagnostics/rules/upgrade.ts index 17643b8..745d66b 100644 --- a/src/providers/diagnostics/rules/upgrade.ts +++ b/src/providers/diagnostics/rules/upgrade.ts @@ -1,14 +1,17 @@ -import type { DependencyInfo } from '#types/extractor' -import type { ParsedVersion } from '#utils/version' -import type { DiagnosticRule, NodeDiagnosticInfo } from '..' +import type { DiagnosticRule } from '..' import { npmxPackageUrl } from '#utils/links' +import { resolveUpgradeTargetVersion } from '#utils/upgrade' import { formatUpgradeVersion } from '#utils/version' -import gt from 'semver/functions/gt' -import lte from 'semver/functions/lte' -import prerelease from 'semver/functions/prerelease' import { DiagnosticSeverity, Uri } from 'vscode' -function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, target: string): NodeDiagnosticInfo { +export const checkUpgrade: DiagnosticRule = ({ dep, pkg, parsed, exactVersion }) => { + if (!parsed || !exactVersion) + return + + const target = resolveUpgradeTargetVersion(pkg, exactVersion) + if (!target) + return + return { node: dep.versionNode, severity: DiagnosticSeverity.Hint, @@ -19,30 +22,3 @@ function createUpgradeDiagnostic(dep: DependencyInfo, parsed: ParsedVersion, tar }, } } - -export const checkUpgrade: DiagnosticRule = ({ dep, pkg, parsed, exactVersion }) => { - if (!parsed || !exactVersion) - return - - if (Object.hasOwn(pkg.distTags, exactVersion)) - return - - const { latest } = pkg.distTags - if (gt(latest, exactVersion)) - return createUpgradeDiagnostic(dep, parsed, latest) - - const currentPreId = prerelease(exactVersion)?.[0] - if (currentPreId == null) - return - - for (const [tag, tagVersion] of Object.entries(pkg.distTags)) { - if (tag === 'latest') - continue - if (prerelease(tagVersion)?.[0] !== currentPreId) - continue - if (lte(tagVersion, exactVersion)) - continue - - return createUpgradeDiagnostic(dep, parsed, tagVersion) - } -} diff --git a/src/utils/upgrade.ts b/src/utils/upgrade.ts new file mode 100644 index 0000000..d3fe73f --- /dev/null +++ b/src/utils/upgrade.ts @@ -0,0 +1,32 @@ +import type { PackageInfo } from '#utils/api/package' +import gt from 'semver/functions/gt' +import lte from 'semver/functions/lte' +import prerelease from 'semver/functions/prerelease' + +/** + * Resolve the next upgrade target from npm dist-tags based on current exact version. + * Mirrors the existing diagnostics upgrade rule behavior so other providers can reuse it. + */ +export function resolveUpgradeTargetVersion(pkg: PackageInfo, exactVersion: string): string | undefined { + if (Object.hasOwn(pkg.distTags, exactVersion)) + return + + const { latest } = pkg.distTags + if (gt(latest, exactVersion)) + return latest + + const currentPreId = prerelease(exactVersion)?.[0] + if (currentPreId == null) + return + + for (const [tag, tagVersion] of Object.entries(pkg.distTags)) { + if (tag === 'latest') + continue + if (prerelease(tagVersion)?.[0] !== currentPreId) + continue + if (lte(tagVersion, exactVersion)) + continue + + return tagVersion + } +} From 8292d9908eddb17dc532b9265baf3026014568a5 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 27 Feb 2026 23:50:55 +0800 Subject: [PATCH 2/4] feat: version lens --- README.md | 1 + eslint.config.ts | 3 + package.json | 5 ++ playground/.vscode/settings.json | 3 +- src/commands/internal/index.ts | 9 +++ src/commands/internal/replace-text.ts | 9 +++ src/index.ts | 5 +- src/providers/code-lens/index.ts | 18 ++++++ src/providers/code-lens/version.ts | 91 +++++++++++++++++++++++++++ src/state.ts | 4 ++ 10 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/commands/internal/index.ts create mode 100644 src/commands/internal/replace-text.ts create mode 100644 src/providers/code-lens/index.ts create mode 100644 src/providers/code-lens/version.ts diff --git a/README.md b/README.md index 7288844..9cbca0f 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ | `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` | | `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` | | `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` | +| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `false` | diff --git a/eslint.config.ts b/eslint.config.ts index 332251b..e9cfbff 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -7,10 +7,13 @@ export default defineConfig( }, { files: ['src/commands/**'], + ignores: ['**/index.ts'], rules: { 'no-restricted-imports': ['error', { paths: [{ name: 'reactive-vscode', + allowImportNames: ['useCommand', 'useCommands', 'useTextEditorCommand', 'useTextEditorCommands'], + allowTypeImports: true, message: 'Do not use reactive-vscode composables in command handlers. Use vscode API directly.', }], }], diff --git a/package.json b/package.json index 858f28f..1ba2ebf 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,11 @@ "type": "boolean", "default": true, "description": "Show warnings when a dependency uses a dist tag" + }, + "npmx.versionLens.enabled": { + "type": "boolean", + "default": false, + "description": "Show version lens (CodeLens) for package dependencies" } } }, diff --git a/playground/.vscode/settings.json b/playground/.vscode/settings.json index 8afa8c2..08fc8d8 100644 --- a/playground/.vscode/settings.json +++ b/playground/.vscode/settings.json @@ -1,4 +1,5 @@ { "eslint.enable": false, - "knip.enabled": false + "knip.enabled": false, + "npmx.versionLens.enabled": true } diff --git a/src/commands/internal/index.ts b/src/commands/internal/index.ts new file mode 100644 index 0000000..d0826ed --- /dev/null +++ b/src/commands/internal/index.ts @@ -0,0 +1,9 @@ +import { internalCommands } from '#state' +import { useTextEditorCommands } from 'reactive-vscode' +import { replaceText } from './replace-text' + +export function useInternalCommands() { + useTextEditorCommands({ + [internalCommands.replaceText]: replaceText, + }) +} diff --git a/src/commands/internal/replace-text.ts b/src/commands/internal/replace-text.ts new file mode 100644 index 0000000..77f9049 --- /dev/null +++ b/src/commands/internal/replace-text.ts @@ -0,0 +1,9 @@ +import type { TextEditorCommandCallback } from 'reactive-vscode' +import type { Range, TextEditor, TextEditorEdit } from 'vscode' + +export const replaceText: TextEditorCommandCallback = (_: TextEditor, edit: TextEditorEdit, range?: Range, text?: string) => { + if (!range || !text) + return + + edit.replace(range, text) +} diff --git a/src/index.ts b/src/index.ts index 5067e36..2293aac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ import { VERSION_TRIGGER_CHARACTERS } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' import { Disposable, languages } from 'vscode' +import { useInternalCommands } from './commands/internal' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' import { extractorEntries } from './extractors' import { commands, displayName, version } from './generated-meta' import { useCodeActions } from './providers/code-actions' +import { useCodeLens } from './providers/code-lens' import { VersionCompletionItemProvider } from './providers/completion-item/version' import { useDiagnostics } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' @@ -40,9 +42,10 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) + useInternalCommands() useDiagnostics() - useCodeActions() + useCodeLens() useCommands({ [commands.openInBrowser]: openInBrowser, diff --git a/src/providers/code-lens/index.ts b/src/providers/code-lens/index.ts new file mode 100644 index 0000000..d1dd00c --- /dev/null +++ b/src/providers/code-lens/index.ts @@ -0,0 +1,18 @@ +import { extractorEntries } from '#extractors' +import { config } from '#state' +import { watchEffect } from 'reactive-vscode' +import { Disposable, languages } from 'vscode' +import { VersionCodeLensProvider } from './version' + +export function useCodeLens() { + watchEffect((onCleanup) => { + if (!config.versionLens.enabled) + return + + const disposables = extractorEntries.map(({ pattern, extractor }) => + languages.registerCodeLensProvider({ pattern }, new VersionCodeLensProvider(extractor)), + ) + + onCleanup(() => Disposable.from(...disposables).dispose()) + }) +} diff --git a/src/providers/code-lens/version.ts b/src/providers/code-lens/version.ts new file mode 100644 index 0000000..22e60f0 --- /dev/null +++ b/src/providers/code-lens/version.ts @@ -0,0 +1,91 @@ +import type { DependencyInfo, Extractor } from '#types/extractor' +import type { CodeLensProvider, TextDocument } from 'vscode' +import { internalCommands } from '#state' +import { getPackageInfo } from '#utils/api/package' +import { resolveExactVersion } from '#utils/package' +import { resolveUpgradeTargetVersion } from '#utils/upgrade' +import { formatUpgradeVersion, isSupportedProtocol, parseVersion } from '#utils/version' +import { debounce } from 'perfect-debounce' +import diff from 'semver/functions/diff' +import { CodeLens, EventEmitter } from 'vscode' + +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 versionRange = this.extractor.getNodeRange(document, dep.versionNode) + const lens = new CodeLens(versionRange) + dataMap.set(lens, dep) + lenses.push(lens) + } + + return lenses + } + + resolveCodeLens(lens: CodeLens) { + const dep = dataMap.get(lens) + if (!dep) + return lens + + const parsed = parseVersion(dep.version) + if (!parsed || !isSupportedProtocol(parsed.protocol)) { + 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 + } + + if (!pkg) { + lens.command = { title: '$(question) unknown', command: '' } + return lens + } + + const exactVersion = resolveExactVersion(pkg, parsed.version) + if (!exactVersion) { + lens.command = { title: '$(question) unknown', command: '' } + return lens + } + + const targetVersion = resolveUpgradeTargetVersion(pkg, exactVersion) + if (!targetVersion) { + lens.command = { title: '$(check) latest', command: '' } + return lens + } + + const newVersion = formatUpgradeVersion(parsed, targetVersion) + const updateType = diff(exactVersion, targetVersion) + lens.command = { + title: updateType + ? `$(arrow-up) ${newVersion} (${updateType})` + : `$(arrow-up) ${newVersion}`, + command: internalCommands.replaceText, + arguments: [lens.range, newVersion], + } + + return lens + } +} diff --git a/src/state.ts b/src/state.ts index 99e6068..3f4c5f0 100644 --- a/src/state.ts +++ b/src/state.ts @@ -5,3 +5,7 @@ import { displayName, scopedConfigs } from './generated-meta' export const config = defineConfig(scopedConfigs.scope) export const logger = defineLogger(displayName) + +export const internalCommands = { + replaceText: 'npmx.internal.replaceText', +} as const From 5d190ad0f4f38b5d72a843956080e153e0a2fa61 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 28 Feb 2026 00:03:10 +0800 Subject: [PATCH 3/4] test: move cases to `resolveUpgradeTargetVersion` --- tests/diagnostics/upgrade.test.ts | 49 ++++++------------------------- tests/utils/upgrade.test.ts | 27 +++++++++++++++++ 2 files changed, 36 insertions(+), 40 deletions(-) create mode 100644 tests/utils/upgrade.test.ts diff --git a/tests/diagnostics/upgrade.test.ts b/tests/diagnostics/upgrade.test.ts index 9a71e7c..3c67108 100644 --- a/tests/diagnostics/upgrade.test.ts +++ b/tests/diagnostics/upgrade.test.ts @@ -20,56 +20,25 @@ function createUpgradeContext(version: string) { } describe('checkUpgrade', () => { - it('should flag when latest is greater than current version', async () => { - const ctx = createUpgradeContext('^1.0.0') - const result = await checkUpgrade(ctx) + it('should create upgrade diagnostic payload', async () => { + const result = await checkUpgrade(createUpgradeContext('^1.0.0')) expect(result).toBeDefined() expect(result!.code).toMatchObject({ value: 'upgrade' }) - expect(result!.message).toContain('2.7.0') + expect(result!.message).toMatchInlineSnapshot('"New version available: ^2.7.0"') }) - it('should not flag when already on latest', async () => { - const ctx = createUpgradeContext('^2.7.0') - const result = await checkUpgrade(ctx) - - expect(result).toBeUndefined() - }) - - it('should not flag when version is a dist tag', async () => { - const ctx = createUpgradeContext('latest') - const result = await checkUpgrade(ctx) - - expect(result).toBeUndefined() - }) - - it('should not flag when version is a dist tag with protocol', async () => { - const ctx = createUpgradeContext('npm:latest') - const result = await checkUpgrade(ctx) - - expect(result).toBeUndefined() - }) - - it('should flag prerelease upgrade within same pre-id', async () => { - const ctx = createUpgradeContext('3.0.0-alpha.1') - const result = await checkUpgrade(ctx) + it('should preserve protocol prefix in diagnostic message', async () => { + const result = await checkUpgrade(createUpgradeContext('npm:^1.0.0')) expect(result).toBeDefined() - expect(result!.message).toContain('3.0.0-alpha.5') + expect(result!.code).toMatchObject({ value: 'upgrade' }) + expect(result!.message).toMatchInlineSnapshot('"New version available: npm:^2.7.0"') }) - it('should not flag prerelease when already on latest pre-id version', async () => { - const ctx = createUpgradeContext('3.0.0-alpha.5') - const result = await checkUpgrade(ctx) + it('should return undefined for unsupported protocol', async () => { + const result = await checkUpgrade(createUpgradeContext('workspace:^1.0.0')) expect(result).toBeUndefined() }) - - it('should preserve protocol prefix in message', async () => { - const ctx = createUpgradeContext('npm:^1.0.0') - const result = await checkUpgrade(ctx) - - expect(result).toBeDefined() - expect(result!.message).toContain('npm:^2.7.0') - }) }) diff --git a/tests/utils/upgrade.test.ts b/tests/utils/upgrade.test.ts new file mode 100644 index 0000000..2b56ce3 --- /dev/null +++ b/tests/utils/upgrade.test.ts @@ -0,0 +1,27 @@ +import type { PackageInfo } from '#utils/api/package' +import { describe, expect, it } from 'vitest' +import { resolveUpgradeTargetVersion } from '../../src/utils/upgrade' + +function createPackageInfo(distTags: Record): PackageInfo { + return { + distTags, + } as PackageInfo +} + +describe('resolveUpgradeTargetVersion', () => { + const pkg = createPackageInfo({ + latest: '2.7.0', + next: '3.0.0-alpha.5', + beta: '3.0.0-beta.3', + }) + + it.each([ + { exactVersion: '1.0.0', expected: '2.7.0' }, + { exactVersion: '2.7.0', expected: undefined }, + { exactVersion: '3.0.0-alpha.1', expected: '3.0.0-alpha.5' }, + { exactVersion: '3.0.0-alpha.5', expected: undefined }, + { exactVersion: '3.0.0-rc.1', expected: undefined }, + ])('should resolve target for $exactVersion to $expected', ({ exactVersion, expected }) => { + expect(resolveUpgradeTargetVersion(pkg, exactVersion)).toBe(expected) + }) +}) From 206418f09e910a859664bc140faadf677db3a705 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 28 Feb 2026 00:44:05 +0800 Subject: [PATCH 4/4] fix: add guard for undefined package info --- src/utils/package.ts | 8 ++++---- src/utils/upgrade.ts | 4 ++-- tests/utils/package.test.ts | 8 +++++++- tests/utils/upgrade.test.ts | 4 ++++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/utils/package.ts b/src/utils/package.ts index 424a8dc..443b33f 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -12,9 +12,9 @@ export function encodePackageName(name: string): string { return encodeURIComponent(name) } -export function resolveExactVersion(pkg: PackageInfo, version: string) { - if (Object.hasOwn(pkg.distTags, version)) - return pkg.distTags[version] +export function resolveExactVersion(pkg: PackageInfo | undefined, version: string) { + if (!pkg?.distTags) + return null - return maxSatisfying(Object.keys(pkg.versionsMeta), version) + return pkg.distTags[version] ?? maxSatisfying(Object.keys(pkg.versionsMeta ?? {}), version) } diff --git a/src/utils/upgrade.ts b/src/utils/upgrade.ts index d3fe73f..8aa8a5a 100644 --- a/src/utils/upgrade.ts +++ b/src/utils/upgrade.ts @@ -7,8 +7,8 @@ import prerelease from 'semver/functions/prerelease' * Resolve the next upgrade target from npm dist-tags based on current exact version. * Mirrors the existing diagnostics upgrade rule behavior so other providers can reuse it. */ -export function resolveUpgradeTargetVersion(pkg: PackageInfo, exactVersion: string): string | undefined { - if (Object.hasOwn(pkg.distTags, exactVersion)) +export function resolveUpgradeTargetVersion(pkg: PackageInfo | undefined, exactVersion: string): string | undefined { + if (!pkg?.distTags || Object.hasOwn(pkg.distTags, exactVersion)) return const { latest } = pkg.distTags diff --git a/tests/utils/package.test.ts b/tests/utils/package.test.ts index 31fc969..f46ff17 100644 --- a/tests/utils/package.test.ts +++ b/tests/utils/package.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { encodePackageName } from '../../src/utils/package' +import { encodePackageName, resolveExactVersion } from '../../src/utils/package' describe('encodePackageName', () => { it('should encode regular package name', () => { @@ -10,3 +10,9 @@ describe('encodePackageName', () => { expect(encodePackageName('@vue/core')).toBe('@vue%2Fcore') }) }) + +describe('resolveExactVersion', () => { + it('should resolve version range without distTags', () => { + expect(resolveExactVersion(undefined, '^1.0.0')).toBeNull() + }) +}) diff --git a/tests/utils/upgrade.test.ts b/tests/utils/upgrade.test.ts index 2b56ce3..98fcb73 100644 --- a/tests/utils/upgrade.test.ts +++ b/tests/utils/upgrade.test.ts @@ -24,4 +24,8 @@ describe('resolveUpgradeTargetVersion', () => { ])('should resolve target for $exactVersion to $expected', ({ exactVersion, expected }) => { expect(resolveUpgradeTargetVersion(pkg, exactVersion)).toBe(expected) }) + + it('should return undefined when distTags are missing', () => { + expect(resolveUpgradeTargetVersion(undefined, '1.0.0')).toBeUndefined() + }) })