diff --git a/docs/specs/cli-config-sources.md b/docs/specs/cli-config-sources.md index dd136f3dcef..15984e3c228 100644 --- a/docs/specs/cli-config-sources.md +++ b/docs/specs/cli-config-sources.md @@ -434,7 +434,7 @@ aspire new Only `templateSources` participates in this runtime overlay. Other remote settings retain their existing allowlisted project-materialization behavior. Project-level `templateSources` are not used. Sources must satisfy the same complete-definition validation as global template discovery; malformed or inaccessible init configuration fails explicitly. Template repository discovery failures retain the normal per-source warnings. -The optional `displayName` supplies the terminal selector's source heading. Missing or blank values use the alias; aliases remain the source and group identity even when headings match. +The optional `displayName` supplies the friendly name in the terminal selector's source heading. Missing or blank values use the alias. Git headings also show the configured repository and ref (`default branch` when omitted); directory headings show the local path instead. Aliases remain the source and group identity even when headings match. Merge source names case-insensitively with global `templateSources`. The user's entire global definition wins on a collision: do not retain an inherited `repository`, `ref`, `path`, or `displayName` when the user replaces that source with a local directory or a different repository. Non-colliding global and inherited sources are both searchable and listed, including their branch refs. diff --git a/docs/specs/git-templating.md b/docs/specs/git-templating.md index 2a7be4a06b1..cc0b13aac8a 100644 --- a/docs/specs/git-templating.md +++ b/docs/specs/git-templating.md @@ -117,6 +117,13 @@ and control characters are rejected. Set an existing source's heading with `aspire config set templateSources.company.displayName "Company templates" --global`. The alias remains the lookup key and group identity, so identical display names do not merge sources or alter explicit invocations, warnings, or flat-client labels. +Git source headings append the configured repository and ref, for example +`Company templates (https://github.com/example/templates @ release/templates)`. +An omitted ref is shown as `default branch`, not a guessed branch name or the +resolved commit. Directory sources append their local path instead, for example +`Company templates (/work/templates)`. Configured source headings are green; +built-in headings keep their default styling. Headings are escaped as literal text and +cropped to the available terminal-cell width, just like template labels. Global overrides replace the entire inherited definition, including `displayName`; inherited values are never saved to global or generated-project configuration. diff --git a/src/Aspire.Cli/Commands/NewCommand.cs b/src/Aspire.Cli/Commands/NewCommand.cs index 7b0d11439c9..f65fcf097d8 100644 --- a/src/Aspire.Cli/Commands/NewCommand.cs +++ b/src/Aspire.Cli/Commands/NewCommand.cs @@ -394,10 +394,26 @@ private static void AddConfiguredTemplateChoices( var label = GetUniqueTemplateLabel( $"{result.Template.Description} ({result.Template.Name}) - {result.SourceName}", labels); choices.Add(new(label, result.Template, true, - new SelectionChoiceGroup($"source:{result.SourceName}", result.SourceDisplayName ?? result.SourceName))); + CreateTemplateSourceGroup(result))); } } + internal static SelectionChoiceGroup CreateTemplateSourceGroup(ConfiguredTemplate template) + { + var name = template.SourceDisplayName ?? template.SourceName; + var source = template.Source; + // Use the configured ref, not the acquired commit: an omitted ref means the + // repository's default branch. Configuration validation rejects embedded credentials. + var location = source.Repository is null + ? source.Path + : $"{source.Repository} @ {source.RepositoryRef ?? ConfigSourceStrings.DefaultBranch}"; + + return new($"source:{template.SourceName}", $"{name} ({location})") + { + Style = new Style(Color.Green) + }; + } + internal static string FormatTemplateDisplayLabel(ITemplate template) => string.IsNullOrWhiteSpace(template.SearchDescription) ? template.Description diff --git a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs index 953a72e6d23..18892245a0f 100644 --- a/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs +++ b/src/Aspire.Cli/Interaction/ConsoleInteractionService.cs @@ -352,7 +352,15 @@ private async Task PromptForSelectionCoreAsync(string promptText, IEnumera // two more cells for grouped children. Its converter also controls // search, so only the visible prefix remains searchable after cropping. // https://github.com/spectreconsole/spectre.console/blob/0.57.2/src/Spectre.Console/Prompts/SelectionPrompt.cs - .UseConverter(item => FormatSelectionLabel(item.Label, MessageConsole.Profile.Width - item.Indent - 3)) + .UseConverter(item => + { + var label = FormatSelectionLabel(item.Label, MessageConsole.Profile.Width - item.Indent - 3); + // Apply the trusted presentation style after cropping and escaping the + // source label so source-provided markup cannot change its appearance. + return item is SelectionPromptGroup { Style: { } style } + ? $"[{style.ToMarkup()}]{label}[/]" + : label; + }) .PageSize(10) .EnableSearch(); prompt.Mode = SelectionMode.Leaf; @@ -375,7 +383,7 @@ private async Task PromptForSelectionCoreAsync(string promptText, IEnumera { // Only create headers for populated groups. An empty Spectre group is // a leaf and would otherwise become a selectable value. - header = prompt.AddChoice(new SelectionPromptGroup(group.Label.EscapeMarkup())); + header = prompt.AddChoice(new SelectionPromptGroup(group.Label.EscapeMarkup(), group.Style)); groups.Add(group.Id, header); } header.AddChild(item); @@ -948,5 +956,5 @@ internal IReadOnlyList MatchChoicesOrThrow(string value, PromptBinding(T Value, string Text, bool Grouped) : SelectionPromptItem(Text, Grouped ? 2 : 0) where T : notnull; - private sealed record SelectionPromptGroup(string Text) : SelectionPromptItem(Text, 0); + private sealed record SelectionPromptGroup(string Text, Style? Style) : SelectionPromptItem(Text, 0); } diff --git a/src/Aspire.Cli/Interaction/SelectionChoiceGroup.cs b/src/Aspire.Cli/Interaction/SelectionChoiceGroup.cs index b52e8e7695c..f78edac112a 100644 --- a/src/Aspire.Cli/Interaction/SelectionChoiceGroup.cs +++ b/src/Aspire.Cli/Interaction/SelectionChoiceGroup.cs @@ -1,6 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Spectre.Console; + namespace Aspire.Cli.Interaction; -internal sealed record SelectionChoiceGroup(string Id, string Label); +internal sealed record SelectionChoiceGroup(string Id, string Label) +{ + public Style? Style { get; init; } +} diff --git a/src/Aspire.Cli/Templating/ConfiguredTemplateSourceService.cs b/src/Aspire.Cli/Templating/ConfiguredTemplateSourceService.cs index 01b638f87aa..338a7a3aa1b 100644 --- a/src/Aspire.Cli/Templating/ConfiguredTemplateSourceService.cs +++ b/src/Aspire.Cli/Templating/ConfiguredTemplateSourceService.cs @@ -91,7 +91,7 @@ public async Task> SearchAsync(string query, C .Where(template => template.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || template.Description.Contains(query, StringComparison.OrdinalIgnoreCase) || template.SearchDescription?.Contains(query, StringComparison.OrdinalIgnoreCase) is true) - .Select(template => new ConfiguredTemplate(name, template, configuredSource.DisplayName))); + .Select(template => new ConfiguredTemplate(name, template, configuredSource.DisplayName, source))); } catch (Exception ex) when (ex is TemplateDiscoveryException or IOException or UnauthorizedAccessException or ArgumentException) { @@ -110,4 +110,4 @@ private void Warn(string source, string message) => CultureInfo.CurrentCulture, NewCommandStrings.TemplateSourceSearchFailed, source, message)); } -internal sealed record ConfiguredTemplate(string SourceName, ITemplate Template, string? SourceDisplayName); +internal sealed record ConfiguredTemplate(string SourceName, ITemplate Template, string? SourceDisplayName, TemplateSource Source); diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.ConfiguredGitSources.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.ConfiguredGitSources.cs index f9b925bffe8..e54b77c17bd 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.ConfiguredGitSources.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.ConfiguredGitSources.cs @@ -14,12 +14,52 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console; using RootCommand = Aspire.Cli.Commands.RootCommand; namespace Aspire.Cli.Tests.Commands; public partial class NewCommandLocalTemplateTests { + [Theory] + [InlineData(null, null)] + [InlineData("release/templates", null)] + [InlineData("refs/heads/release/templates", "Company [templates]")] + [InlineData(TestGitCommandRunner.DefaultCommitSha, "Company [templates]")] + public async Task ConfiguredGitSources_PickerShowsConfiguredRepositoryAndRef(string? reference, string? displayName) + { + const string repository = "https://example.test/templates.git"; + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var interaction = new TestInteractionService + { + PromptForSelectionCallback = (_, choices, _, _) => choices.Cast().Last() + }; + interaction.SetupStringPromptResponse("Hello"); + var git = TestGitCommandRunner.CreateForTemplateCheckout((_, destination, _, _) => + CreateClonedSearchTemplateAsync(workspace, destination, "Remote")); + var services = CreateServices(workspace, interaction, interactive: true); + services.AddSingleton(git); + using var provider = services.BuildServiceProvider(); + await WriteConfiguredSourcesAsync(provider, new() + { + ["company"] = new { repository, @ref = reference, displayName } + }); + + var exitCode = await provider.GetRequiredService() + .Parse("new --name Demo --output out --suppress-agent-init").InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + var choice = Assert.Single(interaction.GroupedSelectionPromptCalls)[^1]; + Assert.Equal(new SelectionChoiceGroup("source:company", + $"{displayName ?? "company"} ({repository} @ {reference ?? ConfigSourceStrings.DefaultBranch})") + { + Style = new Style(Color.Green) + }, choice.Group); + Assert.Equal("Sample template", choice.DisplayLabel); + Assert.Equal("Remote: Hello", await File.ReadAllTextAsync(Path.Combine(workspace.Path, "out", "Demo.txt"))); + Assert.Single(GetGitFetches(git)); + } + [Fact] public async Task ConfiguredGitSources_WarnsOnEachInvocationWithCachedTemplates() { diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.ConfiguredSources.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.ConfiguredSources.cs index 5a80c217c94..223cbccb844 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.ConfiguredSources.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.ConfiguredSources.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Spectre.Console; using RootCommand = Aspire.Cli.Commands.RootCommand; namespace Aspire.Cli.Tests.Commands; @@ -107,6 +108,7 @@ await Assert.ThrowsAsync(() => [InlineData("""{ "repository": "http://example.test/templates" }""")] [InlineData("""{ "repository": "git@example.test:templates" }""")] [InlineData("""{ "repository": "https://token@example.test/templates" }""")] + [InlineData("""{ "repository": "ssh://git:password@example.test/templates" }""")] [InlineData("""{ "repository": "https://example.test/templates?q=search" }""")] [InlineData("""{ "repository": "https://example.test/templates#main" }""")] [InlineData("""{ "repository": "https://example.test/templates", "ref": 42 }""")] @@ -335,8 +337,8 @@ public async Task ConfiguredSources_SelectsExactTemplateWithDuplicateNamesAndPro Assert.Equal(1, selectionCount); var groupedChoices = Assert.Single(interaction.GroupedSelectionPromptCalls); Assert.Equal( - [new SelectionChoiceGroup("source:first-source", "Company [templates]"), - new SelectionChoiceGroup("source:second-source", "Company [templates]")], + [new SelectionChoiceGroup("source:first-source", $"Company [templates] ({Path.Combine(workspace.Path, "first")})") { Style = new Style(Color.Green) }, + new SelectionChoiceGroup("source:second-source", $"Company [templates] ({Path.Combine(workspace.Path, "second")})") { Style = new Style(Color.Green) }], groupedChoices.TakeLast(2).Select(choice => choice.Group)); Assert.Equal(["Same template", "Same template"], groupedChoices.TakeLast(2).Select(choice => choice.DisplayLabel)); Assert.All(groupedChoices.SkipLast(2), choice => @@ -371,6 +373,8 @@ public async Task ConfiguredSources_DescriptionAppearsInPickerWithoutSeparateSea Assert.Equal(CliExitCodes.Success, exitCode); Assert.Equal(1, selectionCount); Assert.Equal("Sample (Observable services)", Assert.Single(interaction.GroupedSelectionPromptCalls)[^1].DisplayLabel); + Assert.Equal(new SelectionChoiceGroup("source:company", $"company ({Path.Combine(workspace.Path, "template")})") { Style = new Style(Color.Green) }, + Assert.Single(interaction.GroupedSelectionPromptCalls)[^1].Group); Assert.Empty(interaction.DisplayedMessages); Assert.Equal(["Greeting text"], interaction.StringPromptCalls.Select(call => call.PromptText)); Assert.Equal([true], interaction.StringPromptLogResponses); diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.InitConfig.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.InitConfig.cs index d5b63da6225..c731bfe53a2 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.InitConfig.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandLocalTemplateTests.InitConfig.cs @@ -12,6 +12,7 @@ using Aspire.Cli.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; using RootCommand = Aspire.Cli.Commands.RootCommand; namespace Aspire.Cli.Tests.Commands; @@ -162,7 +163,8 @@ public async Task InitConfigSources_SearchAndGenerateWithGlobalSourcesWithoutPer Assert.Equal(1, selectionCount); Assert.Equal(1, git.Calls); Assert.Equal( - [new SelectionChoiceGroup("source:enterprise", "Team [templates]"), new SelectionChoiceGroup("source:personal", "Team [templates]")], + [new SelectionChoiceGroup("source:enterprise", $"Team [templates] ({Path.Combine(workspace.Path, "enterprise")})") { Style = new Style(Color.Green) }, + new SelectionChoiceGroup("source:personal", $"Team [templates] ({Path.Combine(workspace.Path, "personal")})") { Style = new Style(Color.Green) }], Assert.Single(interaction.GroupedSelectionPromptCalls).TakeLast(2).Select(choice => choice.Group)); Assert.Equal(originalGlobal, await File.ReadAllTextAsync(globalPath)); var projectConfig = Path.Combine(workspace.Path, "out", AspireConfigFile.FileName); diff --git a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs index 687a7246efa..f1375467adf 100644 --- a/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Interaction/ConsoleInteractionServiceTests.cs @@ -199,6 +199,32 @@ public async Task PromptForSelectionAsync_GroupedLabelsFitWidthAndNormalizeMetad await Verify(output.ToString()).ScrubLinesWithReplace(line => line.TrimEnd()); } + [Theory] + [InlineData(false, 120)] + [InlineData(true, 120)] + [InlineData(false, 40)] + [InlineData(true, 40)] + public async Task PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth(bool gitSource, int width) + { + var output = new StringBuilder(); + var console = CreateSelectionConsole(output, [new('\r', ConsoleKey.Enter, false, false, false)]); + console.Profile.Width = width; + console.Profile.Capabilities.ColorSystem = ColorSystem.Standard; + var interaction = CreateInteractionService(console); + var template = CreateSearchableTemplate("first", null); + var source = gitSource + ? new TemplateSource(".", "https://example.test/organization/[templates]/long-location", "release/[blue]") + : new TemplateSource("/work/[templates]/long-directory-location", null, null); + var configured = new ConfiguredTemplate("source", template, "Team [templates]", source); + + var selected = await interaction.PromptForSelectionAsync("Select:", new[] { template }, item => item.Name, + _ => NewCommand.CreateTemplateSourceGroup(configured), + item => NewCommand.FormatTemplateDisplayLabel(item).EscapeMarkup()); + + Assert.Same(template, selected); + await Verify(output.ToString()).UseParameters(gitSource, width).ScrubLinesWithReplace(line => line.TrimEnd()); + } + [Theory] [InlineData(6)] [InlineData(10)] diff --git a/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=False_width=120.verified.txt b/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=False_width=120.verified.txt new file mode 100644 index 00000000000..14f9328940e --- /dev/null +++ b/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=False_width=120.verified.txt @@ -0,0 +1,7 @@ +[?25lSelect: + + Team [templates] (/work/[templates]/long-directory-location) + > Same + +(Type to search) +[?25hSelect: Same diff --git a/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=False_width=40.verified.txt b/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=False_width=40.verified.txt new file mode 100644 index 00000000000..8b584c81902 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=False_width=40.verified.txt @@ -0,0 +1,7 @@ +[?25lSelect: + + Team [templates] (/work/[templates]/… + > Same + +(Type to search) +[?25hSelect: Same diff --git a/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=True_width=120.verified.txt b/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=True_width=120.verified.txt new file mode 100644 index 00000000000..b1e40a56430 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=True_width=120.verified.txt @@ -0,0 +1,7 @@ +[?25lSelect: + + Team [templates] (https://example.test/organization/[templates]/long-location @ release/[blue]) + > Same + +(Type to search) +[?25hSelect: Same diff --git a/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=True_width=40.verified.txt b/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=True_width=40.verified.txt new file mode 100644 index 00000000000..492a257b546 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Snapshots/ConsoleInteractionServiceTests.PromptForSelectionAsync_TemplateSourceHeadingsAreLiteralAndFitWidth_gitSource=True_width=40.verified.txt @@ -0,0 +1,7 @@ +[?25lSelect: + + Team [templates] (https://example.te… + > Same + +(Type to search) +[?25hSelect: Same