From 0e9c9fef228d6bb81d480b4c281454b7b665524d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:00:51 +0800 Subject: [PATCH 1/2] feat(catalog): make resolved version display configurable --- extensions/vscode/README.md | 3 + extensions/vscode/package.json | 14 +++ extensions/vscode/src/client.ts | 4 +- .../src/providers/catalog-display.test.ts | 45 ++++++++++ .../vscode/src/providers/catalog-display.ts | 15 ++++ extensions/vscode/src/providers/decorators.ts | 88 +++++++++++-------- packages/language-service/src/config.ts | 4 + .../src/plugins/catalog.test.ts | 25 +++++- .../language-service/src/plugins/catalog.ts | 46 ++++++---- 9 files changed, 186 insertions(+), 58 deletions(-) create mode 100644 extensions/vscode/src/providers/catalog-display.test.ts create mode 100644 extensions/vscode/src/providers/catalog-display.ts diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 0449ed25..8b95920d 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -59,6 +59,7 @@ | Key | Description | Type | Default | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------- | +| `npmx.catalog.display` | Display style for resolved catalog versions | `string` | `"decoration"` | | `npmx.hover.enabled` | Enable hover information for packages | `boolean` | `true` | | `npmx.completion.version` | Version completion behavior | `string` | `"provenance-only"` | | `npmx.completion.excludePrerelease` | Exclude prerelease versions (alpha, beta, rc, canary, etc.) from completion suggestions | `boolean` | `true` | @@ -76,6 +77,8 @@ +Set `npmx.catalog.display` to `"decoration"` (default) for resolved catalog versions at the end of the line, or `"inlay-hint"` to show them next to the catalog reference. Changes take effect without reloading the window. Inlay hints also require `editor.inlayHints.enabled` to be enabled. + ## Ignore Diagnostics `npmx` supports ignore lists for selected diagnostics. diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 884aae71..2a8b36b9 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -46,6 +46,20 @@ "configuration": { "title": "npmx", "properties": { + "npmx.catalog.display": { + "type": "string", + "enum": [ + "decoration", + "inlay-hint" + ], + "enumDescriptions": [ + "Show resolved catalog versions at the end of the line", + "Show resolved catalog versions next to the catalog reference using inlay hints" + ], + "default": "decoration", + "description": "Display style for resolved catalog versions", + "markdownDescription": "Display style for resolved catalog versions. Inlay hints also require `#editor.inlayHints.enabled#` to be enabled." + }, "npmx.hover.enabled": { "type": "boolean", "default": true, diff --git a/extensions/vscode/src/client.ts b/extensions/vscode/src/client.ts index b9e03279..77eaabc5 100644 --- a/extensions/vscode/src/client.ts +++ b/extensions/vscode/src/client.ts @@ -4,6 +4,7 @@ import { LanguageClient, TransportKind } from '@volar/vscode/node' import { DEPENDENCY_FILE_GLOB } from 'npmx-language-core/constants' import { displayName, extensionId } from 'npmx-shared/meta' import { Hover, MarkdownString } from 'vscode' +import { provideCatalogInlayHints } from './providers/catalog-display' const SUPPORTED_LANGUAGES = [ 'javascript', @@ -39,6 +40,7 @@ export function launch(serverPath: string) { { middleware: { ...middleware, + provideInlayHints: provideCatalogInlayHints, provideHover: async (document, position, token, next) => { const hover = await next(document, position, token) if (!hover) @@ -70,7 +72,7 @@ export function launch(serverPath: string) { initializationOptions: { npmx: { clientFeatures: { - catalogInlayHints: false, + catalogInlayHints: true, iconStyle: 'codicon', }, }, diff --git a/extensions/vscode/src/providers/catalog-display.test.ts b/extensions/vscode/src/providers/catalog-display.test.ts new file mode 100644 index 00000000..297c95c8 --- /dev/null +++ b/extensions/vscode/src/providers/catalog-display.test.ts @@ -0,0 +1,45 @@ +import type { CancellationToken, InlayHint } from 'vscode' +import { createTextDocument } from 'jest-mock-vscode' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Position, Range, Uri, workspace } from 'vscode' +import { provideCatalogInlayHints } from './catalog-display' + +describe('catalog display', () => { + const document = createTextDocument(Uri.file('/workspace/package.json'), '{"dependencies":{"vue":"catalog:"}}', 'json') + const range = new Range(0, 0, 0, document.getText().length) + const token: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: vi.fn(() => ({ dispose: vi.fn() })), + } + const hints: InlayHint[] = [{ position: new Position(0, 32), label: '^3.5.0' }] + + afterEach(async () => { + await workspace.getConfiguration().update('npmx.catalog.display', undefined) + }) + + it('keeps hints hidden by default and after switching back to decorations', async () => { + const next = vi.fn(() => hints) + expect(await provideCatalogInlayHints(document, range, token, next)).toEqual([]) + expect(next).not.toHaveBeenCalled() + + await workspace.getConfiguration().update('npmx.catalog.display', 'inlay-hint') + expect(await provideCatalogInlayHints(document, range, token, next)).toEqual(hints) + expect(next).toHaveBeenCalledWith(document, range, token) + + next.mockClear() + await workspace.getConfiguration().update('npmx.catalog.display', 'decoration') + expect(await provideCatalogInlayHints(document, range, token, next)).toEqual([]) + expect(next).not.toHaveBeenCalled() + }) + + it('discards hints if the display mode changes while a request is pending', async () => { + await workspace.getConfiguration().update('npmx.catalog.display', 'inlay-hint') + const pending = Promise.withResolvers() + const result = provideCatalogInlayHints(document, range, token, () => pending.promise) + + await workspace.getConfiguration().update('npmx.catalog.display', 'decoration') + pending.resolve(hints) + + expect(await result).toEqual([]) + }) +}) diff --git a/extensions/vscode/src/providers/catalog-display.ts b/extensions/vscode/src/providers/catalog-display.ts new file mode 100644 index 00000000..05fb9b4c --- /dev/null +++ b/extensions/vscode/src/providers/catalog-display.ts @@ -0,0 +1,15 @@ +import type { Middleware } from '@volar/vscode' +import { configs } from 'npmx-shared/meta' +import { workspace } from 'vscode' + +export function usesCatalogInlayHints(): boolean { + return workspace.getConfiguration().get(configs.catalogDisplay.key, configs.catalogDisplay.default) === 'inlay-hint' +} + +export const provideCatalogInlayHints: NonNullable = async (document, range, token, next) => { + if (!usesCatalogInlayHints()) + return [] + + const hints = await next(document, range, token) + return usesCatalogInlayHints() ? hints : [] +} diff --git a/extensions/vscode/src/providers/decorators.ts b/extensions/vscode/src/providers/decorators.ts index d3f28871..a03aa41a 100644 --- a/extensions/vscode/src/providers/decorators.ts +++ b/extensions/vscode/src/providers/decorators.ts @@ -1,52 +1,64 @@ import type { BaseLanguageClient } from '@volar/vscode' -import type { DecorationOptions } from 'vscode' +import type { DecorationOptions, TextEditor } from 'vscode' import { isPackageManifest } from 'npmx-language-core/utils' -import { useActiveTextEditor, useEditorDecorations, watch } from 'reactive-vscode' -import { Range } from 'vscode' +import { configs } from 'npmx-shared/meta' +import { useActiveTextEditor, useDisposable, useEditorDecorations, useEvent, watch } from 'reactive-vscode' +import { Range, window, workspace } from 'vscode' import { logger } from '#state' import { offsetRangeToRange } from '#utils/ast' import { getResolvedDependencies } from '#utils/request' +import { usesCatalogInlayHints } from './catalog-display' export function useDecorators(client: BaseLanguageClient) { const activeEditor = useActiveTextEditor() - const { update } = useEditorDecorations( - activeEditor, - { - after: { color: 'rgba(136, 136, 136, 0.63)' }, - }, - async (editor) => { - const document = editor.document - if (!isPackageManifest(document.uri.path)) - return [] - logger.info(`[decorators] updating ${document.uri.path}`) - - const dependencies = await getResolvedDependencies(client, document.uri) - if (!dependencies) - return [] - - const result: DecorationOptions[] = [] - - for (const dep of dependencies) { - if (dep.protocol !== 'catalog') - continue - - const range = offsetRangeToRange(document, dep.specRange) - const line = range.end.line - const len = document.lineAt(line).text.length - result.push({ - range: new Range(line, 0, line, len), - renderOptions: { - after: { - contentText: `\t\t ${dep.resolvedSpec}`, - }, + const decorationType = useDisposable(window.createTextEditorDecorationType({ + after: { color: 'rgba(136, 136, 136, 0.63)' }, + })) + const { update } = useEditorDecorations(activeEditor, decorationType, getDecorations) + + async function getDecorations(editor: TextEditor): Promise { + if (usesCatalogInlayHints()) + return [] + + const document = editor.document + if (!isPackageManifest(document.uri.path)) + return [] + logger.info(`[decorators] updating ${document.uri.path}`) + + const dependencies = await getResolvedDependencies(client, document.uri) + if (!dependencies || usesCatalogInlayHints()) + return [] + + const result: DecorationOptions[] = [] + + for (const dep of dependencies) { + if (dep.protocol !== 'catalog') + continue + + const range = offsetRangeToRange(document, dep.specRange) + const line = range.end.line + const len = document.lineAt(line).text.length + result.push({ + range: new Range(line, 0, line, len), + renderOptions: { + after: { + contentText: `\t\t ${dep.resolvedSpec}`, }, - }) - } + }, + }) + } - return result - }, - ) + return result + } watch(activeEditor, update) + useEvent(workspace.onDidChangeConfiguration, [async (event) => { + if (!event.affectsConfiguration(configs.catalogDisplay.key)) + return + + await Promise.all(window.visibleTextEditors.map(async (editor) => { + editor.setDecorations(decorationType, await getDecorations(editor)) + })) + }]) } diff --git a/packages/language-service/src/config.ts b/packages/language-service/src/config.ts index 328b0e9a..189ba527 100644 --- a/packages/language-service/src/config.ts +++ b/packages/language-service/src/config.ts @@ -29,6 +29,10 @@ function stringArrayConfig(value: unknown): string[] | undefined { } const configSpecs = { + 'npmx.catalog.display': { + scopedKey: 'catalog.display', + validate: (value) => value === 'decoration' || value === 'inlay-hint' ? value : undefined, + }, 'npmx.hover.enabled': { scopedKey: 'hover.enabled', validate: booleanConfig, diff --git a/packages/language-service/src/plugins/catalog.test.ts b/packages/language-service/src/plugins/catalog.test.ts index 7247efb1..3d66222a 100644 --- a/packages/language-service/src/plugins/catalog.test.ts +++ b/packages/language-service/src/plugins/catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' +import { TextDocument } from 'vscode-languageserver-textdocument' import { createDependencyInfo } from '../test-utils/dependency' -import { getCatalogDependencyAtOffset } from './catalog' +import { getCatalogDependencyAtOffset, getCatalogInlayHints } from './catalog' describe('getCatalogDependencyAtOffset', () => { const dependency = createDependencyInfo({ @@ -27,3 +28,25 @@ describe('getCatalogDependencyAtOffset', () => { expect(getCatalogDependencyAtOffset([npmDependency], 20)).toBeUndefined() }) }) + +describe('getCatalogInlayHints', () => { + it.each(['catalog:', 'catalog:dev'])('places the %s hint outside the quoted spec', (rawSpec) => { + const text = `{"dependencies":{"lodash":"${rawSpec}","vue":"^3.0.0"}}` + const document = TextDocument.create('file:///repo/package.json', 'json', 0, text) + const specStart = text.indexOf(rawSpec) + const specEnd = specStart + rawSpec.length + const range = { start: document.positionAt(0), end: document.positionAt(text.length) } + const dependencies = [createDependencyInfo({ + rawSpec, + protocol: 'catalog', + resolvedSpec: '^4.0.0', + specRange: [specStart, specEnd], + })] + + expect(getCatalogInlayHints(document, range, dependencies)).toEqual([{ + position: document.positionAt(text.indexOf(',"vue"')), + label: '^4.0.0', + paddingLeft: true, + }]) + }) +}) diff --git a/packages/language-service/src/plugins/catalog.ts b/packages/language-service/src/plugins/catalog.ts index 2c1bf9e6..c01661ff 100644 --- a/packages/language-service/src/plugins/catalog.ts +++ b/packages/language-service/src/plugins/catalog.ts @@ -1,5 +1,6 @@ -import type { CompletionItemKind, CompletionList, LanguageServicePlugin, LanguageServicePluginInstance, LocationLink } from '@volar/language-service' +import type { CompletionItemKind, CompletionList, InlayHint, LanguageServicePlugin, LanguageServicePluginInstance, LocationLink, Range } from '@volar/language-service' import type { DependencyInfo } from 'npmx-language-core/workspace' +import type { TextDocument } from 'vscode-languageserver-textdocument' import type { IWorkspaceState } from '../types' import { isPackageManifest } from 'npmx-language-core/utils' import { URI } from 'vscode-uri' @@ -16,6 +17,31 @@ export function getCatalogDependencyAtOffset( return dependency } +export function getCatalogInlayHints( + document: TextDocument, + range: Range, + dependencies: DependencyInfo[], +): InlayHint[] { + const startOffset = document.offsetAt(range.start) + const endOffset = document.offsetAt(range.end) + + return dependencies.flatMap((dependency) => { + if (dependency.protocol !== 'catalog') + return [] + + const [specStart, specEnd] = dependency.specRange + if (specEnd < startOffset || specStart > endOffset) + return [] + + return [{ + // JSON dependency ranges exclude the surrounding quotes. + position: document.positionAt(specEnd + 1), + label: dependency.resolvedSpec, + paddingLeft: true, + }] + }) +} + export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { function getDependencyFileUri(documentUri: string): URI | undefined { const uri = URI.parse(documentUri) @@ -127,23 +153,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { if (!dependencies) return - const startOffset = document.offsetAt(range.start) - const endOffset = document.offsetAt(range.end) - - return dependencies.flatMap((dependency) => { - if (dependency.protocol !== 'catalog') - return [] - - const [specStart, specEnd] = dependency.specRange - if (specEnd < startOffset || specStart > endOffset) - return [] - - return [{ - position: document.positionAt(specEnd), - label: dependency.resolvedSpec, - paddingLeft: true, - }] - }) + return getCatalogInlayHints(document, range, dependencies) }, } }, From 2217edb492a9c924b37a39ce7de54575517aebd1 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:11:39 +0800 Subject: [PATCH 2/2] fix(catalog): preserve existing inlay hint position --- .../src/plugins/catalog.test.ts | 25 +--------- .../language-service/src/plugins/catalog.ts | 46 ++++++++----------- 2 files changed, 19 insertions(+), 52 deletions(-) diff --git a/packages/language-service/src/plugins/catalog.test.ts b/packages/language-service/src/plugins/catalog.test.ts index 3d66222a..7247efb1 100644 --- a/packages/language-service/src/plugins/catalog.test.ts +++ b/packages/language-service/src/plugins/catalog.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' -import { TextDocument } from 'vscode-languageserver-textdocument' import { createDependencyInfo } from '../test-utils/dependency' -import { getCatalogDependencyAtOffset, getCatalogInlayHints } from './catalog' +import { getCatalogDependencyAtOffset } from './catalog' describe('getCatalogDependencyAtOffset', () => { const dependency = createDependencyInfo({ @@ -28,25 +27,3 @@ describe('getCatalogDependencyAtOffset', () => { expect(getCatalogDependencyAtOffset([npmDependency], 20)).toBeUndefined() }) }) - -describe('getCatalogInlayHints', () => { - it.each(['catalog:', 'catalog:dev'])('places the %s hint outside the quoted spec', (rawSpec) => { - const text = `{"dependencies":{"lodash":"${rawSpec}","vue":"^3.0.0"}}` - const document = TextDocument.create('file:///repo/package.json', 'json', 0, text) - const specStart = text.indexOf(rawSpec) - const specEnd = specStart + rawSpec.length - const range = { start: document.positionAt(0), end: document.positionAt(text.length) } - const dependencies = [createDependencyInfo({ - rawSpec, - protocol: 'catalog', - resolvedSpec: '^4.0.0', - specRange: [specStart, specEnd], - })] - - expect(getCatalogInlayHints(document, range, dependencies)).toEqual([{ - position: document.positionAt(text.indexOf(',"vue"')), - label: '^4.0.0', - paddingLeft: true, - }]) - }) -}) diff --git a/packages/language-service/src/plugins/catalog.ts b/packages/language-service/src/plugins/catalog.ts index c01661ff..2c1bf9e6 100644 --- a/packages/language-service/src/plugins/catalog.ts +++ b/packages/language-service/src/plugins/catalog.ts @@ -1,6 +1,5 @@ -import type { CompletionItemKind, CompletionList, InlayHint, LanguageServicePlugin, LanguageServicePluginInstance, LocationLink, Range } from '@volar/language-service' +import type { CompletionItemKind, CompletionList, LanguageServicePlugin, LanguageServicePluginInstance, LocationLink } from '@volar/language-service' import type { DependencyInfo } from 'npmx-language-core/workspace' -import type { TextDocument } from 'vscode-languageserver-textdocument' import type { IWorkspaceState } from '../types' import { isPackageManifest } from 'npmx-language-core/utils' import { URI } from 'vscode-uri' @@ -17,31 +16,6 @@ export function getCatalogDependencyAtOffset( return dependency } -export function getCatalogInlayHints( - document: TextDocument, - range: Range, - dependencies: DependencyInfo[], -): InlayHint[] { - const startOffset = document.offsetAt(range.start) - const endOffset = document.offsetAt(range.end) - - return dependencies.flatMap((dependency) => { - if (dependency.protocol !== 'catalog') - return [] - - const [specStart, specEnd] = dependency.specRange - if (specEnd < startOffset || specStart > endOffset) - return [] - - return [{ - // JSON dependency ranges exclude the surrounding quotes. - position: document.positionAt(specEnd + 1), - label: dependency.resolvedSpec, - paddingLeft: true, - }] - }) -} - export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { function getDependencyFileUri(documentUri: string): URI | undefined { const uri = URI.parse(documentUri) @@ -153,7 +127,23 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { if (!dependencies) return - return getCatalogInlayHints(document, range, dependencies) + const startOffset = document.offsetAt(range.start) + const endOffset = document.offsetAt(range.end) + + return dependencies.flatMap((dependency) => { + if (dependency.protocol !== 'catalog') + return [] + + const [specStart, specEnd] = dependency.specRange + if (specEnd < startOffset || specStart > endOffset) + return [] + + return [{ + position: document.positionAt(specEnd), + label: dependency.resolvedSpec, + paddingLeft: true, + }] + }) }, } },