From 0fd3dcd5b49e3e1e756295aec8cbf9097e524adc Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 6 Feb 2026 22:01:03 +0800 Subject: [PATCH 1/4] wip: code-lens version --- README.md | 1 + package.json | 10 ++++ src/index.ts | 27 +++++++++- src/providers/code-lens/version.ts | 82 ++++++++++++++++++++++++++++++ src/utils/semver.ts | 39 ++++++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/providers/code-lens/version.ts create mode 100644 src/utils/semver.ts diff --git a/README.md b/README.md index 9456e4e..ce17d9a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,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 edc16a5..22ad976 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,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" } } }, @@ -91,6 +96,11 @@ "command": "npmx.openInBrowser", "title": "Open npmx.dev in external browser", "category": "npmx" + }, + { + "command": "npmx.updateVersion", + "title": "Update package version", + "category": "npmx" } ] }, diff --git a/src/index.ts b/src/index.ts index 05bf8d2..46a7c6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import type { Range } from 'vscode' import { NPMX_DEV, PACKAGE_JSON_BASENAME, @@ -7,10 +8,11 @@ import { VERSION_TRIGGER_CHARACTERS, } from '#constants' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, env, languages, Uri } from 'vscode' +import { Disposable, env, languages, Uri, workspace, WorkspaceEdit } from 'vscode' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' +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' @@ -60,6 +62,24 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).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, @@ -69,5 +89,10 @@ export const { activate, deactivate } = defineExtension(() => { [commands.openInBrowser]: () => { env.openExternal(Uri.parse(NPMX_DEV)) }, + [commands.updateVersion]: async (uri: Uri, range: Range, newVersion: string) => { + const edit = new WorkspaceEdit() + edit.replace(uri, range, newVersion) + await workspace.applyEdit(edit) + }, }) }) diff --git a/src/providers/code-lens/version.ts b/src/providers/code-lens/version.ts new file mode 100644 index 0000000..74001ef --- /dev/null +++ b/src/providers/code-lens/version.ts @@ -0,0 +1,82 @@ +import type { DependencyInfo, Extractor } from '#types/extractor' +import type { CodeLensProvider, Range, TextDocument } from 'vscode' +import { getPackageInfo } from '#utils/api/package' +import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package' +import { getUpdateType } from '#utils/semver' +import { CodeLens } from 'vscode' +import { commands } from '../../generated-meta' + +const latestCache = new Map() + +interface LensData { + dep: DependencyInfo + versionRange: Range + uri: TextDocument['uri'] +} + +const dataMap = new WeakMap() + +export class VersionCodeLensProvider implements CodeLensProvider { + extractor: T + + constructor(extractor: T) { + this.extractor = extractor + } + + provideCodeLenses(document: TextDocument) { + 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 + } + + async resolveCodeLens(lens: CodeLens) { + const data = dataMap.get(lens) + if (!data) + return lens + + const { dep, versionRange, uri } = data + const parsed = parseVersion(dep.version)! + + let latest = latestCache.get(dep.name) + if (!latest) { + const pkg = await getPackageInfo(dep.name) + if (!pkg?.distTags?.latest) { + lens.command = { title: '$(question) unknown', command: '' } + return lens + } + latest = pkg.distTags.latest + latestCache.set(dep.name, latest) + } + + 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/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' +} From 6b3d27212f6b333d674d74466eb39cb5a1683acc Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Feb 2026 00:41:04 +0800 Subject: [PATCH 2/4] feat: debounce and error handling for package info --- src/providers/code-lens/version.ts | 38 +++++++++++++++++++----------- src/utils/api/package.ts | 6 ++--- src/utils/memoize.ts | 2 +- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/providers/code-lens/version.ts b/src/providers/code-lens/version.ts index 74001ef..8314223 100644 --- a/src/providers/code-lens/version.ts +++ b/src/providers/code-lens/version.ts @@ -3,11 +3,10 @@ import type { CodeLensProvider, Range, TextDocument } from 'vscode' import { getPackageInfo } from '#utils/api/package' import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/package' import { getUpdateType } from '#utils/semver' -import { CodeLens } from 'vscode' +import { debounce } from 'perfect-debounce' +import { CodeLens, EventEmitter } from 'vscode' import { commands } from '../../generated-meta' -const latestCache = new Map() - interface LensData { dep: DependencyInfo versionRange: Range @@ -18,12 +17,17 @@ 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) { + provideCodeLenses(document: TextDocument): CodeLens[] { const root = this.extractor.parse(document) if (!root) return [] @@ -45,23 +49,29 @@ export class VersionCodeLensProvider implements CodeLensPro return lenses } - async resolveCodeLens(lens: CodeLens) { + resolveCodeLens(lens: CodeLens) { const data = dataMap.get(lens) if (!data) return lens const { dep, versionRange, uri } = data - const parsed = parseVersion(dep.version)! + 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: '' } + void pkg.finally(() => this.scheduleRefresh()) + return lens + } - let latest = latestCache.get(dep.name) + const latest = pkg?.distTags.latest if (!latest) { - const pkg = await getPackageInfo(dep.name) - if (!pkg?.distTags?.latest) { - lens.command = { title: '$(question) unknown', command: '' } - return lens - } - latest = pkg.distTags.latest - latestCache.set(dep.name, latest) + lens.command = { title: '$(question) unknown', command: '' } + return lens } const updateType = getUpdateType(parsed.semver, latest) 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 { From 0394e22395265634424e209a018b587457f94f50 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sat, 7 Feb 2026 01:02:47 +0800 Subject: [PATCH 3/4] debounce update --- src/index.ts | 8 +++++--- src/providers/code-lens/version.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 46a7c6f..6eeadb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,9 @@ import { PNPM_WORKSPACE_PATTERN, VERSION_TRIGGER_CHARACTERS, } from '#constants' +import { debounce } from 'perfect-debounce' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, env, languages, Uri, workspace, WorkspaceEdit } from 'vscode' +import { Disposable, env, languages, Uri, commands as vscodeCommands, workspace, WorkspaceEdit } from 'vscode' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' @@ -89,10 +90,11 @@ export const { activate, deactivate } = defineExtension(() => { [commands.openInBrowser]: () => { env.openExternal(Uri.parse(NPMX_DEV)) }, - [commands.updateVersion]: async (uri: Uri, range: Range, newVersion: string) => { + [commands.updateVersion]: debounce(async (uri: Uri, range: Range, newVersion: string) => { const edit = new WorkspaceEdit() edit.replace(uri, range, newVersion) await workspace.applyEdit(edit) - }, + vscodeCommands.executeCommand('editor.action.codeLens.refresh') + }, 300, { leading: true, trailing: false }), }) }) diff --git a/src/providers/code-lens/version.ts b/src/providers/code-lens/version.ts index 8314223..a34e438 100644 --- a/src/providers/code-lens/version.ts +++ b/src/providers/code-lens/version.ts @@ -64,7 +64,7 @@ export class VersionCodeLensProvider implements CodeLensPro const pkg = getPackageInfo(dep.name) if (pkg instanceof Promise) { lens.command = { title: '$(sync~spin) checking...', command: '' } - void pkg.finally(() => this.scheduleRefresh()) + pkg.finally(() => this.scheduleRefresh()) return lens } From 60d84b9a1a213e057b90e03c82da324ed3e433b5 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 10 Feb 2026 17:10:24 +0800 Subject: [PATCH 4/4] refactor: move `updateVersion` to command --- src/commands/update-version.ts | 13 +++++++++++++ src/index.ts | 12 +++--------- src/providers/code-lens/version.ts | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 src/commands/update-version.ts 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 d256b25..00b92db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import type { Range, Uri } from 'vscode' import { PACKAGE_JSON_BASENAME, PACKAGE_JSON_PATTERN, @@ -6,11 +5,11 @@ import { PNPM_WORKSPACE_PATTERN, VERSION_TRIGGER_CHARACTERS, } from '#constants' -import { debounce } from 'perfect-debounce' import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { CodeActionKind, Disposable, languages, commands as vscodeCommands, workspace, WorkspaceEdit } from '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' @@ -105,11 +104,6 @@ export const { activate, deactivate } = defineExtension(() => { useCommands({ [commands.openInBrowser]: openInBrowser, [commands.openFileInNpmx]: openFileInNpmx, - [commands.updateVersion]: debounce(async (uri: Uri, range: Range, newVersion: string) => { - const edit = new WorkspaceEdit() - edit.replace(uri, range, newVersion) - await workspace.applyEdit(edit) - vscodeCommands.executeCommand('editor.action.codeLens.refresh') - }, 300, { leading: true, trailing: false }), + [commands.updateVersion]: updateVersion, }) }) diff --git a/src/providers/code-lens/version.ts b/src/providers/code-lens/version.ts index a34e438..741c075 100644 --- a/src/providers/code-lens/version.ts +++ b/src/providers/code-lens/version.ts @@ -1,8 +1,8 @@ import type { DependencyInfo, Extractor } from '#types/extractor' import type { CodeLensProvider, Range, TextDocument } from 'vscode' import { getPackageInfo } from '#utils/api/package' -import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/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'