Skip to content

Commit df2e1e5

Browse files
authored
Merge pull request #8 from yongsk0066/feature/codelens
Add CodeLens annotations for Server/Client Components
2 parents 19c1001 + 322f5e8 commit df2e1e5

File tree

7 files changed

+492
-15
lines changed

7 files changed

+492
-15
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"package.json":"Minor"},"note":"Add CodeLens annotations for Server/Client Components with enabled by default","date":"2026-03-25T14:54:52.283396Z"}

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@
7474
"default": true,
7575
"description": "Highlight TypeScript interface and type alias declaration names."
7676
},
77+
"reactComponentLens.codelens.enabled": {
78+
"type": "boolean",
79+
"default": true,
80+
"description": "Show CodeLens annotations indicating Server/Client Component kind."
81+
},
82+
"reactComponentLens.codelens.clientComponent": {
83+
"type": "boolean",
84+
"default": true,
85+
"description": "Show CodeLens for Client Components."
86+
},
87+
"reactComponentLens.codelens.serverComponent": {
88+
"type": "boolean",
89+
"default": true,
90+
"description": "Show CodeLens for Server Components."
91+
},
7792
"reactComponentLens.highlightColors": {
7893
"type": "object",
7994
"default": {

src/analyzer.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,49 @@ export class ComponentLensAnalyzer {
9696
this.directiveCache.delete(filePath)
9797
}
9898

99+
public async findComponentDeclaration(
100+
filePath: string,
101+
componentName: string,
102+
): Promise<{ character: number; line: number } | undefined> {
103+
const sourceText = this.host.readFileAsync
104+
? await this.host.readFileAsync(filePath)
105+
: this.host.readFile(filePath)
106+
107+
if (sourceText === undefined) {
108+
return undefined
109+
}
110+
111+
const signature = this.host.getSignatureAsync
112+
? await this.host.getSignatureAsync(filePath)
113+
: this.host.getSignature(filePath)
114+
115+
if (signature === undefined) {
116+
return undefined
117+
}
118+
119+
const analysis = this.getAnalysis(filePath, sourceText, signature)
120+
if (!analysis) {
121+
return undefined
122+
}
123+
124+
const component = analysis.localComponents.get(componentName)
125+
if (!component || component.ranges.length === 0) {
126+
return undefined
127+
}
128+
129+
const offset = component.ranges[0]!.start
130+
let line = 0
131+
let lastNewline = -1
132+
for (let i = 0; i < offset; i++) {
133+
if (sourceText.charCodeAt(i) === 10) {
134+
line++
135+
lastNewline = i
136+
}
137+
}
138+
139+
return { character: offset - lastNewline - 1, line }
140+
}
141+
99142
public async analyzeDocument(
100143
filePath: string,
101144
sourceText: string,

src/codelens.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import * as vscode from 'vscode'
2+
3+
import type {
4+
ComponentLensAnalyzer,
5+
ComponentUsage,
6+
ScopeConfig,
7+
} from './analyzer'
8+
import { createOpenSignature } from './resolver'
9+
10+
export interface CodeLensConfig {
11+
clientComponent: boolean
12+
enabled: boolean
13+
globalEnabled: boolean
14+
serverComponent: boolean
15+
}
16+
17+
const CODELENS_SCOPE: ScopeConfig = {
18+
declaration: true,
19+
element: true,
20+
export: true,
21+
import: true,
22+
type: true,
23+
}
24+
25+
interface LineGroup {
26+
clients: Set<string>
27+
components: Map<string, string>
28+
servers: Set<string>
29+
}
30+
31+
export class ComponentCodeLensProvider
32+
implements vscode.CodeLensProvider, vscode.Disposable
33+
{
34+
private readonly changeEmitter = new vscode.EventEmitter<void>()
35+
public readonly onDidChangeCodeLenses = this.changeEmitter.event
36+
37+
private config: CodeLensConfig
38+
39+
public constructor(
40+
private readonly analyzer: ComponentLensAnalyzer,
41+
config: CodeLensConfig,
42+
) {
43+
this.config = config
44+
}
45+
46+
public updateConfig(config: CodeLensConfig): void {
47+
this.config = config
48+
this.changeEmitter.fire()
49+
}
50+
51+
public refresh(): void {
52+
this.changeEmitter.fire()
53+
}
54+
55+
public dispose(): void {
56+
this.changeEmitter.dispose()
57+
}
58+
59+
public async provideCodeLenses(
60+
document: vscode.TextDocument,
61+
): Promise<vscode.CodeLens[]> {
62+
if (!this.config.globalEnabled || !this.config.enabled) {
63+
return []
64+
}
65+
66+
if (!this.config.clientComponent && !this.config.serverComponent) {
67+
return []
68+
}
69+
70+
const signature = createOpenSignature(document.version)
71+
const usages = await this.analyzer.analyzeDocument(
72+
document.fileName,
73+
document.getText(),
74+
signature,
75+
CODELENS_SCOPE,
76+
)
77+
78+
return this.buildCodeLenses(document, usages)
79+
}
80+
81+
private async buildCodeLenses(
82+
document: vscode.TextDocument,
83+
usages: ComponentUsage[],
84+
): Promise<vscode.CodeLens[]> {
85+
const lineMap = new Map<number, LineGroup>()
86+
87+
for (let i = 0; i < usages.length; i++) {
88+
const usage = usages[i]!
89+
if (usage.kind === 'client' && !this.config.clientComponent) {
90+
continue
91+
}
92+
if (usage.kind === 'server' && !this.config.serverComponent) {
93+
continue
94+
}
95+
96+
if (usage.ranges.length === 0) {
97+
continue
98+
}
99+
100+
const line = document.positionAt(usage.ranges[0]!.start).line
101+
let entry = lineMap.get(line)
102+
if (!entry) {
103+
entry = {
104+
clients: new Set(),
105+
components: new Map(),
106+
servers: new Set(),
107+
}
108+
lineMap.set(line, entry)
109+
}
110+
111+
if (usage.kind === 'client') {
112+
entry.clients.add(usage.tagName)
113+
} else {
114+
entry.servers.add(usage.tagName)
115+
}
116+
117+
if (!entry.components.has(usage.tagName)) {
118+
entry.components.set(usage.tagName, usage.sourceFilePath)
119+
}
120+
}
121+
122+
const positions = await this.resolveDeclarationPositions(lineMap)
123+
const codeLenses: vscode.CodeLens[] = []
124+
125+
for (const [line, { clients, servers, components }] of lineMap) {
126+
const parts: string[] = []
127+
if (clients.size > 0) {
128+
parts.push('Client Component')
129+
}
130+
if (servers.size > 0) {
131+
parts.push('Server Component')
132+
}
133+
134+
if (parts.length === 0) {
135+
continue
136+
}
137+
138+
const locations = this.buildLocations(components, positions)
139+
const position = new vscode.Position(line, 0)
140+
141+
if (locations.length > 0) {
142+
codeLenses.push(
143+
new vscode.CodeLens(new vscode.Range(position, position), {
144+
arguments: [document.uri, position, locations, 'peek'],
145+
command: 'editor.action.peekLocations',
146+
title: parts.join(' · '),
147+
}),
148+
)
149+
} else {
150+
codeLenses.push(
151+
new vscode.CodeLens(new vscode.Range(position, position), {
152+
command: '',
153+
title: parts.join(' · '),
154+
}),
155+
)
156+
}
157+
}
158+
159+
return codeLenses
160+
}
161+
162+
private async resolveDeclarationPositions(
163+
lineMap: Map<number, LineGroup>,
164+
): Promise<Map<string, vscode.Position>> {
165+
const filesToResolve = new Map<string, Set<string>>()
166+
167+
for (const [, { components }] of lineMap) {
168+
for (const [tagName, sourceFilePath] of components) {
169+
let names = filesToResolve.get(sourceFilePath)
170+
if (!names) {
171+
names = new Set()
172+
filesToResolve.set(sourceFilePath, names)
173+
}
174+
names.add(tagName)
175+
}
176+
}
177+
178+
const positions = new Map<string, vscode.Position>()
179+
180+
await Promise.all(
181+
Array.from(filesToResolve, async ([filePath, names]) => {
182+
for (const name of names) {
183+
const pos = await this.analyzer.findComponentDeclaration(
184+
filePath,
185+
name,
186+
)
187+
if (pos) {
188+
positions.set(
189+
filePath + ':' + name,
190+
new vscode.Position(pos.line, pos.character),
191+
)
192+
}
193+
}
194+
}),
195+
)
196+
197+
return positions
198+
}
199+
200+
private buildLocations(
201+
components: Map<string, string>,
202+
positions: Map<string, vscode.Position>,
203+
): vscode.Location[] {
204+
const seen = new Set<string>()
205+
const locations: vscode.Location[] = []
206+
207+
for (const [tagName, sourceFilePath] of components) {
208+
if (seen.has(sourceFilePath)) {
209+
continue
210+
}
211+
seen.add(sourceFilePath)
212+
213+
const uri = vscode.Uri.file(sourceFilePath)
214+
const pos =
215+
positions.get(sourceFilePath + ':' + tagName) ??
216+
new vscode.Position(0, 0)
217+
locations.push(new vscode.Location(uri, pos))
218+
}
219+
220+
return locations
221+
}
222+
}

src/extension.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import * as path from 'node:path'
44
import * as vscode from 'vscode'
55

66
import { ComponentLensAnalyzer, type ScopeConfig } from './analyzer'
7+
import { type CodeLensConfig, ComponentCodeLensProvider } from './codelens'
78
import { type HighlightColors, LensDecorations } from './decorations'
8-
import { ImportResolver, type SourceHost } from './resolver'
9+
import {
10+
createDiskSignature,
11+
createOpenSignature,
12+
ImportResolver,
13+
type SourceHost,
14+
} from './resolver'
915

1016
const LANG_JSX = 'javascriptreact'
1117
const LANG_TSX = 'typescriptreact'
@@ -22,8 +28,19 @@ export function activate(context: vscode.ExtensionContext): void {
2228
const resolver = new ImportResolver(sourceHost)
2329
const analyzer = new ComponentLensAnalyzer(sourceHost, resolver)
2430
const decorations = new LensDecorations(config.highlightColors)
31+
const codeLensProvider = new ComponentCodeLensProvider(
32+
analyzer,
33+
config.codeLens,
34+
)
35+
36+
context.subscriptions.push(decorations, codeLensProvider)
2537

26-
context.subscriptions.push(decorations)
38+
context.subscriptions.push(
39+
vscode.languages.registerCodeLensProvider(
40+
[{ language: LANG_TSX }, { language: LANG_JSX }],
41+
codeLensProvider,
42+
),
43+
)
2744

2845
let refreshTimer: NodeJS.Timeout | undefined
2946
let watcherDisposables: vscode.Disposable[] = []
@@ -47,6 +64,7 @@ export function activate(context: vscode.ExtensionContext): void {
4764

4865
refreshTimer = setTimeout(() => {
4966
refreshTimer = undefined
67+
codeLensProvider.refresh()
5068
void refreshVisibleEditors()
5169
}, delay)
5270
}
@@ -153,6 +171,10 @@ export function activate(context: vscode.ExtensionContext): void {
153171
decorations.updateColors(config.highlightColors)
154172
}
155173

174+
if (event.affectsConfiguration('reactComponentLens.codelens')) {
175+
codeLensProvider.updateConfig(config.codeLens)
176+
}
177+
156178
scheduleRefresh(0)
157179
}),
158180
new vscode.Disposable(() => {
@@ -171,6 +193,7 @@ export function deactivate(): void {
171193
}
172194

173195
function getConfiguration(): {
196+
codeLens: CodeLensConfig
174197
debounceMs: number
175198
enabled: boolean
176199
highlightColors: HighlightColors
@@ -184,6 +207,18 @@ function getConfiguration(): {
184207
)
185208

186209
return {
210+
codeLens: {
211+
clientComponent: configuration.get<boolean>(
212+
'codelens.clientComponent',
213+
true,
214+
),
215+
enabled: configuration.get<boolean>('codelens.enabled', true),
216+
globalEnabled: configuration.get<boolean>('enabled', true),
217+
serverComponent: configuration.get<boolean>(
218+
'codelens.serverComponent',
219+
true,
220+
),
221+
},
187222
debounceMs: Math.max(0, Math.min(2000, debounceMs)),
188223
enabled: configuration.get<boolean>('enabled', true),
189224
highlightColors: {
@@ -300,14 +335,6 @@ class WorkspaceSourceHost implements SourceHost {
300335
}
301336
}
302337

303-
function createOpenSignature(version: number): string {
304-
return 'open:' + version
305-
}
306-
307-
function createDiskSignature(mtimeMs: number, size: number): string {
308-
return 'disk:' + mtimeMs + ':' + size
309-
}
310-
311338
function normalizeColor(
312339
color: string | undefined,
313340
fallbackColor: string,

0 commit comments

Comments
 (0)