Skip to content

Commit 8292d99

Browse files
committed
feat: version lens
1 parent 9383e13 commit 8292d99

10 files changed

Lines changed: 146 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
| `npmx.diagnostics.replacement` | Show suggestions for package replacements | `boolean` | `true` |
4747
| `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` |
4848
| `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` |
49+
| `npmx.versionLens.enabled` | Show version lens (CodeLens) for package dependencies | `boolean` | `false` |
4950

5051
<!-- configs -->
5152

eslint.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ export default defineConfig(
77
},
88
{
99
files: ['src/commands/**'],
10+
ignores: ['**/index.ts'],
1011
rules: {
1112
'no-restricted-imports': ['error', {
1213
paths: [{
1314
name: 'reactive-vscode',
15+
allowImportNames: ['useCommand', 'useCommands', 'useTextEditorCommand', 'useTextEditorCommands'],
16+
allowTypeImports: true,
1417
message: 'Do not use reactive-vscode composables in command handlers. Use vscode API directly.',
1518
}],
1619
}],

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@
9393
"type": "boolean",
9494
"default": true,
9595
"description": "Show warnings when a dependency uses a dist tag"
96+
},
97+
"npmx.versionLens.enabled": {
98+
"type": "boolean",
99+
"default": false,
100+
"description": "Show version lens (CodeLens) for package dependencies"
96101
}
97102
}
98103
},

playground/.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"eslint.enable": false,
3-
"knip.enabled": false
3+
"knip.enabled": false,
4+
"npmx.versionLens.enabled": true
45
}

