Skip to content

Commit 76b5976

Browse files
authored
Add AppHost CodeLens and gutter decoration support (#15397)
* Add CodeLens and gutter decoration support for AppHost resources * wip * remove status bar strings from branch * Add CodeLens, gutter decorations, and AppHost resource parsers for VS Code extension - Add CodeLens provider showing resource state, actions (start/stop/restart), and view logs - Add gutter decoration provider with colored status circles for resources - Add C# and JS/TS AppHost resource parsers with registry pattern - Add statementStartLine for multi-line fluent chain CodeLens positioning - Add comment-skipping logic so CodeLens appears below comments, above code - Extract shared resource state utilities and resource constants - Add enableCodeLens and enableGutterDecorations settings - Add comprehensive test coverage (parsers, CodeLens, resourceStateUtils) - Only match parent resources (Add* calls), not implicit child resources (With* calls) * Address Copilot review: conditional appHostPath, displayName preference, Unhealthy as error - Guard --apphost flag when appHostPath is falsy in CodeLens commands - Prefer displayName over name in two-pass resource matching - Classify Unhealthy health status as error (not warning) to align with tree view * bump extension version * update config json
1 parent 96d4ccd commit 76b5976

21 files changed

+3013
-1432
lines changed

extension/loc/xlf/aspire-vscode.xlf

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extension/package.json

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "Aspire",
44
"description": "%extension.description%",
55
"publisher": "microsoft-aspire",
6-
"version": "1.0.5",
6+
"version": "1.0.6",
77
"aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255",
88
"icon": "dotnet-aspire-logo-128.png",
99
"license": "SEE LICENSE IN LICENSE.TXT",
@@ -334,6 +334,26 @@
334334
"command": "aspire-vscode.verifyCliInstalled",
335335
"title": "%command.verifyCliInstalled%",
336336
"category": "Aspire"
337+
},
338+
{
339+
"command": "aspire-vscode.codeLensDebugPipelineStep",
340+
"title": "%command.codeLensDebugPipelineStep%",
341+
"category": "Aspire"
342+
},
343+
{
344+
"command": "aspire-vscode.codeLensResourceAction",
345+
"title": "%command.codeLensResourceAction%",
346+
"category": "Aspire"
347+
},
348+
{
349+
"command": "aspire-vscode.codeLensViewLogs",
350+
"title": "%command.codeLensViewLogs%",
351+
"category": "Aspire"
352+
},
353+
{
354+
"command": "aspire-vscode.codeLensRevealResource",
355+
"title": "%command.codeLensRevealResource%",
356+
"category": "Aspire"
337357
}
338358
],
339359
"jsonValidation": [
@@ -416,6 +436,22 @@
416436
"command": "aspire-vscode.executeResourceCommand",
417437
"when": "false"
418438
},
439+
{
440+
"command": "aspire-vscode.codeLensDebugPipelineStep",
441+
"when": "false"
442+
},
443+
{
444+
"command": "aspire-vscode.codeLensResourceAction",
445+
"when": "false"
446+
},
447+
{
448+
"command": "aspire-vscode.codeLensViewLogs",
449+
"when": "false"
450+
},
451+
{
452+
"command": "aspire-vscode.codeLensRevealResource",
453+
"when": "false"
454+
},
419455
{
420456
"command": "aspire-vscode.switchToGlobalView",
421457
"when": "false"
@@ -622,6 +658,18 @@
622658
"minimum": 1000,
623659
"description": "%configuration.aspire.globalAppHostsPollingInterval%",
624660
"scope": "window"
661+
},
662+
"aspire.enableCodeLens": {
663+
"type": "boolean",
664+
"default": true,
665+
"description": "%configuration.aspire.enableCodeLens%",
666+
"scope": "window"
667+
},
668+
"aspire.enableGutterDecorations": {
669+
"type": "boolean",
670+
"default": true,
671+
"description": "%configuration.aspire.enableGutterDecorations%",
672+
"scope": "window"
625673
}
626674
}
627675
},

