Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions extension/loc/xlf/aspire-vscode.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,12 @@
"command": "aspire-vscode.codeLensRevealResource",
"title": "%command.codeLensRevealResource%",
"category": "Aspire"
},
{
"command": "aspire-vscode.expandAll",
"title": "%command.expandAll%",
"category": "Aspire",
"icon": "$(expand-all)"
}
],
"jsonValidation": [
Expand Down Expand Up @@ -493,6 +499,10 @@
{
"command": "aspire-vscode.copyPid",
"when": "false"
},
{
"command": "aspire-vscode.expandAll",
"when": "false"
}
],
"view/title": [
Expand All @@ -518,6 +528,11 @@
"when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources)$/",
"group": "inline"
},
{
"command": "aspire-vscode.expandAll",
"when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources)$/",
"group": "inline"
},
{
"command": "aspire-vscode.openAppHostSource",
"when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources)$/",
Expand Down
1 change: 1 addition & 0 deletions extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"command.codeLensResourceAction": "Aspire resource action",
"command.codeLensRevealResource": "Reveal resource in Aspire panel",
"command.codeLensViewLogs": "View Aspire resource logs",
"command.expandAll": "Expand all resources",
"walkthrough.getStarted.title": "Get started with Aspire",
"walkthrough.getStarted.description": "Learn how to create, run, and monitor distributed applications with Aspire.",
"walkthrough.getStarted.welcome.title": "Welcome to Aspire",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 33 additions & 8 deletions extension/src/editor/AspireCodeLensProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ import {
codeLensResourceRunningWarning,
codeLensResourceRunningError,
codeLensResourceStarting,
codeLensResourceNotStarted,
codeLensResourceWaiting,
codeLensResourceStopped,
codeLensResourceStoppedWithExitCode,
codeLensResourceStoppedError,
codeLensResourceStoppedErrorWithExitCode,
codeLensResourceError,
codeLensRestart,
codeLensStop,
Expand Down Expand Up @@ -105,14 +109,31 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider {
const commands = resource.commands ? Object.keys(resource.commands) : [];

// State indicator lens (clickable — reveals resource in tree view)
let stateLabel = getCodeLensStateLabel(state, stateStyle);
let stateLabel = getCodeLensStateLabel(state, stateStyle, resource.exitCode);
if (healthStatus && healthStatus !== HealthStatus.Healthy) {
stateLabel += ` - (${healthStatus})`;
const reports = resource.healthReports;
if (reports) {
const entries = Object.values(reports);
const healthy = entries.filter(r => r.status === HealthStatus.Healthy).length;
stateLabel += ` - (${healthStatus} ${healthy}/${entries.length})`;
} else {
stateLabel += ` - (${healthStatus})`;
}
}

let tooltipText = `${resource.displayName ?? resource.name}: ${state}${healthStatus ? ` (${healthStatus})` : ''}`;
const reports = resource.healthReports;
if (reports && healthStatus && healthStatus !== HealthStatus.Healthy) {
const failing = Object.entries(reports).filter(([, r]) => r.status !== HealthStatus.Healthy);
if (failing.length > 0) {
tooltipText += '\n' + failing.map(([name, r]) => ` ${name}: ${r.status}${r.description ? ` - ${r.description}` : ''}`).join('\n');
}
}

lenses.push(new vscode.CodeLens(range, {
title: stateLabel,
command: 'aspire-vscode.codeLensRevealResource',
tooltip: `${resource.displayName ?? resource.name}: ${state}${healthStatus ? ` (${healthStatus})` : ''}`,
tooltip: tooltipText,
arguments: [resource.displayName ?? resource.name],
}));

Expand Down Expand Up @@ -177,7 +198,7 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider {
}
}

export function getCodeLensStateLabel(state: string, stateStyle: string): string {
export function getCodeLensStateLabel(state: string, stateStyle: string, exitCode?: number | null): string {
switch (state) {
case ResourceState.Running:
case ResourceState.Active:
Expand All @@ -190,19 +211,23 @@ export function getCodeLensStateLabel(state: string, stateStyle: string): string
return codeLensResourceRunning;
case ResourceState.Starting:
case ResourceState.Building:
return codeLensResourceStarting;
case ResourceState.Waiting:
return codeLensResourceWaiting;
case ResourceState.NotStarted:
return codeLensResourceStarting;
return codeLensResourceNotStarted;
case ResourceState.FailedToStart:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Stopping reuses codeLensResourceStarting, which renders as "$(loading~spin) Starting". When a resource is actively stopping, users will see "Starting" — the opposite of what's happening. The spinner icon is appropriate, but the text should say "Stopping". Consider adding a codeLensResourceStopping string (e.g., $(loading~spin) Stopping).

The test at line 95 ('Stopping returns starting label') asserts this incorrect behavior too.

case ResourceState.RuntimeUnhealthy:
return codeLensResourceError;
case ResourceState.Stopping:
return codeLensResourceStarting;
case ResourceState.Finished:
case ResourceState.Exited:
case ResourceState.Stopping:
case ResourceState.Stopped:
if (stateStyle === StateStyle.Error) {
return codeLensResourceStoppedError;
return exitCode != null ? codeLensResourceStoppedErrorWithExitCode(exitCode) : codeLensResourceStoppedError;
}
return codeLensResourceStopped;
return exitCode != null ? codeLensResourceStoppedWithExitCode(exitCode) : codeLensResourceStopped;
default:
return state || codeLensResourceStopped;
}
Expand Down
83 changes: 61 additions & 22 deletions extension/src/editor/AspireGutterDecorationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,73 @@ import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider';
import { findResourceState, findWorkspaceResourceState } from './resourceStateUtils';
import { ResourceState, StateStyle, HealthStatus } from './resourceConstants';

type GutterCategory = 'running' | 'warning' | 'error' | 'starting' | 'stopped';

const gutterCategories: GutterCategory[] = ['running', 'warning', 'error', 'starting', 'stopped'];

const gutterColors: Record<GutterCategory, string> = {
running: '#28a745', // green
warning: '#e0a30b', // yellow/amber
error: '#d73a49', // red
starting: '#2188ff', // blue
stopped: '#6a737d', // gray
};

/** Creates a data-URI SVG of a filled circle with the given color. */
function makeGutterSvgUri(color: string): vscode.Uri {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="8" cy="8" r="6" fill="${color}"/></svg>`;
type GutterCategory = 'running' | 'warning' | 'error' | 'starting' | 'stopped' | 'completed';

const gutterCategories: GutterCategory[] = ['running', 'warning', 'error', 'starting', 'stopped', 'completed'];

/**
* Creates a data-URI SVG gutter icon for each category.
* Uses distinct shapes (not just colored dots) so they aren't confused with breakpoints.
*/
function makeGutterSvgUri(category: GutterCategory): vscode.Uri {
let svg: string;
switch (category) {
case 'running':
// Green checkmark ✅
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M3 8.5 L6.5 12 L13 4" stroke="#28a745" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
break;
case 'warning':
// Yellow warning triangle ⚠️
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M8 2 L14.5 13 H1.5 Z" fill="#e0a30b" stroke="#e0a30b" stroke-width="0.5"/>
<text x="8" y="12.5" text-anchor="middle" font-size="9" font-weight="bold" fill="#000" font-family="sans-serif">!</text>
</svg>`;
break;
case 'error':
// Red X ❌
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M4 4 L12 12 M12 4 L4 12" stroke="#d73a49" stroke-width="2.5" stroke-linecap="round"/>
</svg>`;
break;
case 'starting':
// Blue hourglass ⌛
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M4 2 H12 L8 8 L12 14 H4 L8 8 Z" fill="none" stroke="#2188ff" stroke-width="1.5" stroke-linejoin="round"/>
</svg>`;
break;
case 'stopped':
// Grey hollow circle (clearly distinct from solid breakpoint dot)
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<circle cx="8" cy="8" r="5.5" fill="none" stroke="#6a737d" stroke-width="1.5"/>
</svg>`;
break;
case 'completed':
// Pale green checkmark (lighter than running)
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<path d="M3 8.5 L6.5 12 L13 4" stroke="#69d1a0" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
break;
}
return vscode.Uri.parse(`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`);
}

const decorationTypes = Object.fromEntries(
gutterCategories.map(c => [c, vscode.window.createTextEditorDecorationType({
gutterIconPath: makeGutterSvgUri(gutterColors[c]),
gutterIconPath: makeGutterSvgUri(c),
gutterIconSize: '70%',
})])
) as Record<GutterCategory, vscode.TextEditorDecorationType>;

function classifyState(state: string, stateStyle: string, healthStatus: string): GutterCategory {
function classifyState(state: string, stateStyle: string, healthStatus: string, exitCode?: number | null): GutterCategory {
switch (state) {
case ResourceState.Running:
case ResourceState.Active:
if (stateStyle === StateStyle.Error || healthStatus === HealthStatus.Unhealthy) {
if (stateStyle === StateStyle.Error) {
return 'error';
}
if (stateStyle === StateStyle.Warning || healthStatus === HealthStatus.Degraded) {
if (healthStatus === HealthStatus.Unhealthy || healthStatus === HealthStatus.Degraded || stateStyle === StateStyle.Warning) {
return 'warning';
}
return 'running';
Expand All @@ -50,11 +84,16 @@ function classifyState(state: string, stateStyle: string, healthStatus: string):
case ResourceState.Stopping:
case ResourceState.Building:
case ResourceState.Waiting:
case ResourceState.NotStarted:
return 'starting';
case ResourceState.NotStarted:
return 'stopped';
case ResourceState.Finished:
case ResourceState.Exited:
return stateStyle === StateStyle.Error ? 'error' : 'stopped';
case ResourceState.Stopped:
if (stateStyle === StateStyle.Error || (exitCode != null && exitCode !== 0)) {
return 'error';
}
return 'completed';
default:
return 'stopped';
}
Expand Down Expand Up @@ -138,7 +177,7 @@ export class AspireGutterDecorationProvider implements vscode.Disposable {
}

const { resource } = match;
const category = classifyState(resource.state ?? '', resource.stateStyle ?? '', resource.healthStatus ?? '');
const category = classifyState(resource.state ?? '', resource.stateStyle ?? '', resource.healthStatus ?? '', resource.exitCode);
buckets.get(category)!.push({ range: editor.document.lineAt(parsed.range.start.line).range });
}

Expand Down
5 changes: 4 additions & 1 deletion extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export async function activate(context: vscode.ExtensionContext) {
const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider);
const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', {
treeDataProvider: appHostTreeProvider,
showCollapseAll: true,
});
appHostTreeProvider.setTreeView(appHostTreeView);

// Global-mode polling is tied to panel visibility
dataRepository.setPanelVisible(appHostTreeView.visible);
Expand All @@ -107,6 +109,7 @@ export async function activate(context: vscode.ExtensionContext) {
const copyResourceNameRegistration = vscode.commands.registerCommand('aspire-vscode.copyResourceName', (element) => appHostTreeProvider.copyResourceName(element));
const copyPidRegistration = vscode.commands.registerCommand('aspire-vscode.copyPid', (element) => appHostTreeProvider.copyPid(element));
const copyAppHostPathRegistration = vscode.commands.registerCommand('aspire-vscode.copyAppHostPath', (element) => appHostTreeProvider.copyAppHostPath(element));
const expandAllRegistration = vscode.commands.registerCommand('aspire-vscode.expandAll', (element) => appHostTreeProvider.expandAll(element));

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

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

// CodeLens provider — shows Debug on pipeline steps, resource state on resources
const codeLensProvider = new AspireCodeLensProvider(appHostTreeProvider);
Expand Down
9 changes: 8 additions & 1 deletion extension/src/loc/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ export const cliPidLabel = (pid: number) => vscode.l10n.t('CLI PID: {0}', pid);
export const appHostPidLabel = (pid: number) => vscode.l10n.t('Apphost PID: {0}', pid);
export const errorFetchingAppHosts = (error: string) => vscode.l10n.t('Error fetching running apphosts: {0}', error);
export const resourcesGroupLabel = vscode.l10n.t('Resources');
export const resourceStateLabel = (name: string, state: string) => vscode.l10n.t('{0} — {1}', name, state);
export const noCommandsAvailable = vscode.l10n.t('No commands available for this resource.');
export const selectCommandPlaceholder = vscode.l10n.t('Select a command to execute');
export const selectDashboardPlaceholder = vscode.l10n.t('Select a dashboard to open');
Expand All @@ -69,6 +68,10 @@ export const tooltipType = (type: string) => vscode.l10n.t('Type: {0}', type);
export const tooltipState = (state: string) => vscode.l10n.t('State: {0}', state);
export const tooltipHealth = (health: string) => vscode.l10n.t('Health: {0}', health);
export const tooltipEndpoints = vscode.l10n.t('Endpoints:');
export const healthChecksLabel = vscode.l10n.t('Health Checks');
export const healthCheckDescription = (status: string) => vscode.l10n.t('Status: {0}', status);
export const resourceDescriptionHealth = (passed: number, total: number) => vscode.l10n.t('Health: {0}/{1}', passed, total);
export const resourceDescriptionExitCode = (exitCode: number) => vscode.l10n.t('Exit Code: {0}', exitCode);
export const failedToStartDebugSession = vscode.l10n.t('Failed to start debug session.');
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);
export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error);
Expand Down Expand Up @@ -107,8 +110,12 @@ export const codeLensResourceRunning = vscode.l10n.t('$(pass) Running');
export const codeLensResourceRunningWarning = vscode.l10n.t('$(warning) Running');
export const codeLensResourceRunningError = vscode.l10n.t('$(error) Running');
export const codeLensResourceStarting = vscode.l10n.t('$(loading~spin) Starting');
export const codeLensResourceNotStarted = vscode.l10n.t('$(circle-outline) Not Started');
export const codeLensResourceWaiting = vscode.l10n.t('$(loading~spin) Waiting');
export const codeLensResourceStopped = vscode.l10n.t('$(circle-outline) Stopped');
export const codeLensResourceStoppedWithExitCode = (exitCode: number) => vscode.l10n.t('$(circle-outline) Stopped (Exit Code: {0})', exitCode);
export const codeLensResourceStoppedError = vscode.l10n.t('$(error) Stopped');
export const codeLensResourceStoppedErrorWithExitCode = (exitCode: number) => vscode.l10n.t('$(error) Stopped (Exit Code: {0})', exitCode);
export const codeLensResourceError = vscode.l10n.t('$(error) Error');
export const codeLensRestart = vscode.l10n.t('$(debug-restart) Restart');
export const codeLensStop = vscode.l10n.t('$(debug-stop) Stop');
Expand Down
Loading
Loading