From cf7e39d88db8dcbc81aae76122d8b2bbd8bb75c4 Mon Sep 17 00:00:00 2001 From: yongsk0066 Date: Mon, 16 Mar 2026 13:36:46 +0900 Subject: [PATCH 1/2] Add CodeLens annotations for Server/Client Component identification - Add ComponentCodeLensProvider with component kind labels above code lines - Respect global enabled setting to properly disable all features - Add codelens.enabled, codelens.clientComponent, codelens.serverComponent settings - Move signature helpers to resolver.ts for contextual locality - Add tests for CodeLens scope behavior --- package.json | 15 ++++ src/codelens.ts | 136 +++++++++++++++++++++++++++++++++ src/extension.ts | 47 +++++++++--- src/resolver.ts | 8 ++ test/analyzer.test.ts | 171 ++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 src/codelens.ts diff --git a/package.json b/package.json index 79c3ac9..937f57c 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,21 @@ "default": true, "description": "Highlight TypeScript interface and type alias declaration names." }, + "reactComponentLens.codelens.enabled": { + "type": "boolean", + "default": false, + "description": "Show CodeLens annotations indicating Server/Client Component kind." + }, + "reactComponentLens.codelens.clientComponent": { + "type": "boolean", + "default": true, + "description": "Show CodeLens for Client Components." + }, + "reactComponentLens.codelens.serverComponent": { + "type": "boolean", + "default": true, + "description": "Show CodeLens for Server Components." + }, "reactComponentLens.highlightColors": { "type": "object", "default": { diff --git a/src/codelens.ts b/src/codelens.ts new file mode 100644 index 0000000..3dfc50f --- /dev/null +++ b/src/codelens.ts @@ -0,0 +1,136 @@ +import * as vscode from 'vscode' + +import type { + ComponentLensAnalyzer, + ComponentUsage, + ScopeConfig, +} from './analyzer' +import { createOpenSignature } from './resolver' + +export interface CodeLensConfig { + clientComponent: boolean + enabled: boolean + globalEnabled: boolean + serverComponent: boolean +} + +const CODELENS_SCOPE: ScopeConfig = { + declaration: true, + element: true, + export: true, + import: true, + type: true, +} + +export class ComponentCodeLensProvider + implements vscode.CodeLensProvider, vscode.Disposable +{ + private readonly changeEmitter = new vscode.EventEmitter() + public readonly onDidChangeCodeLenses = this.changeEmitter.event + + private config: CodeLensConfig + + public constructor( + private readonly analyzer: ComponentLensAnalyzer, + config: CodeLensConfig, + ) { + this.config = config + } + + public updateConfig(config: CodeLensConfig): void { + this.config = config + this.changeEmitter.fire() + } + + public refresh(): void { + this.changeEmitter.fire() + } + + public dispose(): void { + this.changeEmitter.dispose() + } + + public async provideCodeLenses( + document: vscode.TextDocument, + ): Promise { + if (!this.config.globalEnabled || !this.config.enabled) { + return [] + } + + if (!this.config.clientComponent && !this.config.serverComponent) { + return [] + } + + const signature = createOpenSignature(document.version) + const usages = await this.analyzer.analyzeDocument( + document.fileName, + document.getText(), + signature, + CODELENS_SCOPE, + ) + + return this.buildCodeLenses(document, usages) + } + + private buildCodeLenses( + document: vscode.TextDocument, + usages: ComponentUsage[], + ): vscode.CodeLens[] { + const lineMap = new Map< + number, + { clients: Set; servers: Set } + >() + + for (let i = 0; i < usages.length; i++) { + const usage = usages[i]! + if (usage.kind === 'client' && !this.config.clientComponent) { + continue + } + if (usage.kind === 'server' && !this.config.serverComponent) { + continue + } + + if (usage.ranges.length === 0) { + continue + } + + const line = document.positionAt(usage.ranges[0]!.start).line + let entry = lineMap.get(line) + if (!entry) { + entry = { clients: new Set(), servers: new Set() } + lineMap.set(line, entry) + } + + if (usage.kind === 'client') { + entry.clients.add(usage.tagName) + } else { + entry.servers.add(usage.tagName) + } + } + + const codeLenses: vscode.CodeLens[] = [] + + for (const [line, { clients, servers }] of lineMap) { + const parts: string[] = [] + if (clients.size > 0) { + parts.push('Client Component') + } + if (servers.size > 0) { + parts.push('Server Component') + } + + if (parts.length === 0) { + continue + } + + codeLenses.push( + new vscode.CodeLens(new vscode.Range(line, 0, line, 0), { + command: '', + title: parts.join(' · '), + }), + ) + } + + return codeLenses + } +} diff --git a/src/extension.ts b/src/extension.ts index 838e259..8a7e2ab 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,8 +4,14 @@ import * as path from 'node:path' import * as vscode from 'vscode' import { ComponentLensAnalyzer, type ScopeConfig } from './analyzer' +import { type CodeLensConfig, ComponentCodeLensProvider } from './codelens' import { type HighlightColors, LensDecorations } from './decorations' -import { ImportResolver, type SourceHost } from './resolver' +import { + createDiskSignature, + createOpenSignature, + ImportResolver, + type SourceHost, +} from './resolver' const LANG_JSX = 'javascriptreact' const LANG_TSX = 'typescriptreact' @@ -22,8 +28,19 @@ export function activate(context: vscode.ExtensionContext): void { const resolver = new ImportResolver(sourceHost) const analyzer = new ComponentLensAnalyzer(sourceHost, resolver) const decorations = new LensDecorations(config.highlightColors) + const codeLensProvider = new ComponentCodeLensProvider( + analyzer, + config.codeLens, + ) + + context.subscriptions.push(decorations, codeLensProvider) - context.subscriptions.push(decorations) + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + [{ language: LANG_TSX }, { language: LANG_JSX }], + codeLensProvider, + ), + ) let refreshTimer: NodeJS.Timeout | undefined let watcherDisposables: vscode.Disposable[] = [] @@ -47,6 +64,7 @@ export function activate(context: vscode.ExtensionContext): void { refreshTimer = setTimeout(() => { refreshTimer = undefined + codeLensProvider.refresh() void refreshVisibleEditors() }, delay) } @@ -153,6 +171,10 @@ export function activate(context: vscode.ExtensionContext): void { decorations.updateColors(config.highlightColors) } + if (event.affectsConfiguration('reactComponentLens.codelens')) { + codeLensProvider.updateConfig(config.codeLens) + } + scheduleRefresh(0) }), new vscode.Disposable(() => { @@ -171,6 +193,7 @@ export function deactivate(): void { } function getConfiguration(): { + codeLens: CodeLensConfig debounceMs: number enabled: boolean highlightColors: HighlightColors @@ -184,6 +207,18 @@ function getConfiguration(): { ) return { + codeLens: { + clientComponent: configuration.get( + 'codelens.clientComponent', + true, + ), + enabled: configuration.get('codelens.enabled', false), + globalEnabled: configuration.get('enabled', true), + serverComponent: configuration.get( + 'codelens.serverComponent', + true, + ), + }, debounceMs: Math.max(0, Math.min(2000, debounceMs)), enabled: configuration.get('enabled', true), highlightColors: { @@ -300,14 +335,6 @@ class WorkspaceSourceHost implements SourceHost { } } -function createOpenSignature(version: number): string { - return 'open:' + version -} - -function createDiskSignature(mtimeMs: number, size: number): string { - return 'disk:' + mtimeMs + ':' + size -} - function normalizeColor( color: string | undefined, fallbackColor: string, diff --git a/src/resolver.ts b/src/resolver.ts index ce88e41..8d3b028 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -172,6 +172,14 @@ function findNearestConfigFile(startDirectory: string): string | undefined { } } +export function createOpenSignature(version: number): string { + return 'open:' + version +} + +export function createDiskSignature(mtimeMs: number, size: number): string { + return 'disk:' + mtimeMs + ':' + size +} + function isSupportedSourceFile(filePath: string): boolean { if (filePath.endsWith('.d.ts')) { return false diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index 157d6fb..926454c 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -5,7 +5,11 @@ import * as path from 'node:path' import { expect, test } from 'bun:test' import { ComponentLensAnalyzer, type ScopeConfig } from '../src/analyzer' -import { ImportResolver, type SourceHost } from '../src/resolver' +import { + createDiskSignature, + ImportResolver, + type SourceHost, +} from '../src/resolver' test('detects client and server component usages from relative imports', async () => { const project = createProject({ @@ -1523,6 +1527,167 @@ test('highlights default import when source file exports a component', async () } }) +test('codelens scope returns import and declaration usages together', async () => { + const project = createProject({ + 'Page.tsx': [ + "import Button from './Button';", + "import { Layout } from './Layout';", + '', + 'export default function Page() {', + ' return (', + ' <>', + '