extension/package.nls.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"configuration.aspire.enableDebugConfigEnvironmentLogging": "Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs.",
3838
"configuration.aspire.registerMcpServerInWorkspace": "Whether to register the Aspire MCP server when a workspace is open.",
3939
"configuration.aspire.globalAppHostsPollingInterval": "Polling interval in milliseconds for fetching all running apphosts (used in global view). Minimum: 1000.",
40+
"configuration.aspire.enableCodeLens": "Show CodeLens actions (state, restart, stop, logs) inline above resource declarations in apphost files.",
41+
"configuration.aspire.enableGutterDecorations": "Show colored status dots in the editor gutter next to resource declarations in apphost files.",
4042
"command.runAppHost": "Run Aspire apphost",
4143
"command.debugAppHost": "Debug Aspire apphost",
4244
"aspire-vscode.strings.noCsprojFound": "No apphost found in the current workspace.",
@@ -140,6 +142,10 @@
140142
"command.installCliStable": "Install Aspire CLI (stable)",
141143
"command.installCliDaily": "Install Aspire CLI (daily)",
142144
"command.verifyCliInstalled": "Verify Aspire CLI installation",
145+
"command.codeLensDebugPipelineStep": "Debug Aspire pipeline step",
146+
"command.codeLensResourceAction": "Aspire resource action",
147+
"command.codeLensRevealResource": "Reveal resource in Aspire panel",
148+
"command.codeLensViewLogs": "View Aspire resource logs",
143149
"walkthrough.getStarted.title": "Get started with Aspire",
144150
"walkthrough.getStarted.description": "Learn how to create, run, and monitor distributed applications with Aspire.",
145151
"walkthrough.getStarted.welcome.title": "Welcome to Aspire",
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import * as vscode from 'vscode';
2+
import { getParserForDocument } from './parsers/AppHostResourceParser';
3+
// Import parsers to trigger self-registration
4+
import './parsers/csharpAppHostParser';
5+
import './parsers/jsTsAppHostParser';
6+
import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider';
7+
import { ResourceJson, AppHostDisplayInfo, ResourceCommandJson } from '../views/AppHostDataRepository';
8+
import { findResourceState, findWorkspaceResourceState } from './resourceStateUtils';
9+
import { ResourceState, HealthStatus, StateStyle, ResourceType } from './resourceConstants';
10+
import {
11+
codeLensDebugPipelineStep,
12+
codeLensResourceRunning,
13+
codeLensResourceRunningWarning,
14+
codeLensResourceRunningError,
15+
codeLensResourceStarting,
16+
codeLensResourceStopped,
17+
codeLensResourceStoppedError,
18+
codeLensResourceError,
19+
codeLensRestart,
20+
codeLensStop,
21+
codeLensStart,
22+
codeLensViewLogs,
23+
codeLensCommand,
24+
} from '../loc/strings';
25+
26+
export class AspireCodeLensProvider implements vscode.CodeLensProvider {
27+
private readonly _onDidChangeCodeLenses = new vscode.EventEmitter<void>();
28+
readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
29+
30+
private _disposables: vscode.Disposable[] = [];
31+
32+
constructor(private readonly _treeProvider: AspireAppHostTreeProvider) {
33+
// Re-compute lenses whenever the polling data changes
34+
this._disposables.push(
35+
_treeProvider.onDidChangeTreeData(() => this._onDidChangeCodeLenses.fire())
36+
);
37+
}
38+
39+
provideCodeLenses(document: vscode.TextDocument, _token: vscode.CancellationToken): vscode.CodeLens[] {
40+
if (!vscode.workspace.getConfiguration('aspire').get<boolean>('enableCodeLens', true)) {
41+
return [];
42+
}
43+
44+
const parser = getParserForDocument(document);
45+
if (!parser) {
46+
return [];
47+
}
48+
49+
const resources = parser.parseResources(document);
50+
if (resources.length === 0) {
51+
return [];
52+
}
53+
54+
const appHosts = this._treeProvider.appHosts;
55+
const workspaceResources = this._treeProvider.workspaceResources;
56+
const workspaceAppHostPath = this._treeProvider.workspaceAppHostPath ?? '';
57+
const hasRunningData = appHosts.length > 0 || workspaceResources.length > 0;
58+
const findWorkspace = findWorkspaceResourceState(workspaceResources, workspaceAppHostPath);
59+
60+
const lenses: vscode.CodeLens[] = [];
61+
62+
for (const resource of resources) {
63+
// Use statementStartLine to position the CodeLens at the top of a multi-line chain
64+
const lensLine = resource.statementStartLine ?? resource.range.start.line;
65+
const lineRange = new vscode.Range(lensLine, 0, lensLine, 0);
66+
67+
if (resource.kind === 'pipelineStep') {
68+
// Pipeline steps get Debug lens when no AppHost is running
69+
if (!hasRunningData) {
70+
this._addPipelineStepLenses(lenses, lineRange, resource.name);
71+
}
72+
} else if (resource.kind === 'resource') {
73+
// Resources get state lenses when live data is available
74+
if (hasRunningData) {
75+
const match = findResourceState(appHosts, resource.name)
76+
?? findWorkspace(resource.name);
77+
if (match) {
78+
this._addStateLenses(lenses, lineRange, match.resource, match.appHost);
79+
}
80+
}
81+
}
82+
}
83+
84+
return lenses;
85+
}
86+
87+
private _addPipelineStepLenses(lenses: vscode.CodeLens[], range: vscode.Range, stepName: string): void {
88+
lenses.push(new vscode.CodeLens(range, {
89+
title: codeLensDebugPipelineStep,
90+
command: 'aspire-vscode.codeLensDebugPipelineStep',
91+
tooltip: codeLensDebugPipelineStep,
92+
arguments: [stepName],
93+
}));
94+
}
95+
96+
private _addStateLenses(
97+
lenses: vscode.CodeLens[],
98+
range: vscode.Range,
99+
resource: ResourceJson,
100+
appHost: AppHostDisplayInfo,
101+
): void {
102+
const state = resource.state ?? '';
103+
const stateStyle = resource.stateStyle ?? '';
104+
const healthStatus = resource.healthStatus;
105+
const commands = resource.commands ? Object.keys(resource.commands) : [];
106+
107+
// State indicator lens (clickable — reveals resource in tree view)
108+
let stateLabel = getCodeLensStateLabel(state, stateStyle);
109+
if (healthStatus && healthStatus !== HealthStatus.Healthy) {
110+
stateLabel += ` - (${healthStatus})`;
111+
}
112+
lenses.push(new vscode.CodeLens(range, {
113+
title: stateLabel,
114+
command: 'aspire-vscode.codeLensRevealResource',
115+
tooltip: `${resource.displayName ?? resource.name}: ${state}${healthStatus ? ` (${healthStatus})` : ''}`,
116+
arguments: [resource.displayName ?? resource.name],
117+
}));
118+
119+
// Action lenses based on available commands
120+
if (commands.includes('restart') || commands.includes('resource-restart')) {
121+
lenses.push(new vscode.CodeLens(range, {
122+
title: codeLensRestart,
123+
command: 'aspire-vscode.codeLensResourceAction',
124+
tooltip: codeLensRestart,
125+
arguments: [resource.name, 'restart', appHost.appHostPath],
126+
}));
127+
}
128+
129+
if (commands.includes('stop') || commands.includes('resource-stop')) {
130+
lenses.push(new vscode.CodeLens(range, {
131+
title: codeLensStop,
132+
command: 'aspire-vscode.codeLensResourceAction',
133+
tooltip: codeLensStop,
134+
arguments: [resource.name, 'stop', appHost.appHostPath],
135+
}));
136+
}
137+
138+
if (commands.includes('start') || commands.includes('resource-start')) {
139+
lenses.push(new vscode.CodeLens(range, {
140+
title: codeLensStart,
141+
command: 'aspire-vscode.codeLensResourceAction',
142+
tooltip: codeLensStart,
143+
arguments: [resource.name, 'start', appHost.appHostPath],
144+
}));
145+
}
146+
147+
// View Logs lens (not applicable to parameters)
148+
if (resource.resourceType !== ResourceType.Parameter) {
149+
lenses.push(new vscode.CodeLens(range, {
150+
title: codeLensViewLogs,
151+
command: 'aspire-vscode.codeLensViewLogs',
152+
tooltip: codeLensViewLogs,
153+
arguments: [resource.displayName ?? resource.name, appHost.appHostPath],
154+
}));
155+
}
156+
157+
// Custom commands (non-standard ones like "Reset Database")
158+
const standardCommands = new Set(['restart', 'resource-restart', 'stop', 'resource-stop', 'start', 'resource-start']);
159+
if (resource.commands) {
160+
for (const [cmdName, cmd] of Object.entries(resource.commands) as [string, ResourceCommandJson][]) {
161+
if (!standardCommands.has(cmdName)) {
162+
const label = codeLensCommand(cmd.description ?? cmdName);
163+
lenses.push(new vscode.CodeLens(range, {
164+
title: label,
165+
command: 'aspire-vscode.codeLensResourceAction',
166+
tooltip: cmd.description ?? cmdName,
167+
arguments: [resource.name, cmdName, appHost.appHostPath],
168+
}));
169+
}
170+
}
171+
}
172+
}
173+
174+
dispose(): void {
175+
this._disposables.forEach(d => d.dispose());
176+
this._onDidChangeCodeLenses.dispose();
177+
}
178+
}
179+
180+
export function getCodeLensStateLabel(state: string, stateStyle: string): string {
181+
switch (state) {
182+
case ResourceState.Running:
183+
case ResourceState.Active:
184+
if (stateStyle === StateStyle.Error) {
185+
return codeLensResourceRunningError;
186+
}
187+
if (stateStyle === StateStyle.Warning) {
188+
return codeLensResourceRunningWarning;
189+
}
190+
return codeLensResourceRunning;
191+
case ResourceState.Starting:
192+
case ResourceState.Building:
193+
case ResourceState.Waiting:
194+
case ResourceState.NotStarted:
195+
return codeLensResourceStarting;
196+
case ResourceState.FailedToStart:
197+
case ResourceState.RuntimeUnhealthy:
198+
return codeLensResourceError;
199+
case ResourceState.Finished:
200+
case ResourceState.Exited:
201+
case ResourceState.Stopping:
202+
if (stateStyle === StateStyle.Error) {
203+
return codeLensResourceStoppedError;
204+
}
205+
return codeLensResourceStopped;
206+
default:
207+
return state || codeLensResourceStopped;
208+
}
209+
}

0 commit comments

Comments
 (0)