diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 0449ed2..8b95920 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 884aae7..2a8b36b 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 b9e0327..77eaabc 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 0000000..297c95c --- /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 0000000..05fb9b4 --- /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 d3f2887..a03aa41 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 328b0e9..189ba52 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,