Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

<!-- configs -->

Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand All @@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions src/commands/update-version.ts
Original file line number Diff line number Diff line change
@@ -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 })
21 changes: 21 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -84,5 +104,6 @@ export const { activate, deactivate } = defineExtension(() => {
useCommands({
[commands.openInBrowser]: openInBrowser,
[commands.openFileInNpmx]: openFileInNpmx,
[commands.updateVersion]: updateVersion,
})
})
92 changes: 92 additions & 0 deletions src/providers/code-lens/version.ts
Original file line number Diff line number Diff line change
@@ -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<CodeLens, LensData>()

export class VersionCodeLensProvider<T extends Extractor> implements CodeLensProvider {
extractor: T
private readonly onDidChangeCodeLensesEmitter = new EventEmitter<void>()
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
}
}
6 changes: 2 additions & 4 deletions src/utils/api/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,14 @@ export const getPackageInfo = memoize<string, Promise<PackageInfo | null>>(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}`)
Expand Down
2 changes: 1 addition & 1 deletion src/utils/memoize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface MemoizeEntry<V> {
expiresAt?: number
}

type MemoizeReturn<R> = R extends Promise<infer V> ? Promise<V | undefined> : R | undefined
type MemoizeReturn<R> = R extends Promise<infer V> ? Promise<V | undefined> | V | undefined : R | undefined

export function memoize<P, V>(fn: (params: P) => V, options: MemoizeOptions<P> = {}): (params: P) => MemoizeReturn<V> {
const {
Expand Down
39 changes: 39 additions & 0 deletions src/utils/semver.ts
Original file line number Diff line number Diff line change
@@ -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'
}