src/commands/internal/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { internalCommands } from '#state'
2+
import { useTextEditorCommands } from 'reactive-vscode'
3+
import { replaceText } from './replace-text'
4+
5+
export function useInternalCommands() {
6+
useTextEditorCommands({
7+
[internalCommands.replaceText]: replaceText,
8+
})
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { TextEditorCommandCallback } from 'reactive-vscode'
2+
import type { Range, TextEditor, TextEditorEdit } from 'vscode'
3+
4+
export const replaceText: TextEditorCommandCallback = (_: TextEditor, edit: TextEditorEdit, range?: Range, text?: string) => {
5+
if (!range || !text)
6+
return
7+
8+
edit.replace(range, text)
9+
}

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { VERSION_TRIGGER_CHARACTERS } from '#constants'
22
import { defineExtension, useCommands, watchEffect } from 'reactive-vscode'
33
import { Disposable, languages } from 'vscode'
4+
import { useInternalCommands } from './commands/internal'
45
import { openFileInNpmx } from './commands/open-file-in-npmx'
56
import { openInBrowser } from './commands/open-in-browser'
67
import { extractorEntries } from './extractors'
78
import { commands, displayName, version } from './generated-meta'
89
import { useCodeActions } from './providers/code-actions'
10+
import { useCodeLens } from './providers/code-lens'
911
import { VersionCompletionItemProvider } from './providers/completion-item/version'
1012
import { useDiagnostics } from './providers/diagnostics'
1113
import { NpmxHoverProvider } from './providers/hover/npmx'
@@ -40,9 +42,10 @@ export const { activate, deactivate } = defineExtension(() => {
4042
onCleanup(() => Disposable.from(...disposables).dispose())
4143
})
4244

45+
useInternalCommands()
4346
useDiagnostics()
44-
4547
useCodeActions()
48+
useCodeLens()
4649

4750
useCommands({
4851
[commands.openInBrowser]: openInBrowser,

src/providers/code-lens/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { extractorEntries } from '#extractors'
2+
import { config } from '#state'
3+
import { watchEffect } from 'reactive-vscode'
4+
import { Disposable, languages } from 'vscode'
5+
import { VersionCodeLensProvider } from './version'
6+
7+
export function useCodeLens() {
8+
watchEffect((onCleanup) => {
9+
if (!config.versionLens.enabled)
10+
return
11+
12+
const disposables = extractorEntries.map(({ pattern, extractor }) =>
13+
languages.registerCodeLensProvider({ pattern }, new VersionCodeLensProvider(extractor)),
14+
)
15+
16+
onCleanup(() => Disposable.from(...disposables).dispose())
17+
})
18+
}

src/providers/code-lens/version.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { DependencyInfo, Extractor } from '#types/extractor'
2+
import type { CodeLensProvider, TextDocument } from 'vscode'
3+
import { internalCommands } from '#state'
4+
import { getPackageInfo } from '#utils/api/package'
5+
import { resolveExactVersion } from '#utils/package'
6+
import { resolveUpgradeTargetVersion } from '#utils/upgrade'
7+
import { formatUpgradeVersion, isSupportedProtocol, parseVersion } from '#utils/version'
8+
import { debounce } from 'perfect-debounce'
9+
import diff from 'semver/functions/diff'
10+
import { CodeLens, EventEmitter } from 'vscode'
11+
12+
const dataMap = new WeakMap<CodeLens, DependencyInfo>()
13+
14+
export class VersionCodeLensProvider<T extends Extractor> implements CodeLensProvider {
15+
extractor: T
16+
private readonly onDidChangeCodeLensesEmitter = new EventEmitter<void>()
17+
readonly onDidChangeCodeLenses = this.onDidChangeCodeLensesEmitter.event
18+
private readonly scheduleRefresh = debounce(() => {
19+
this.onDidChangeCodeLensesEmitter.fire()
20+
}, 100, { leading: false, trailing: true })
21+
22+
constructor(extractor: T) {
23+
this.extractor = extractor
24+
}
25+
26+
provideCodeLenses(document: TextDocument): CodeLens[] {
27+
const root = this.extractor.parse(document)
28+
if (!root)
29+
return []
30+
31+
const deps = this.extractor.getDependenciesInfo(root)
32+
const lenses: CodeLens[] = []
33+
34+
for (const dep of deps) {
35+
const versionRange = this.extractor.getNodeRange(document, dep.versionNode)
36+
const lens = new CodeLens(versionRange)
37+
dataMap.set(lens, dep)
38+
lenses.push(lens)
39+
}
40+
41+
return lenses
42+
}
43+
44+
resolveCodeLens(lens: CodeLens) {
45+
const dep = dataMap.get(lens)
46+
if (!dep)
47+
return lens
48+
49+
const parsed = parseVersion(dep.version)
50+
if (!parsed || !isSupportedProtocol(parsed.protocol)) {
51+
lens.command = { title: '$(question) unknown', command: '' }
52+
return lens
53+
}
54+
55+
const pkg = getPackageInfo(dep.name)
56+
if (pkg instanceof Promise) {
57+
lens.command = { title: '$(sync~spin) checking...', command: '' }
58+
pkg.finally(() => this.scheduleRefresh())
59+
return lens
60+
}
61+
62+
if (!pkg) {
63+
lens.command = { title: '$(question) unknown', command: '' }
64+
return lens
65+
}
66+
67+
const exactVersion = resolveExactVersion(pkg, parsed.version)
68+
if (!exactVersion) {
69+
lens.command = { title: '$(question) unknown', command: '' }
70+
return lens
71+
}
72+
73+
const targetVersion = resolveUpgradeTargetVersion(pkg, exactVersion)
74+
if (!targetVersion) {
75+
lens.command = { title: '$(check) latest', command: '' }
76+
return lens
77+
}
78+
79+
const newVersion = formatUpgradeVersion(parsed, targetVersion)
80+
const updateType = diff(exactVersion, targetVersion)
81+
lens.command = {
82+
title: updateType
83+
? `$(arrow-up) ${newVersion} (${updateType})`
84+
: `$(arrow-up) ${newVersion}`,
85+
command: internalCommands.replaceText,
86+
arguments: [lens.range, newVersion],
87+
}
88+
89+
return lens
90+
}
91+
}

src/state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ import { displayName, scopedConfigs } from './generated-meta'
55
export const config = defineConfig<NestedScopedConfigs>(scopedConfigs.scope)
66

77
export const logger = defineLogger(displayName)
8+
9+
export const internalCommands = {
10+
replaceText: 'npmx.internal.replaceText',
11+
} as const

0 commit comments

Comments
 (0)