Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
11 changes: 9 additions & 2 deletions extension/src/editor/AspireCodeLensProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,13 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider {
if (resource.commands) {
for (const [cmdName, cmd] of Object.entries(resource.commands) as [string, ResourceCommandJson][]) {
if (!standardCommands.has(cmdName)) {
const label = codeLensCommand(cmd.description ?? cmdName);
const displayName = getNormalizedCommandText(cmd.displayName);
const description = getNormalizedCommandText(cmd.description);
const label = codeLensCommand(displayName ?? cmdName);
lenses.push(new vscode.CodeLens(range, {
title: label,
command: 'aspire-vscode.codeLensResourceAction',
tooltip: cmd.description ?? cmdName,
tooltip: description ?? displayName ?? cmdName,
arguments: [resource.name, cmdName, appHost.appHostPath],
}));
}
Expand Down Expand Up @@ -339,3 +341,8 @@ export function getCodeLensStateLabel(state: string, stateStyle: string, exitCod
return state || codeLensResourceStopped;
}
}

function getNormalizedCommandText(value: string | null | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
18 changes: 9 additions & 9 deletions extension/src/test/appHostTreeView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,48 +351,48 @@ suite('getResourceContextValue', () => {

test('resource with start command', () => {
const result = getResourceContextValue(makeResource({
commands: { 'start': { description: null } },
commands: { 'start': { displayName: null, description: null } },
}));
assert.strictEqual(result, 'resource:canStart');
});

test('resource with resource-start command', () => {
const result = getResourceContextValue(makeResource({
commands: { 'resource-start': { description: null } },
commands: { 'resource-start': { displayName: null, description: null } },
}));
assert.strictEqual(result, 'resource:canStart');
});

test('resource with stop command', () => {
const result = getResourceContextValue(makeResource({
commands: { 'stop': { description: null } },
commands: { 'stop': { displayName: null, description: null } },
}));
assert.strictEqual(result, 'resource:canStop');
});

test('resource with all lifecycle commands', () => {
const result = getResourceContextValue(makeResource({
commands: {
'start': { description: null },
'stop': { description: null },
'restart': { description: null },
'start': { displayName: null, description: null },
'stop': { displayName: null, description: null },
'restart': { displayName: null, description: null },
},
}));
assert.strictEqual(result, 'resource:canStart:canStop:canRestart');
});

test('resource with non-lifecycle commands has base context only', () => {
const result = getResourceContextValue(makeResource({
commands: { 'custom-action': { description: 'do something' } },
commands: { 'custom-action': { displayName: null, description: 'do something' } },
}));
assert.strictEqual(result, 'resource');
});

test('resource with mixed lifecycle and custom commands', () => {
const result = getResourceContextValue(makeResource({
commands: {
'restart': { description: null },
'custom-action': { description: null },
'restart': { displayName: null, description: null },
'custom-action': { displayName: null, description: null },
},
}));
assert.strictEqual(result, 'resource:canRestart');
Expand Down
67 changes: 66 additions & 1 deletion extension/src/test/aspireCodeLensProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ function makeAppHost(appHostPath: string): AppHostDisplayInfo {
} as unknown as AppHostDisplayInfo;
}

function makeResource(name: string): ResourceJson {
function makeResource(name: string, overrides: Partial<ResourceJson> = {}): ResourceJson {
return {
name,
displayName: name,
Expand All @@ -97,6 +97,7 @@ function makeResource(name: string): ResourceJson {
stateStyle: '',
commands: {},
endpoints: [],
...overrides,
} as unknown as ResourceJson;
}

Expand Down Expand Up @@ -399,4 +400,68 @@ suite('AspireCodeLensProvider resource lens anchoring', () => {
);
harness.dispose();
});

test('custom command lens uses displayName as label and description as tooltip', () => {
const docPath = p('repo', 'AppHost', 'apphost.ts');
const hostPath = p('repo', 'AppHost', 'apphost.ts');
const content = [
'const builder = await createBuilder();',
'builder.addRedis("cache");',
].join('\n');

const harness = createHarness({
workspaceAppHostPath: hostPath,
workspaceResources: [makeResource('cache', {
commands: {
'reset-db': {
displayName: 'Reset Database',
description: 'Stop the resource, rebuild the project from source, and restart it.',
},
},
})],
});

const doc = createMockDocument(content, docPath);
const lenses = harness.provider.provideCodeLenses(doc, cancellationToken) as vscode.CodeLens[];
const customLens = lenses.find(l =>
l.command?.command === 'aspire-vscode.codeLensResourceAction'
&& l.command?.arguments?.[1] === 'reset-db');

assert.ok(customLens);
assert.strictEqual(customLens!.command?.title, 'Command: Reset Database');
assert.strictEqual(customLens!.command?.tooltip, 'Stop the resource, rebuild the project from source, and restart it.');
harness.dispose();
});

test('custom command lens falls back to command name when display text is whitespace', () => {
const docPath = p('repo', 'AppHost', 'apphost.ts');
const hostPath = p('repo', 'AppHost', 'apphost.ts');
const content = [
'const builder = await createBuilder();',
'builder.addRedis("cache");',
].join('\n');

const harness = createHarness({
workspaceAppHostPath: hostPath,
workspaceResources: [makeResource('cache', {
commands: {
'reset-db': {
displayName: ' ',
description: ' ',
},
},
})],
});

const doc = createMockDocument(content, docPath);
const lenses = harness.provider.provideCodeLenses(doc, cancellationToken) as vscode.CodeLens[];
const customLens = lenses.find(l =>
l.command?.command === 'aspire-vscode.codeLensResourceAction'
&& l.command?.arguments?.[1] === 'reset-db');

assert.ok(customLens);
assert.strictEqual(customLens!.command?.title, 'Command: reset-db');
assert.strictEqual(customLens!.command?.tooltip, 'reset-db');
harness.dispose();
});
});
1 change: 1 addition & 0 deletions extension/src/views/AppHostDataRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface ResourceUrlJson {
}

export interface ResourceCommandJson {
displayName: string | null;
description: string | null;
}

Expand Down
5 changes: 3 additions & 2 deletions src/Aspire.Cli/Backchannel/ResourceSnapshotMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ public static ResourceJson MapToResourceJson(ResourceSnapshot snapshot, IReadOnl
.OrderBy(c => c.Name)
.ToDistinctDictionary(
c => c.Name,
c => new ResourceCommandJson
{
c => new ResourceCommandJson
{
DisplayName = string.IsNullOrWhiteSpace(c.DisplayName) ? null : c.DisplayName.Trim(),
Description = c.Description,
Visibility = IsDefaultCommandVisibility(c.Visibility) ? null : c.Visibility,
ArgumentInputs = c.ArgumentInputs.Length > 0
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Model/TelemetryExportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,7 @@ internal static ResourceJson CreateResourceJson(ResourceViewModel resource, IRea
c => c.Name,
c => new ResourceCommandJson
{
DisplayName = c.GetDisplayName(),
Description = c.GetDisplayDescription()
})
: null,
Expand Down
5 changes: 5 additions & 0 deletions src/Shared/Model/Serialization/ResourceJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ internal sealed class ResourceRelationshipJson
/// </summary>
internal sealed class ResourceCommandJson
{
/// <summary>
/// The display name of the command.
/// </summary>
public string? DisplayName { get; set; }

/// <summary>
/// The description of the command.
/// </summary>
Expand Down
29 changes: 29 additions & 0 deletions tests/Aspire.Cli.Tests/Backchannel/ResourceSnapshotMapperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,35 @@ public void MapToResourceJson_WithPopulatedProperties_MapsCorrectly()
Assert.Contains("localhost:18080", result.DashboardUrl);
}

[Fact]
public void MapToResourceJson_WithWhitespaceCommandDisplayName_MapsDisplayNameToNull()
{
var snapshot = new ResourceSnapshot
{
Name = "frontend",
DisplayName = "frontend",
ResourceType = "Project",
State = "Running",
Commands =
[
new ResourceSnapshotCommand
{
Name = "custom-command",
State = "Enabled",
DisplayName = " ",
Description = "Run custom command",
Visibility = KnownCommandVisibility.Api
}
]
};

var result = ResourceSnapshotMapper.MapToResourceJson(snapshot, [snapshot]);

var command = Assert.Single(result.Commands!);
Assert.Null(command.Value.DisplayName);
Assert.Equal("Run custom command", command.Value.Description);
}

[Fact]
public void ResolveResources_ByExactName_ReturnsMatch()
{
Expand Down
Loading