Skip to content

Commit d3b82a6

Browse files
committed
VS Code extension: Improve gutter, code lens, and tree view resource state indicators
- NotStarted resources show grey idle icon instead of loading spinner - Waiting resources show 'Waiting' label distinct from 'Starting' - Unhealthy health status shows yellow/warning instead of red/error - Successfully completed resources show pale green gutter dot and green tree icon - Non-zero exit codes show as error in gutter and tree view - Exit codes displayed in code lens for stopped resources - Health check details shown in code lens (e.g. 'Unhealthy 1/3') and tree tooltip - Added healthReports and exitCode to ResourceJson TypeScript interface - Added walkthrough section explaining editor indicators Fixes #15667 Related to #15577
1 parent 59f3ffa commit d3b82a6

File tree

13 files changed

+399
-52
lines changed

13 files changed

+399
-52
lines changed

extension/loc/xlf/aspire-vscode.xlf

Lines changed: 3 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: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,18 @@
354354
"command": "aspire-vscode.codeLensRevealResource",
355355
"title": "%command.codeLensRevealResource%",
356356
"category": "Aspire"
357+
},
358+
{
359+
"command": "aspire-vscode.expandAll",
360+
"title": "%command.expandAll%",
361+
"category": "Aspire",
362+
"icon": "$(expand-all)"
363+
},
364+
{
365+
"command": "aspire-vscode.collapseAll",
366+
"title": "%command.collapseAll%",
367+
"category": "Aspire",
368+
"icon": "$(collapse-all)"
357369
}
358370
],
359371
"jsonValidation": [
@@ -483,6 +495,14 @@
483495
{
484496
"command": "aspire-vscode.copyPid",
485497
"when": "false"
498+
},
499+
{
500+
"command": "aspire-vscode.expandAll",
501+
"when": "false"
502+
},
503+
{
504+
"command": "aspire-vscode.collapseAll",
505+
"when": "false"
486506
}
487507
],
488508
"view/title": [
@@ -505,17 +525,27 @@
505525
"view/item/context": [
506526
{
507527
"command": "aspire-vscode.openDashboard",
528+
"when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources)(:|$)/",
529+
"group": "inline"
530+
},
531+
{
532+
"command": "aspire-vscode.expandAll",
508533
"when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources)$/",
509534
"group": "inline"
510535
},
536+
{
537+
"command": "aspire-vscode.collapseAll",
538+
"when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources):expanded$/",
539+
"group": "inline"
540+
},
511541
{
512542
"command": "aspire-vscode.stopAppHost",
513-
"when": "view == aspire-vscode.runningAppHosts && viewItem == appHost",
543+
"when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^appHost(:|$)/",
514544
"group": "2_actions@1"
515545
},
516546
{
517547
"command": "aspire-vscode.copyAppHostPath",
518-
"when": "view == aspire-vscode.runningAppHosts && viewItem == appHost",
548+
"when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^appHost(:|$)/",
519549
"group": "3_clipboard@1"
520550
},
521551
{

extension/package.nls.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@
146146
"command.codeLensResourceAction": "Aspire resource action",
147147
"command.codeLensRevealResource": "Reveal resource in Aspire panel",
148148
"command.codeLensViewLogs": "View Aspire resource logs",
149+
"command.expandAll": "Expand all resources",
150+
"command.collapseAll": "Collapse all resources",
149151
"walkthrough.getStarted.title": "Get started with Aspire",
150152
"walkthrough.getStarted.description": "Learn how to create, run, and monitor distributed applications with Aspire.",
151153
"walkthrough.getStarted.welcome.title": "Welcome to Aspire",
195 KB
Loading

extension/src/editor/AspireCodeLensProvider.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ import {
1313
codeLensResourceRunningWarning,
1414
codeLensResourceRunningError,
1515
codeLensResourceStarting,
16+
codeLensResourceNotStarted,
17+
codeLensResourceWaiting,
1618
codeLensResourceStopped,
19+
codeLensResourceStoppedWithExitCode,
1720
codeLensResourceStoppedError,
21+
codeLensResourceStoppedErrorWithExitCode,
1822
codeLensResourceError,
1923
codeLensRestart,
2024
codeLensStop,
@@ -105,14 +109,31 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider {
105109
const commands = resource.commands ? Object.keys(resource.commands) : [];
106110

107111
// State indicator lens (clickable — reveals resource in tree view)
108-
let stateLabel = getCodeLensStateLabel(state, stateStyle);
112+
let stateLabel = getCodeLensStateLabel(state, stateStyle, resource.exitCode);
109113
if (healthStatus && healthStatus !== HealthStatus.Healthy) {
110-
stateLabel += ` - (${healthStatus})`;
114+
const reports = resource.healthReports;
115+
if (reports) {
116+
const entries = Object.values(reports);
117+
const healthy = entries.filter(r => r.status === HealthStatus.Healthy).length;
118+
stateLabel += ` - (${healthStatus} ${healthy}/${entries.length})`;
119+
} else {
120+
stateLabel += ` - (${healthStatus})`;
121+
}
122+
}
123+
124+
let tooltipText = `${resource.displayName ?? resource.name}: ${state}${healthStatus ? ` (${healthStatus})` : ''}`;
125+
const reports = resource.healthReports;
126+
if (reports && healthStatus && healthStatus !== HealthStatus.Healthy) {
127+
const failing = Object.entries(reports).filter(([, r]) => r.status !== HealthStatus.Healthy);
128+
if (failing.length > 0) {
129+
tooltipText += '\n' + failing.map(([name, r]) => ` ${name}: ${r.status}${r.description ? ` - ${r.description}` : ''}`).join('\n');
130+
}
111131
}
132+
112133
lenses.push(new vscode.CodeLens(range, {
113134
title: stateLabel,
114135
command: 'aspire-vscode.codeLensRevealResource',
115-
tooltip: `${resource.displayName ?? resource.name}: ${state}${healthStatus ? ` (${healthStatus})` : ''}`,
136+
tooltip: tooltipText,
116137
arguments: [resource.displayName ?? resource.name],
117138
}));
118139

@@ -177,7 +198,7 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider {
177198
}
178199
}
179200

180-
export function getCodeLensStateLabel(state: string, stateStyle: string): string {
201+
export function getCodeLensStateLabel(state: string, stateStyle: string, exitCode?: number | null): string {
181202
switch (state) {
182203
case ResourceState.Running:
183204
case ResourceState.Active:
@@ -190,19 +211,21 @@ export function getCodeLensStateLabel(state: string, stateStyle: string): string
190211
return codeLensResourceRunning;
191212
case ResourceState.Starting:
192213
case ResourceState.Building:
214+
return codeLensResourceStarting;
193215
case ResourceState.Waiting:
216+
return codeLensResourceWaiting;
194217
case ResourceState.NotStarted:
195-
return codeLensResourceStarting;
218+
return codeLensResourceNotStarted;
196219
case ResourceState.FailedToStart:
197220
case ResourceState.RuntimeUnhealthy:
198221
return codeLensResourceError;
199222
case ResourceState.Finished:
200223
case ResourceState.Exited:
201224
case ResourceState.Stopping:
202225
if (stateStyle === StateStyle.Error) {
203-
return codeLensResourceStoppedError;
226+
return exitCode != null ? codeLensResourceStoppedErrorWithExitCode(exitCode) : codeLensResourceStoppedError;
204227
}
205-
return codeLensResourceStopped;
228+
return exitCode != null ? codeLensResourceStoppedWithExitCode(exitCode) : codeLensResourceStopped;
206229
default:
207230
return state || codeLensResourceStopped;
208231
}

extension/src/editor/AspireGutterDecorationProvider.ts

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,73 @@ import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider';
77
import { findResourceState, findWorkspaceResourceState } from './resourceStateUtils';
88
import { ResourceState, StateStyle, HealthStatus } from './resourceConstants';
99

10-
type GutterCategory = 'running' | 'warning' | 'error' | 'starting' | 'stopped';
11-
12-
const gutterCategories: GutterCategory[] = ['running', 'warning', 'error', 'starting', 'stopped'];
13-
14-
const gutterColors: Record<GutterCategory, string> = {
15-
running: '#28a745', // green
16-
warning: '#e0a30b', // yellow/amber
17-
error: '#d73a49', // red
18-
starting: '#2188ff', // blue
19-
stopped: '#6a737d', // gray
20-
};
21-
22-
/** Creates a data-URI SVG of a filled circle with the given color. */
23-
function makeGutterSvgUri(color: string): vscode.Uri {
24-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="6" fill="${color}"/></svg>`;
10+
type GutterCategory = 'running' | 'warning' | 'error' | 'starting' | 'stopped' | 'completed';
11+
12+
const gutterCategories: GutterCategory[] = ['running', 'warning', 'error', 'starting', 'stopped', 'completed'];
13+
14+
/**
15+
* Creates a data-URI SVG gutter icon for each category.
16+
* Uses distinct shapes (not just colored dots) so they aren't confused with breakpoints.
17+
*/
18+
function makeGutterSvgUri(category: GutterCategory): vscode.Uri {
19+
let svg: string;
20+
switch (category) {
21+
case 'running':
22+
// Green checkmark ✅
23+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
24+
<path d="M3 8.5 L6.5 12 L13 4" stroke="#28a745" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
25+
</svg>`;
26+
break;
27+
case 'warning':
28+
// Yellow warning triangle ⚠️
29+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
30+
<path d="M8 2 L14.5 13 H1.5 Z" fill="#e0a30b" stroke="#e0a30b" stroke-width="0.5"/>
31+
<text x="8" y="12.5" text-anchor="middle" font-size="9" font-weight="bold" fill="#000" font-family="sans-serif">!</text>
32+
</svg>`;
33+
break;
34+
case 'error':
35+
// Red X ❌
36+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
37+
<path d="M4 4 L12 12 M12 4 L4 12" stroke="#d73a49" stroke-width="2.5" stroke-linecap="round"/>
38+
</svg>`;
39+
break;
40+
case 'starting':
41+
// Blue hourglass ⌛
42+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
43+
<path d="M4 2 H12 L8 8 L12 14 H4 L8 8 Z" fill="none" stroke="#2188ff" stroke-width="1.5" stroke-linejoin="round"/>
44+
</svg>`;
45+
break;
46+
case 'stopped':
47+
// Grey hollow circle (clearly distinct from solid breakpoint dot)
48+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
49+
<circle cx="8" cy="8" r="5.5" fill="none" stroke="#6a737d" stroke-width="1.5"/>
50+
</svg>`;
51+
break;
52+
case 'completed':
53+
// Pale green checkmark (lighter than running)
54+
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
55+
<path d="M3 8.5 L6.5 12 L13 4" stroke="#69d1a0" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
56+
</svg>`;
57+
break;
58+
}
2559
return vscode.Uri.parse(`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`);
2660
}
2761

2862
const decorationTypes = Object.fromEntries(
2963
gutterCategories.map(c => [c, vscode.window.createTextEditorDecorationType({
30-
gutterIconPath: makeGutterSvgUri(gutterColors[c]),
64+
gutterIconPath: makeGutterSvgUri(c),
3165
gutterIconSize: '70%',
3266
})])
3367
) as Record<GutterCategory, vscode.TextEditorDecorationType>;
3468

35-
function classifyState(state: string, stateStyle: string, healthStatus: string): GutterCategory {
69+
function classifyState(state: string, stateStyle: string, healthStatus: string, exitCode?: number | null): GutterCategory {
3670
switch (state) {
3771
case ResourceState.Running:
3872
case ResourceState.Active:
39-
if (stateStyle === StateStyle.Error || healthStatus === HealthStatus.Unhealthy) {
73+
if (stateStyle === StateStyle.Error) {
4074
return 'error';
4175
}
42-
if (stateStyle === StateStyle.Warning || healthStatus === HealthStatus.Degraded) {
76+
if (healthStatus === HealthStatus.Unhealthy || healthStatus === HealthStatus.Degraded || stateStyle === StateStyle.Warning) {
4377
return 'warning';
4478
}
4579
return 'running';
@@ -50,11 +84,15 @@ function classifyState(state: string, stateStyle: string, healthStatus: string):
5084
case ResourceState.Stopping:
5185
case ResourceState.Building:
5286
case ResourceState.Waiting:
53-
case ResourceState.NotStarted:
5487
return 'starting';
88+
case ResourceState.NotStarted:
89+
return 'stopped';
5590
case ResourceState.Finished:
5691
case ResourceState.Exited:
57-
return stateStyle === StateStyle.Error ? 'error' : 'stopped';
92+
if (stateStyle === StateStyle.Error || (exitCode != null && exitCode !== 0)) {
93+
return 'error';
94+
}
95+
return 'completed';
5896
default:
5997
return 'stopped';
6098
}
@@ -138,7 +176,7 @@ export class AspireGutterDecorationProvider implements vscode.Disposable {
138176
}
139177

140178
const { resource } = match;
141-
const category = classifyState(resource.state ?? '', resource.stateStyle ?? '', resource.healthStatus ?? '');
179+
const category = classifyState(resource.state ?? '', resource.stateStyle ?? '', resource.healthStatus ?? '', resource.exitCode);
142180
buckets.get(category)!.push({ range: editor.document.lineAt(parsed.range.start.line).range });
143181
}
144182

extension/src/extension.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ export async function activate(context: vscode.ExtensionContext) {
8282
const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider);
8383
const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', {
8484
treeDataProvider: appHostTreeProvider,
85+
showCollapseAll: true,
8586
});
87+
appHostTreeProvider.setTreeView(appHostTreeView);
8688

8789
// Global-mode polling is tied to panel visibility
8890
dataRepository.setPanelVisible(appHostTreeView.visible);
@@ -106,6 +108,8 @@ export async function activate(context: vscode.ExtensionContext) {
106108
const copyResourceNameRegistration = vscode.commands.registerCommand('aspire-vscode.copyResourceName', (element) => appHostTreeProvider.copyResourceName(element));
107109
const copyPidRegistration = vscode.commands.registerCommand('aspire-vscode.copyPid', (element) => appHostTreeProvider.copyPid(element));
108110
const copyAppHostPathRegistration = vscode.commands.registerCommand('aspire-vscode.copyAppHostPath', (element) => appHostTreeProvider.copyAppHostPath(element));
111+
const expandAllRegistration = vscode.commands.registerCommand('aspire-vscode.expandAll', (element) => appHostTreeProvider.toggleExpandAll(element));
112+
const collapseAllRegistration = vscode.commands.registerCommand('aspire-vscode.collapseAll', (element) => appHostTreeProvider.collapseAllResources(element));
109113

110114
// Set initial context for welcome view
111115
vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', true);
@@ -114,7 +118,7 @@ export async function activate(context: vscode.ExtensionContext) {
114118
// Activate the data repository (starts workspace describe --follow; global polling begins when the panel is visible)
115119
dataRepository.activate();
116120

117-
context.subscriptions.push(appHostTreeView, refreshRunningAppHostsRegistration, switchToGlobalViewRegistration, switchToWorkspaceViewRegistration, openDashboardRegistration, stopAppHostRegistration, stopResourceRegistration, startResourceRegistration, restartResourceRegistration, viewResourceLogsRegistration, executeResourceCommandRegistration, copyEndpointUrlRegistration, openInExternalBrowserRegistration, openInSimpleBrowserRegistration, copyResourceNameRegistration, copyPidRegistration, copyAppHostPathRegistration, { dispose: () => { appHostTreeProvider.dispose(); dataRepository.dispose(); } });
121+
context.subscriptions.push(appHostTreeView, refreshRunningAppHostsRegistration, switchToGlobalViewRegistration, switchToWorkspaceViewRegistration, openDashboardRegistration, stopAppHostRegistration, stopResourceRegistration, startResourceRegistration, restartResourceRegistration, viewResourceLogsRegistration, executeResourceCommandRegistration, copyEndpointUrlRegistration, openInExternalBrowserRegistration, openInSimpleBrowserRegistration, copyResourceNameRegistration, copyPidRegistration, copyAppHostPathRegistration, expandAllRegistration, collapseAllRegistration, { dispose: () => { appHostTreeProvider.dispose(); dataRepository.dispose(); } });
118122

119123
// CodeLens provider — shows Debug on pipeline steps, resource state on resources
120124
const codeLensProvider = new AspireCodeLensProvider(appHostTreeProvider);

extension/src/loc/strings.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ export const resourceCountDescription = (count: number) => vscode.l10n.t('({0} r
6868
export const tooltipType = (type: string) => vscode.l10n.t('Type: {0}', type);
6969
export const tooltipState = (state: string) => vscode.l10n.t('State: {0}', state);
7070
export const tooltipHealth = (health: string) => vscode.l10n.t('Health: {0}', health);
71+
export const resourceDescriptionWithExitCode = (type: string, exitCode: number) => vscode.l10n.t('{0} · Exit Code: {1}', type, exitCode);
72+
export const resourceDescriptionWithHealth = (type: string, passed: number, total: number) => vscode.l10n.t('{0} · Health: {1}/{2}', type, passed, total);
73+
export const resourceDescriptionWithHealthAndExitCode = (type: string, passed: number, total: number, exitCode: number) => vscode.l10n.t('{0} · Health: {1}/{2} · Exit Code: {3}', type, passed, total, exitCode);
7174
export const tooltipEndpoints = vscode.l10n.t('Endpoints:');
75+
export const healthChecksLabel = vscode.l10n.t('Health Checks');
76+
export const healthCheckDescription = (status: string) => vscode.l10n.t('{0}', status);
7277
export const failedToStartDebugSession = vscode.l10n.t('Failed to start debug session.');
7378
export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed to get Aspire config info (exit code: {0}). Try updating the Aspire CLI with: aspire update', exitCode);
7479
export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error);
@@ -105,8 +110,12 @@ export const codeLensResourceRunning = vscode.l10n.t('$(pass) Running');
105110
export const codeLensResourceRunningWarning = vscode.l10n.t('$(warning) Running');
106111
export const codeLensResourceRunningError = vscode.l10n.t('$(error) Running');
107112
export const codeLensResourceStarting = vscode.l10n.t('$(loading~spin) Starting');
113+
export const codeLensResourceNotStarted = vscode.l10n.t('$(circle-outline) Not Started');
114+
export const codeLensResourceWaiting = vscode.l10n.t('$(loading~spin) Waiting');
108115
export const codeLensResourceStopped = vscode.l10n.t('$(circle-outline) Stopped');
116+
export const codeLensResourceStoppedWithExitCode = (exitCode: number) => vscode.l10n.t('$(circle-outline) Stopped (Exit Code: {0})', exitCode);
109117
export const codeLensResourceStoppedError = vscode.l10n.t('$(error) Stopped');
118+
export const codeLensResourceStoppedErrorWithExitCode = (exitCode: number) => vscode.l10n.t('$(error) Stopped (Exit Code: {0})', exitCode);
110119
export const codeLensResourceError = vscode.l10n.t('$(error) Error');
111120
export const codeLensRestart = vscode.l10n.t('$(debug-restart) Restart');
112121
export const codeLensStop = vscode.l10n.t('$(debug-stop) Stop');

0 commit comments

Comments
 (0)