From 0fd3dcd5b49e3e1e756295aec8cbf9097e524adc Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Fri, 6 Feb 2026 22:01:03 +0800 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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' From 4158268bac2e7e4e15c0b8998044d3bc7306dd86 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Feb 2026 11:13:03 +0800 Subject: [PATCH 5/8] feat: set `npmx.versionLens.enabled` default to false --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7930da2..57d4a8f 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ }, "npmx.versionLens.enabled": { "type": "boolean", - "default": true, + "default": false, "description": "Show version lens (CodeLens) for package dependencies" } } From 916678f8088ba748f4797419898967f3fa05942f Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Feb 2026 11:53:23 +0800 Subject: [PATCH 6/8] refactor: migrate `updateVersion` to internal `replaceText` command --- README.md | 2 +- eslint.config.ts | 3 +++ package.json | 5 ----- src/commands/internal/index.ts | 9 +++++++++ src/commands/internal/replace-text.ts | 9 +++++++++ src/commands/update-version.ts | 13 ------------- src/index.ts | 4 ++-- src/providers/code-lens/version.ts | 23 ++++++++--------------- src/state.ts | 4 ++++ 9 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 src/commands/internal/index.ts create mode 100644 src/commands/internal/replace-text.ts delete mode 100644 src/commands/update-version.ts diff --git a/README.md b/README.md index 473307d..9cbca0f 100644 --- a/README.md +++ b/README.md @@ -46,7 +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` | `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 57d4a8f..9411817 100644 --- a/package.json +++ b/package.json @@ -111,11 +111,6 @@ "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/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/commands/update-version.ts b/src/commands/update-version.ts deleted file mode 100644 index 27e724d..0000000 --- a/src/commands/update-version.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 a7910da..2293aac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ 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 { updateVersion } from './commands/update-version' import { extractorEntries } from './extractors' import { commands, displayName, version } from './generated-meta' import { useCodeActions } from './providers/code-actions' @@ -42,6 +42,7 @@ export const { activate, deactivate } = defineExtension(() => { onCleanup(() => Disposable.from(...disposables).dispose()) }) + useInternalCommands() useDiagnostics() useCodeActions() useCodeLens() @@ -49,6 +50,5 @@ 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 index 741c075..3cfa5b8 100644 --- a/src/providers/code-lens/version.ts +++ b/src/providers/code-lens/version.ts @@ -1,19 +1,13 @@ import type { DependencyInfo, Extractor } from '#types/extractor' -import type { CodeLensProvider, Range, TextDocument } from 'vscode' +import type { CodeLensProvider, TextDocument } from 'vscode' +import { internalCommands } from '#state' 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() +const dataMap = new WeakMap() export class VersionCodeLensProvider implements CodeLensProvider { extractor: T @@ -42,7 +36,7 @@ export class VersionCodeLensProvider implements CodeLensPro const versionRange = this.extractor.getNodeRange(document, dep.versionNode) const lens = new CodeLens(versionRange) - dataMap.set(lens, { dep, versionRange, uri: document.uri }) + dataMap.set(lens, dep) lenses.push(lens) } @@ -50,11 +44,10 @@ export class VersionCodeLensProvider implements CodeLensPro } resolveCodeLens(lens: CodeLens) { - const data = dataMap.get(lens) - if (!data) + const dep = dataMap.get(lens) + if (!dep) return lens - const { dep, versionRange, uri } = data const parsed = parseVersion(dep.version) if (!parsed) { lens.command = { title: '$(question) unknown', command: '' } @@ -82,8 +75,8 @@ export class VersionCodeLensProvider implements CodeLensPro const newVersion = formatVersion({ ...parsed, semver: latest }) lens.command = { title: `$(arrow-up) ${newVersion} (${updateType})`, - command: commands.updateVersion, - arguments: [uri, versionRange, newVersion], + command: internalCommands.replaceText, + arguments: [lens.range, newVersion], } } diff --git a/src/state.ts b/src/state.ts index 99e6068..499d6d4 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', +} From 9fb3ea14505c9b5cbfb64dbf4a88fa50abacf14f Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Feb 2026 14:36:54 +0800 Subject: [PATCH 7/8] feat: follow up semver v2.0.0-rc.2 --- src/providers/code-lens/version.ts | 3 +- src/utils/semver.ts | 39 ----------------- src/utils/version.ts | 67 +++++++++++++++++++++++++++--- tests/utils/version.test.ts | 42 ++++++++++++++++++- 4 files changed, 103 insertions(+), 48 deletions(-) delete mode 100644 src/utils/semver.ts diff --git a/src/providers/code-lens/version.ts b/src/providers/code-lens/version.ts index 3cfa5b8..1af22b7 100644 --- a/src/providers/code-lens/version.ts +++ b/src/providers/code-lens/version.ts @@ -2,8 +2,7 @@ import type { DependencyInfo, Extractor } from '#types/extractor' import type { CodeLensProvider, TextDocument } from 'vscode' import { internalCommands } from '#state' import { getPackageInfo } from '#utils/api/package' -import { getUpdateType } from '#utils/semver' -import { formatVersion, isSupportedProtocol, parseVersion } from '#utils/version' +import { formatVersion, getUpdateType, isSupportedProtocol, parseVersion } from '#utils/version' import { debounce } from 'perfect-debounce' import { CodeLens, EventEmitter } from 'vscode' diff --git a/src/utils/semver.ts b/src/utils/semver.ts deleted file mode 100644 index c4b6c27..0000000 --- a/src/utils/semver.ts +++ /dev/null @@ -1,39 +0,0 @@ -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' -} diff --git a/src/utils/version.ts b/src/utils/version.ts index 6b0a5b0..ca37801 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -49,7 +49,52 @@ export function parseVersion(rawVersion: string): ParsedVersion | null { return { protocol, prefix, semver } } +/** related to: https://semver.org/spec/v2.0.0-rc.2.html */ +const CORE_VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)/ +type CoreVersion = [major: number, minor: number, patch: number] + +function stripBuildMetadata(version: string): string { + const idx = version.indexOf('+') + return idx === -1 ? version : version.slice(0, idx) +} + +function parseCoreVersion(version: string): CoreVersion | null { + const match = version.match(CORE_VERSION_PATTERN) + 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 { + current = stripBuildMetadata(current) + latest = stripBuildMetadata(latest) + + const cur = parseCoreVersion(current) + const lat = parseCoreVersion(latest) + + if (!cur || !lat) + return 'none' + + if (lat[0] !== cur[0]) + return lat[0] > cur[0] ? 'major' : 'none' + + if (lat[1] !== cur[1]) + return lat[1] > cur[1] ? 'minor' : 'none' + + if (lat[2] !== cur[2]) + return lat[2] > cur[2] ? 'patch' : 'none' + + if (current !== latest && current.includes('-') && !latest.includes('-')) + return 'prerelease' + + return 'none' +} + export function getPrereleaseId(version: string): string | null { + version = stripBuildMetadata(version) const idx = version.indexOf('-') if (idx === -1) return null @@ -75,8 +120,14 @@ function comparePrereleasePrecedence(a: string, b: string): number { const numA = Number(partsA[i]) const numB = Number(partsB[i]) - if (!Number.isNaN(numA) && !Number.isNaN(numB)) { - return numA - numB + const isNumA = !Number.isNaN(numA) + const isNumB = !Number.isNaN(numB) + + if (isNumA && isNumB) { + if (numA !== numB) + return numA - numB + } else if (isNumA !== isNumB) { + return isNumA ? -1 : 1 } else if (partsA[i] !== partsB[i]) { return partsA[i] < partsB[i] ? -1 : 1 } @@ -86,10 +137,14 @@ function comparePrereleasePrecedence(a: string, b: string): number { } export function lt(a: string, b: string): boolean { - const [coreA, preA] = a.split('-', 2) - const [coreB, preB] = b.split('-', 2) - const partsA = coreA.split('.').map(Number) - const partsB = coreB.split('.').map(Number) + a = stripBuildMetadata(a) + b = stripBuildMetadata(b) + + const [coreA, preA] = a.split('-') + const [coreB, preB] = b.split('-') + + const partsA = parseCoreVersion(coreA)! + const partsB = parseCoreVersion(coreB)! for (let i = 0; i < 3; i++) { const diff = (partsA[i] || 0) - (partsB[i] || 0) if (diff !== 0) diff --git a/tests/utils/version.test.ts b/tests/utils/version.test.ts index 0522063..ac21be8 100644 --- a/tests/utils/version.test.ts +++ b/tests/utils/version.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getPrereleaseId, lt, parseVersion } from '../../src/utils/version' +import { getPrereleaseId, getUpdateType, lt, parseVersion } from '../../src/utils/version' describe('parseVersion', () => { it('should parse plain version', () => { @@ -85,6 +85,11 @@ describe('getPrereleaseId', () => { it('should handle prerelease without dots', () => { expect(getPrereleaseId('1.0.0-canary')).toBe('canary') }) + + it('should ignore build metadata', () => { + expect(getPrereleaseId('1.0.0-beta.1+build')).toBe('beta') + expect(getPrereleaseId('1.0.0+build')).toBeNull() + }) }) describe('lt', () => { @@ -126,4 +131,39 @@ describe('lt', () => { expect(lt('1.0.0-beta', '1.0.0-beta.1')).toBe(true) expect(lt('1.0.0-beta.1', '1.0.0-beta')).toBe(false) }) + + it('should ignore build metadata for precedence', () => { + expect(lt('1.0.0+build1', '1.0.0+build2')).toBe(false) + expect(lt('1.0.0-alpha+001', '1.0.0')).toBe(true) + expect(lt('1.0.0-alpha+001', '1.0.0-alpha.1')).toBe(true) + }) + + it('should rank numeric identifiers lower than non-numeric', () => { + expect(lt('1.0.0-1', '1.0.0-alpha')).toBe(true) + expect(lt('1.0.0-alpha', '1.0.0-1')).toBe(false) + }) + + it('should follow rc.2 spec precedence example', () => { + expect(lt('1.0.0-alpha', '1.0.0-alpha.1')).toBe(true) + expect(lt('1.0.0-alpha.1', '1.0.0-beta.2')).toBe(true) + expect(lt('1.0.0-beta.2', '1.0.0-beta.11')).toBe(true) + expect(lt('1.0.0-beta.11', '1.0.0-rc.1')).toBe(true) + expect(lt('1.0.0-rc.1', '1.0.0')).toBe(true) + }) + + it('should handle prerelease identifiers containing hyphens', () => { + expect(lt('1.0.0-alpha-beta', '1.0.0-alpha-gamma')).toBe(true) + expect(lt('1.0.0-alpha-gamma', '1.0.0-alpha-beta')).toBe(false) + }) +}) + +describe('getUpdateType', () => { + it('should ignore build metadata', () => { + expect(getUpdateType('1.0.0+build', '2.0.0+build')).toBe('major') + expect(getUpdateType('1.0.0+build1', '1.0.0+build2')).toBe('none') + }) + + it('should detect prerelease with build metadata', () => { + expect(getUpdateType('1.0.0-alpha+001', '1.0.0')).toBe('prerelease') + }) }) From c235f90a17029cb0935a0f82b76563fe942b7268 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Feb 2026 14:45:17 +0800 Subject: [PATCH 8/8] chore: fix ci --- src/utils/version.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/utils/version.ts b/src/utils/version.ts index ca37801..2dddcda 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -136,12 +136,23 @@ function comparePrereleasePrecedence(a: string, b: string): number { return 0 } +function parseSemver(v: string) { + const hyphen = v.indexOf('-') + const core = hyphen === -1 ? v : v.slice(0, hyphen) + const pre = hyphen === -1 ? undefined : v.slice(hyphen + 1) + + return { + core, + pre, + } +} + export function lt(a: string, b: string): boolean { a = stripBuildMetadata(a) b = stripBuildMetadata(b) - const [coreA, preA] = a.split('-') - const [coreB, preB] = b.split('-') + const { core: coreA, pre: preA } = parseSemver(a) + const { core: coreB, pre: preB } = parseSemver(b) const partsA = parseCoreVersion(coreA)! const partsB = parseCoreVersion(coreB)!