From ea8f628ec8558be33915c4f4a9073f386240542b Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 3 Apr 2026 13:51:27 +0800 Subject: [PATCH 1/6] feat: implement version lens --- extensions/vscode/README.md | 1 + extensions/vscode/package.json | 5 ++ .../vscode/src/commands/replace-text.ts | 15 ++++ extensions/vscode/src/index.ts | 9 +- packages/language-service/src/index.ts | 2 + .../src/plugins/version-lens.ts | 89 +++++++++++++++++++ packages/shared/src/commands.ts | 1 + 7 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 extensions/vscode/src/commands/replace-text.ts create mode 100644 packages/language-service/src/plugins/version-lens.ts diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index dbcb8e4..de03ad3 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -68,6 +68,7 @@ | `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.diagnostics.engineMismatch` | Show warnings when dependency engines mismatch with the current package | `boolean` | `true` | +| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `false` | | `npmx.packageLinks` | Enable clickable links for package names | `string` | `"declared"` | | `npmx.ignore.upgrade` | Ignore list for upgrade diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` | | `npmx.ignore.deprecation` | Ignore list for deprecation diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` | diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 0c1fb4c..cd5da2e 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -101,6 +101,11 @@ "default": true, "description": "Show warnings when dependency engines mismatch with the current package" }, + "npmx.versionLens.enabled": { + "type": "boolean", + "default": false, + "description": "Show version lens (CodeLens) for package dependencies" + }, "npmx.packageLinks": { "type": "string", "enum": [ diff --git a/extensions/vscode/src/commands/replace-text.ts b/extensions/vscode/src/commands/replace-text.ts new file mode 100644 index 0000000..2c6789c --- /dev/null +++ b/extensions/vscode/src/commands/replace-text.ts @@ -0,0 +1,15 @@ +import type { Range as LspRange } from '@volar/vscode' +import { Position, Range, Uri, workspace, WorkspaceEdit } from 'vscode' + +export async function replaceText(uri: string, range: LspRange, newText: string) { + const edit = new WorkspaceEdit() + edit.replace( + Uri.parse(uri), + new Range( + new Position(range.start.line, range.start.character), + new Position(range.end.line, range.end.character), + ), + newText, + ) + await workspace.applyEdit(edit) +} diff --git a/extensions/vscode/src/index.ts b/extensions/vscode/src/index.ts index d825611..75add70 100644 --- a/extensions/vscode/src/index.ts +++ b/extensions/vscode/src/index.ts @@ -1,12 +1,13 @@ import { createLabsInfo } from '@volar/vscode' -import { ADD_TO_IGNORE_COMMAND } from 'npmx-shared/commands' +import { ADD_TO_IGNORE_COMMAND, REPLACE_TEXT_COMMAND } from 'npmx-shared/commands' import { commands, displayName, version } from 'npmx-shared/meta' -import { defineExtension, useCommand, useCommands } from 'reactive-vscode' +import { defineExtension, useCommands } from 'reactive-vscode' import { Uri } from 'vscode' import { launch } from './client' import { addToIgnore } from './commands/add-to-ignore' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' +import { replaceText } from './commands/replace-text' import { useDecorators } from './providers/decorators' import { logger } from './state' @@ -19,11 +20,11 @@ export const { activate, deactivate } = defineExtension((ctx) => { useDecorators(client) - useCommand(ADD_TO_IGNORE_COMMAND, addToIgnore) - useCommands({ [commands.openInBrowser]: openInBrowser, [commands.openFileInNpmx]: openFileInNpmx, + [ADD_TO_IGNORE_COMMAND]: addToIgnore, + [REPLACE_TEXT_COMMAND]: replaceText, }) logger.info(`${displayName} Activated, v${version}`) diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts index a95dd9c..ce891c7 100644 --- a/packages/language-service/src/index.ts +++ b/packages/language-service/src/index.ts @@ -5,6 +5,7 @@ import { create as createNpmxDiagnosticsService } from './plugins/diagnostics' import { create as createNpmxDocumentLinkService } from './plugins/document-link' import { create as createNpmxHoverService } from './plugins/hover' import { create as createNpmxVersionCompletionService } from './plugins/version-completion' +import { create as createNpmxVersionLensService } from './plugins/version-lens' export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): LanguageServicePlugin[] { return [ @@ -13,5 +14,6 @@ export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): La createNpmxDocumentLinkService(workspace), createNpmxHoverService(workspace), createNpmxVersionCompletionService(workspace), + createNpmxVersionLensService(workspace), ] } diff --git a/packages/language-service/src/plugins/version-lens.ts b/packages/language-service/src/plugins/version-lens.ts new file mode 100644 index 0000000..5029ba1 --- /dev/null +++ b/packages/language-service/src/plugins/version-lens.ts @@ -0,0 +1,89 @@ +import type { CodeLens, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service' +import type { OffsetRange } from 'npmx-language-core/types' +import type { IWorkspaceState } from '../types' +import { isDependencyFile } from 'npmx-language-core/utils' +import { REPLACE_TEXT_COMMAND } from 'npmx-shared/commands' +import diff from 'semver/functions/diff' +import { URI } from 'vscode-uri' +import { getConfig } from '../config' +import { resolveUpgrade } from './diagnostics/rules/upgrade' + +interface LenData { + uri: string + specRange: OffsetRange +} + +export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { + const UNKNOWN_COMMAND: CodeLens['command'] = { title: '$(question) unknown', command: '' } + + return { + name: 'npmx-version-lens', + capabilities: { + codeLensProvider: { + resolveProvider: true, + }, + }, + create(context): LanguageServicePluginInstance { + async function resolveVersionLensCommand({ uri, specRange }: LenData, range: CodeLens['range']): Promise { + const dependencies = await workspaceState.getResolvedDependencies(uri) + const dep = dependencies?.find( + (d) => d.specRange[0] === specRange[0] && d.specRange[1] === specRange[1], + ) + if (!dep) + return UNKNOWN_COMMAND + + const pkg = await dep.packageInfo() + if (!pkg) + return UNKNOWN_COMMAND + + const resolvedVersion = await dep.resolvedVersion() + if (!resolvedVersion) + return UNKNOWN_COMMAND + + const ignoreList = await getConfig(context, 'npmx.ignore.upgrade') + const targetVersion = resolveUpgrade(dep, pkg, resolvedVersion, ignoreList) + if (!targetVersion) + return { title: '$(check) latest', command: '' } + + const updateType = diff(resolvedVersion, pkg.distTags.latest) + return { + title: updateType + ? `$(arrow-up) ${targetVersion} (${updateType})` + : `$(arrow-up) ${targetVersion}`, + command: REPLACE_TEXT_COMMAND, + arguments: [uri, range, targetVersion], + } + } + + return { + async provideCodeLenses(document): Promise { + if (!await getConfig(context, 'npmx.versionLens.enabled')) + return [] + + const uri = URI.parse(document.uri) + if (uri.scheme !== 'file' || !isDependencyFile(uri.path)) + return [] + + const dependencies = await workspaceState.getResolvedDependencies(document.uri) + if (!dependencies) + return [] + + return dependencies + .filter((dep) => dep.resolvedProtocol === 'npm') + .map((dep) => ({ + range: { + start: document.positionAt(dep.specRange[0]), + end: document.positionAt(dep.specRange[1]), + }, + data: { uri: document.uri, specRange: dep.specRange } satisfies LenData, + } satisfies CodeLens)) + }, + + async resolveCodeLens(lens): Promise { + const command = await resolveVersionLensCommand(lens.data as LenData, lens.range) + return { ...lens, command } + }, + } + }, + } +} diff --git a/packages/shared/src/commands.ts b/packages/shared/src/commands.ts index 7fcbad0..c104690 100644 --- a/packages/shared/src/commands.ts +++ b/packages/shared/src/commands.ts @@ -1,3 +1,4 @@ import { displayName } from './meta' export const ADD_TO_IGNORE_COMMAND = `${displayName}.addToIgnore` +export const REPLACE_TEXT_COMMAND = `${displayName}.replaceText` From 0f21aa0f1628bf6de5f3827fad54b26638a13c8c Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 3 Apr 2026 13:53:28 +0800 Subject: [PATCH 2/6] fix: change default value --- extensions/vscode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index cd5da2e..b3ea2fe 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -103,7 +103,7 @@ }, "npmx.versionLens.enabled": { "type": "boolean", - "default": false, + "default": true, "description": "Show version lens (CodeLens) for package dependencies" }, "npmx.packageLinks": { From 1bbf963c4cd6651e95312b44f13e68b05f28b07c Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 3 Apr 2026 14:19:03 +0800 Subject: [PATCH 3/6] support major, minor, patch upgrade --- .../src/plugins/version-lens.ts | 61 ++++++++++++++----- .../src/utils/version.test.ts | 41 ++++++++++++- .../language-service/src/utils/version.ts | 46 ++++++++++++++ 3 files changed, 132 insertions(+), 16 deletions(-) diff --git a/packages/language-service/src/plugins/version-lens.ts b/packages/language-service/src/plugins/version-lens.ts index 5029ba1..07f4dfe 100644 --- a/packages/language-service/src/plugins/version-lens.ts +++ b/packages/language-service/src/plugins/version-lens.ts @@ -1,16 +1,18 @@ import type { CodeLens, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service' import type { OffsetRange } from 'npmx-language-core/types' import type { IWorkspaceState } from '../types' +import type { UpgradeTier } from '../utils/version' import { isDependencyFile } from 'npmx-language-core/utils' import { REPLACE_TEXT_COMMAND } from 'npmx-shared/commands' -import diff from 'semver/functions/diff' import { URI } from 'vscode-uri' import { getConfig } from '../config' +import { formatUpgradeVersion, resolveUpgradeTiers } from '../utils/version' import { resolveUpgrade } from './diagnostics/rules/upgrade' interface LenData { uri: string specRange: OffsetRange + tier?: UpgradeTier } export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { @@ -24,7 +26,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { }, }, create(context): LanguageServicePluginInstance { - async function resolveVersionLensCommand({ uri, specRange }: LenData, range: CodeLens['range']): Promise { + async function resolveVersionLensCommand({ uri, specRange, tier }: LenData, range: CodeLens['range']): Promise { const dependencies = await workspaceState.getResolvedDependencies(uri) const dep = dependencies?.find( (d) => d.specRange[0] === specRange[0] && d.specRange[1] === specRange[1], @@ -40,16 +42,22 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { if (!resolvedVersion) return UNKNOWN_COMMAND + if (tier) { + const formatted = formatUpgradeVersion(dep, tier.version) + return { + title: `$(arrow-up) ${formatted} (${tier.type})`, + command: REPLACE_TEXT_COMMAND, + arguments: [uri, range, formatted], + } + } + const ignoreList = await getConfig(context, 'npmx.ignore.upgrade') const targetVersion = resolveUpgrade(dep, pkg, resolvedVersion, ignoreList) if (!targetVersion) return { title: '$(check) latest', command: '' } - const updateType = diff(resolvedVersion, pkg.distTags.latest) return { - title: updateType - ? `$(arrow-up) ${targetVersion} (${updateType})` - : `$(arrow-up) ${targetVersion}`, + title: `$(arrow-up) ${targetVersion}`, command: REPLACE_TEXT_COMMAND, arguments: [uri, range, targetVersion], } @@ -68,15 +76,38 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { if (!dependencies) return [] - return dependencies - .filter((dep) => dep.resolvedProtocol === 'npm') - .map((dep) => ({ - range: { - start: document.positionAt(dep.specRange[0]), - end: document.positionAt(dep.specRange[1]), - }, - data: { uri: document.uri, specRange: dep.specRange } satisfies LenData, - } satisfies CodeLens)) + const lenses: CodeLens[] = [] + + for (const dep of dependencies) { + if (dep.resolvedProtocol !== 'npm') + continue + + const range = { + start: document.positionAt(dep.specRange[0]), + end: document.positionAt(dep.specRange[1]), + } + const baseData: LenData = { uri: document.uri, specRange: dep.specRange } + + const pkg = await dep.packageInfo() + const resolvedVersion = await dep.resolvedVersion() + + if (pkg && resolvedVersion) { + const tiers = resolveUpgradeTiers(pkg, resolvedVersion) + if (tiers.length > 0) { + for (const tier of tiers) { + lenses.push({ + range, + data: { ...baseData, tier } satisfies LenData, + }) + } + continue + } + } + + lenses.push({ range, data: baseData }) + } + + return lenses }, async resolveCodeLens(lens): Promise { diff --git a/packages/language-service/src/utils/version.test.ts b/packages/language-service/src/utils/version.test.ts index 7c3cfec..81f53bb 100644 --- a/packages/language-service/src/utils/version.test.ts +++ b/packages/language-service/src/utils/version.test.ts @@ -1,6 +1,7 @@ +import type { PackageInfo } from 'npmx-language-core/api/package' import type { DependencyInfo } from 'npmx-language-core/workspace' import { describe, expect, it } from 'vitest' -import { formatUpgradeVersion } from './version' +import { formatUpgradeVersion, resolveUpgradeTiers } from './version' describe('formatUpgradeVersion', () => { it.each([ @@ -23,3 +24,41 @@ describe('formatUpgradeVersion', () => { ).toBe(expected) }) }) + +function createPkg(versions: string[]): PackageInfo { + const versionsMeta: Record = {} + for (const v of versions) + versionsMeta[v] = {} + return { versionsMeta, distTags: { latest: versions.at(-1)! } } as PackageInfo +} + +describe('resolveUpgradeTiers', () => { + it('returns all three tiers', () => { + const pkg = createPkg(['1.0.0', '1.0.1', '1.0.2', '1.1.0', '1.2.0', '2.0.0', '3.0.0']) + expect(resolveUpgradeTiers(pkg, '1.0.0')).toEqual([ + { type: 'patch', version: '1.0.2' }, + { type: 'minor', version: '1.2.0' }, + { type: 'major', version: '3.0.0' }, + ]) + }) + + it('returns only patch and minor when no major upgrade exists', () => { + const pkg = createPkg(['1.0.0', '1.0.3', '1.1.0']) + expect(resolveUpgradeTiers(pkg, '1.0.0')).toEqual([ + { type: 'patch', version: '1.0.3' }, + { type: 'minor', version: '1.1.0' }, + ]) + }) + + it('returns empty when already on latest', () => { + const pkg = createPkg(['1.0.0', '1.0.1']) + expect(resolveUpgradeTiers(pkg, '1.0.1')).toEqual([]) + }) + + it('skips prerelease versions', () => { + const pkg = createPkg(['1.0.0', '1.0.1', '2.0.0-beta.1']) + expect(resolveUpgradeTiers(pkg, '1.0.0')).toEqual([ + { type: 'patch', version: '1.0.1' }, + ]) + }) +}) diff --git a/packages/language-service/src/utils/version.ts b/packages/language-service/src/utils/version.ts index 45d3f84..92bb328 100644 --- a/packages/language-service/src/utils/version.ts +++ b/packages/language-service/src/utils/version.ts @@ -1,5 +1,8 @@ +import type { PackageInfo } from 'npmx-language-core/api/package' import type { DependencyInfo } from 'npmx-language-core/workspace' import { formatPackageId } from 'npmx-language-core/utils' +import SemVer from 'semver/classes/semver' +import gt from 'semver/functions/gt' const RANGE_PREFIXES = ['>=', '<=', '=', '>', '<'] @@ -45,3 +48,46 @@ export function formatUpgradeVersion(dep: DependencyInfo, target: string): strin return `${declaredProtocol}:${formatPackageId(resolvedName, result)}` } + +export type UpgradeType = 'major' | 'minor' | 'patch' + +export interface UpgradeTier { + type: UpgradeType + version: string +} + +export function resolveUpgradeTiers(pkg: PackageInfo, resolvedVersion: string): UpgradeTier[] { + const current = new SemVer(resolvedVersion) + const currentMajor = current.major + const currentMinor = current.minor + + let maxPatch: SemVer | undefined + let maxMinor: SemVer | undefined + let maxMajor: SemVer | undefined + + for (const v of Object.keys(pkg.versionsMeta)) { + const parsed = new SemVer(v, { loose: true }) + if (parsed.prerelease.length > 0 || !gt(parsed, current)) + continue + + if (parsed.major === currentMajor && parsed.minor === currentMinor) { + if (!maxPatch || gt(parsed, maxPatch)) + maxPatch = parsed + } else if (parsed.major === currentMajor) { + if (!maxMinor || gt(parsed, maxMinor)) + maxMinor = parsed + } else { + if (!maxMajor || gt(parsed, maxMajor)) + maxMajor = parsed + } + } + + const tiers: UpgradeTier[] = [] + if (maxPatch) + tiers.push({ type: 'patch', version: maxPatch.version }) + if (maxMinor) + tiers.push({ type: 'minor', version: maxMinor.version }) + if (maxMajor) + tiers.push({ type: 'major', version: maxMajor.version }) + return tiers +} From a8f657edbc135899d469b922686f5282625667f5 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 3 Apr 2026 14:27:32 +0800 Subject: [PATCH 4/6] feat: add hideWhenLatest --- extensions/vscode/README.md | 3 ++- extensions/vscode/package.json | 5 +++++ packages/language-service/src/plugins/version-lens.ts | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index de03ad3..fe61a79 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -68,7 +68,8 @@ | `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.diagnostics.engineMismatch` | Show warnings when dependency engines mismatch with the current package | `boolean` | `true` | -| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `false` | +| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `true` | +| `npmx.versionLens.hideWhenLatest` | Hide version lens when the dependency is already at the latest version | `boolean` | `false` | | `npmx.packageLinks` | Enable clickable links for package names | `string` | `"declared"` | | `npmx.ignore.upgrade` | Ignore list for upgrade diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` | | `npmx.ignore.deprecation` | Ignore list for deprecation diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` | diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index b3ea2fe..2d3f1cb 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -106,6 +106,11 @@ "default": true, "description": "Show version lens (CodeLens) for package dependencies" }, + "npmx.versionLens.hideWhenLatest": { + "type": "boolean", + "default": false, + "description": "Hide version lens when the dependency is already at the latest version" + }, "npmx.packageLinks": { "type": "string", "enum": [ diff --git a/packages/language-service/src/plugins/version-lens.ts b/packages/language-service/src/plugins/version-lens.ts index 07f4dfe..c4e579f 100644 --- a/packages/language-service/src/plugins/version-lens.ts +++ b/packages/language-service/src/plugins/version-lens.ts @@ -104,6 +104,10 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { } } + const hideWhenLatest = await getConfig(context, 'npmx.versionLens.hideWhenLatest') + if (hideWhenLatest) + continue + lenses.push({ range, data: baseData }) } From a859f832b26ba9d22fb2222b9e370248c057f5ca Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 3 Apr 2026 14:29:02 +0800 Subject: [PATCH 5/6] don't check in peerDenpendencies --- packages/language-service/src/plugins/version-lens.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language-service/src/plugins/version-lens.ts b/packages/language-service/src/plugins/version-lens.ts index c4e579f..a0f0ae6 100644 --- a/packages/language-service/src/plugins/version-lens.ts +++ b/packages/language-service/src/plugins/version-lens.ts @@ -79,7 +79,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { const lenses: CodeLens[] = [] for (const dep of dependencies) { - if (dep.resolvedProtocol !== 'npm') + if (dep.resolvedProtocol !== 'npm' || dep.category === 'peerDependencies') continue const range = { From 63508f1b987beeb84e7fd70dec598011f048cba9 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 7 Apr 2026 15:13:46 +0800 Subject: [PATCH 6/6] hoist get config --- packages/language-service/src/plugins/version-lens.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language-service/src/plugins/version-lens.ts b/packages/language-service/src/plugins/version-lens.ts index a0f0ae6..37463b7 100644 --- a/packages/language-service/src/plugins/version-lens.ts +++ b/packages/language-service/src/plugins/version-lens.ts @@ -77,6 +77,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { return [] const lenses: CodeLens[] = [] + const hideWhenLatest = await getConfig(context, 'npmx.versionLens.hideWhenLatest') for (const dep of dependencies) { if (dep.resolvedProtocol !== 'npm' || dep.category === 'peerDependencies') @@ -104,7 +105,6 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { } } - const hideWhenLatest = await getConfig(context, 'npmx.versionLens.hideWhenLatest') if (hideWhenLatest) continue