diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index dbcb8e4..fe61a79 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -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` | `[]` | diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 0c1fb4c..2d3f1cb 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -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": [ 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..37463b7 --- /dev/null +++ b/packages/language-service/src/plugins/version-lens.ts @@ -0,0 +1,124 @@ +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 { 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 { + 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, 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], + ) + 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 + + 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: '' } + + return { + title: `$(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 [] + + const lenses: CodeLens[] = [] + const hideWhenLatest = await getConfig(context, 'npmx.versionLens.hideWhenLatest') + + for (const dep of dependencies) { + if (dep.resolvedProtocol !== 'npm' || dep.category === 'peerDependencies') + 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 + } + } + + if (hideWhenLatest) + continue + + lenses.push({ range, data: baseData }) + } + + return lenses + }, + + async resolveCodeLens(lens): Promise { + const command = await resolveVersionLensCommand(lens.data as LenData, lens.range) + return { ...lens, command } + }, + } + }, + } +} 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 +} 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`