Skip to content
Open
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
2 changes: 2 additions & 0 deletions extensions/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +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` | `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` | `[]` |
Expand Down
10 changes: 10 additions & 0 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@
"default": true,
"description": "Show warnings when dependency engines mismatch with the current package"
},
"npmx.versionLens.enabled": {
"type": "boolean",
"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": [
Expand Down
15 changes: 15 additions & 0 deletions extensions/vscode/src/commands/replace-text.ts
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 5 additions & 4 deletions extensions/vscode/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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}`)
Expand Down
2 changes: 2 additions & 0 deletions packages/language-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -13,5 +14,6 @@ export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): La
createNpmxDocumentLinkService(workspace),
createNpmxHoverService(workspace),
createNpmxVersionCompletionService(workspace),
createNpmxVersionLensService(workspace),
]
}
74 changes: 74 additions & 0 deletions packages/language-service/src/plugins/version-lens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { CodeLens, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service'
import type { IWorkspaceState } from '../types'
import { checkIgnored, isDependencyFile } from 'npmx-language-core/utils'
import { REPLACE_TEXT_COMMAND } from 'npmx-shared/commands'
import { URI } from 'vscode-uri'
import { getConfig } from '../config'
import { formatUpgradeVersion, resolveUpgradeTiers } from '../utils/version'

export function create(workspaceState: IWorkspaceState): LanguageServicePlugin {
return {
name: 'npmx-version-lens',
capabilities: {
codeLensProvider: {},
},
create(context): LanguageServicePluginInstance {
return {
async provideCodeLenses(document): Promise<CodeLens[]> {
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 []

const lenses: CodeLens[] = []
const [hideWhenLatest, ignoreList] = await Promise.all([
getConfig(context, 'npmx.versionLens.hideWhenLatest'),
getConfig(context, 'npmx.ignore.upgrade'),
])

for (const dep of dependencies) {
if (dep.resolvedProtocol !== 'npm' || dep.category === 'peerDependencies')
continue

const [pkg, resolvedVersion] = await Promise.all([dep.packageInfo(), dep.resolvedVersion()])
if (!pkg || !resolvedVersion)
continue

const range = {
start: document.positionAt(dep.specRange[0]),
end: document.positionAt(dep.specRange[1]),
}

const tiers = resolveUpgradeTiers(pkg, resolvedVersion)
.map(({ type, version }) => ({ type, formatted: formatUpgradeVersion(dep, version) }))
.filter(({ formatted }) => !checkIgnored({ ignoreList, name: dep.resolvedName, version: formatted }))

if (tiers.length === 0 && !hideWhenLatest) {
lenses.push({ range, command: { title: '$(check) latest', command: '' } })
continue
}

for (const { type, formatted } of tiers) {
lenses.push({
range,
command: {
title: `$(arrow-up) ${formatted} (${type})`,
command: REPLACE_TEXT_COMMAND,
arguments: [document.uri, range, formatted],
},
})
}
}

return lenses
},
}
},
}
}
41 changes: 40 additions & 1 deletion packages/language-service/src/utils/version.test.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -23,3 +24,41 @@ describe('formatUpgradeVersion', () => {
).toBe(expected)
})
})

function createPkg(versions: string[]): PackageInfo {
const versionsMeta: Record<string, object> = {}
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' },
])
})
})
50 changes: 50 additions & 0 deletions packages/language-service/src/utils/version.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { PackageInfo } from 'npmx-language-core/api/package'
import type { DependencyInfo } from 'npmx-language-core/workspace'
import type SemVer from 'semver/classes/semver'
import { formatPackageId } from 'npmx-language-core/utils'
import gt from 'semver/functions/gt'
import parse from 'semver/functions/parse'

const RANGE_PREFIXES = ['>=', '<=', '=', '>', '<']

Expand Down Expand Up @@ -45,3 +49,49 @@ 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 = parse(resolvedVersion)
if (!current)
return []

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 = parse(v, { loose: true })
if (!parsed || 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
}
1 change: 1 addition & 0 deletions packages/shared/src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { displayName } from './meta'

export const ADD_TO_IGNORE_COMMAND = `${displayName}.addToIgnore`
export const REPLACE_TEXT_COMMAND = `${displayName}.replaceText`
Loading