Skip to content
Merged
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
16 changes: 11 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,17 @@ jobs:
grep -q ':root' wwwroot/input.css || (echo "init did not write :root variables to input.css"; exit 1)
grep -q '\.dark' wwwroot/input.css || (echo "init did not write .dark variables to input.css"; exit 1)

shellui add chart pie-chart dashboard-02 --force || true
# Chart components reference ApexCharts.* types but the CLI does not yet
# auto-add the runtime NuGet dep. Drop this line once `shellui add chart`
# declares Blazor-ApexCharts as a nuget dependency.
dotnet add package Blazor-ApexCharts --version 6.0.2
shellui add chart pie-chart dashboard-02 data-table --force

# NuGet dependencies (Blazor-ApexCharts, System.Linq.Dynamic.Core) should
# now be added automatically by `shellui add`. Assert they appear in the
# project file so a regression in the auto-install fails loudly here.
grep -q 'Blazor-ApexCharts' SmokeApp.csproj || (echo "shellui add chart did not add Blazor-ApexCharts NuGet dep"; exit 1)
grep -q 'System.Linq.Dynamic.Core' SmokeApp.csproj || (echo "shellui add data-table did not add System.Linq.Dynamic.Core NuGet dep"; exit 1)

# And the DataTable models file must land at Components/UI/Models/, not be missing.
test -f Components/UI/Models/DataTableModels.cs || (echo "shellui add data-table did not install data-table-models"; exit 1)

dotnet build -c Debug

- name: Upload build artifacts
Expand Down
104 changes: 104 additions & 0 deletions ShellUI.Tests/NuGetDepsAndSuggestionsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Linq;
using ShellUI.Templates;
using Xunit;

namespace ShellUI.Tests;

public class RegistrySuggestionsTests
{
[Theory]
[InlineData("datatable", "data-table")]
[InlineData("data_table", "data-table")]
[InlineData("buttong", "button")]
[InlineData("chrt", "chart")]
[InlineData("themetoggle", "theme-toggle")]
public void FindClosestMatch_SuggestsExpectedComponent(string typo, string expected)
{
Assert.Equal(expected, ComponentRegistry.FindClosestMatch(typo));
}

[Fact]
public void FindClosestMatch_ReturnsNullForExactMatch()
{
// Exact matches have distance 0 and are excluded from suggestions —
// the caller already knows the component exists.
Assert.Null(ComponentRegistry.FindClosestMatch("button"));
}

[Fact]
public void FindClosestMatch_ReturnsNullWhenNothingIsClose()
{
Assert.Null(ComponentRegistry.FindClosestMatch("xyzzy-no-such-component-at-all"));
}

[Fact]
public void FindClosestMatch_DoesNotSuggestHiddenSubComponents()
{
// `data-table-models` is IsAvailable=false (installed only as a dep of data-table).
// A user typo like "data-table-modls" should not be redirected to it.
var result = ComponentRegistry.FindClosestMatch("data-table-modls");
Assert.NotEqual("data-table-models", result);
}
}

public class NuGetDependenciesTests
{
[Fact]
public void DataTableModels_IsRegisteredAndHidden()
{
var metadata = ComponentRegistry.GetMetadata("data-table-models");
Assert.NotNull(metadata);
Assert.False(metadata!.IsAvailable, "data-table-models is a sub-component dep and must not appear in `shellui list`");
}

[Fact]
public void DataTable_DeclaresSystemLinqDynamicCore()
{
var metadata = ComponentRegistry.GetMetadata("data-table");
Assert.NotNull(metadata);
Assert.Contains(metadata!.NuGetDependencies, p => p.PackageId == "System.Linq.Dynamic.Core");
}

[Fact]
public void Chart_DeclaresBlazorApexCharts()
{
var metadata = ComponentRegistry.GetMetadata("chart");
Assert.NotNull(metadata);
Assert.Contains(metadata!.NuGetDependencies, p => p.PackageId == "Blazor-ApexCharts");
}

[Theory]
[InlineData("pie-chart")]
[InlineData("bar-chart")]
[InlineData("area-chart")]
[InlineData("line-chart")]
[InlineData("multi-series-chart")]
public void ChartVariants_TransitivelyPullInBlazorApexCharts(string componentName)
{
// Chart family components don't declare the NuGet dep themselves — they depend
// on `chart` which does. Verify the dependency chain is intact so the installer's
// recursive walk picks up the package.
var metadata = ComponentRegistry.GetMetadata(componentName);
Assert.NotNull(metadata);
Assert.Contains("chart", metadata!.Dependencies);
}
}

public class DataTableTemplateContentTests
{
// The library-wide convention is `Components.Models` for model namespaces regardless
// of where the file lives on disk (TabModels, StepperModels, ContextMenuModels,
// ChartModels all use this). The DataTable @using and DataTableModels namespace
// must agree on that convention so consumers can compile.
[Fact]
public void DataTable_UsingDirectiveMatchesDataTableModelsNamespace()
{
var dataTable = ComponentRegistry.GetComponentContent("data-table");
var models = ComponentRegistry.GetComponentContent("data-table-models");

Assert.NotNull(dataTable);
Assert.NotNull(models);
Assert.Contains("@using YourProjectNamespace.Components.Models", dataTable);
Assert.Contains("namespace YourProjectNamespace.Components.Models;", models);
}
}
92 changes: 82 additions & 10 deletions src/ShellUI.CLI/Services/ComponentInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class ComponentInstaller
public static async Task InstallComponents(string[] components, bool force)
{
var configPath = Path.Combine(Directory.GetCurrentDirectory(), "shellui.json");

if (!File.Exists(configPath))
{
AnsiConsole.MarkupLine("[red]ShellUI not initialized![/]");
Expand All @@ -21,7 +21,7 @@ public static async Task InstallComponents(string[] components, bool force)
// Load config
var configJson = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<ShellUIConfig>(configJson);

if (config == null)
{
AnsiConsole.MarkupLine("[red]Failed to read shellui.json[/]");
Expand All @@ -44,7 +44,10 @@ public static async Task InstallComponents(string[] components, bool force)

// Track installed components to avoid duplicates
var installedSet = new HashSet<string>();

// Track NuGet packages requested this batch so we don't re-invoke `dotnet add package` for the same dep
var requestedPackages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pendingNuGetDeps = new List<NuGetDependency>();

// Show dependency information
foreach (var componentName in componentList)
{
Expand All @@ -54,9 +57,9 @@ public static async Task InstallComponents(string[] components, bool force)
AnsiConsole.MarkupLine($"[green]●[/] [bold]{componentName}[/] requires: [yellow]{string.Join(", ", metadata.Dependencies)}[/]");
}
}

AnsiConsole.MarkupLine("");

await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
Expand All @@ -65,11 +68,15 @@ await AnsiConsole.Status()
foreach (var componentName in componentList)
{
ctx.Status($"Installing {componentName}...");
InstallComponentWithDependencies(componentName, config, projectInfo, force, installedSet, ref successCount, ref skippedCount, failedComponents);
InstallComponentWithDependencies(componentName, config, projectInfo, force, installedSet, requestedPackages, pendingNuGetDeps, ref successCount, ref skippedCount, failedComponents);
}
return Task.CompletedTask;
});

// Install collected NuGet dependencies once, after all source files are in place
// (so `dotnet add package` doesn't restore between every component).
await InstallNuGetDependenciesAsync(projectInfo, pendingNuGetDeps);

// Update config
var updatedJson = JsonSerializer.Serialize(config, new JsonSerializerOptions
{
Expand Down Expand Up @@ -203,14 +210,19 @@ private static InstallResult InstallComponentInternal(string componentName, Shel
return InstallResult.Success;
}

private static void InstallComponentWithDependencies(string componentName, ShellUIConfig config, ProjectInfo projectInfo, bool force, HashSet<string> installedSet, ref int successCount, ref int skippedCount, List<string> failedComponents)
private static void InstallComponentWithDependencies(string componentName, ShellUIConfig config, ProjectInfo projectInfo, bool force, HashSet<string> installedSet, HashSet<string> requestedPackages, List<NuGetDependency> pendingNuGetDeps, ref int successCount, ref int skippedCount, List<string> failedComponents)
{
if (installedSet.Contains(componentName))
return; // Already processed

if (!ComponentRegistry.Exists(componentName))
{
AnsiConsole.MarkupLine($"[red]Component '{componentName}' not found[/]");
var suggestion = ComponentRegistry.FindClosestMatch(componentName);
if (suggestion != null)
{
AnsiConsole.MarkupLine($"[yellow]Did you mean '[bold]{suggestion}[/]'?[/]");
}
failedComponents.Add(componentName);
return;
}
Expand All @@ -231,14 +243,14 @@ private static void InstallComponentWithDependencies(string componentName, Shell
{
if (!installedSet.Contains(dep))
{
InstallComponentWithDependencies(dep, config, projectInfo, force, installedSet, ref successCount, ref skippedCount, failedComponents);
InstallComponentWithDependencies(dep, config, projectInfo, force, installedSet, requestedPackages, pendingNuGetDeps, ref successCount, ref skippedCount, failedComponents);
}
}
}

// Install the component itself
var result = InstallComponentInternal(componentName, config, projectInfo, force);

if (result == InstallResult.Success)
{
successCount++;
Expand All @@ -253,6 +265,66 @@ private static void InstallComponentWithDependencies(string componentName, Shell
{
failedComponents.Add(componentName);
}

// Collect NuGet deps regardless of source-file install result — even a `Skipped`
// file still requires its NuGet packages to compile.
if (result != InstallResult.Failed && metadata.NuGetDependencies?.Any() == true)
{
foreach (var pkg in metadata.NuGetDependencies)
{
if (requestedPackages.Add(pkg.PackageId))
{
pendingNuGetDeps.Add(pkg);
}
}
}
}

private static async Task InstallNuGetDependenciesAsync(ProjectInfo projectInfo, List<NuGetDependency> deps)
{
if (deps.Count == 0) return;

AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine($"[cyan]Adding {deps.Count} NuGet package reference(s)...[/]");

foreach (var dep in deps)
{
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "dotnet",
ArgumentList = { "add", projectInfo.ProjectPath, "package", dep.PackageId, "--version", dep.Version },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};

try
{
using var process = System.Diagnostics.Process.Start(startInfo);
if (process == null)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] could not start `dotnet add package` for {dep.PackageId}");
continue;
}

await process.WaitForExitAsync();

if (process.ExitCode == 0)
{
AnsiConsole.MarkupLine($"[green]Added:[/] {dep.PackageId} {dep.Version}");
}
else
{
var err = (await process.StandardError.ReadToEndAsync()).Trim();
AnsiConsole.MarkupLine($"[yellow]Warning:[/] failed to add {dep.PackageId}: {err.Replace("[", "[[").Replace("]", "]]")}");
}
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] could not add {dep.PackageId}: {ex.Message.Replace("[", "[[").Replace("]", "]]")}");
}
}
}

private enum InstallResult
Expand Down
6 changes: 6 additions & 0 deletions src/ShellUI.Core/Models/ComponentMetadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ public class ComponentMetadata
public required ComponentCategory Category { get; set; }
public List<string> Dependencies { get; set; } = new();

// NuGet packages the rendered component references at compile time (e.g. Chart
// uses Blazor-ApexCharts; DataTable uses System.Linq.Dynamic.Core). The CLI
// runs `dotnet add package` for each on install — without this the consumer
// sees CS0246 errors after `shellui add`.
public List<NuGetDependency> NuGetDependencies { get; set; } = new();

// Relative to Components/UI folder (or LayoutPath when IsLayoutBlock is true)
public required string FilePath { get; set; }

Expand Down
7 changes: 7 additions & 0 deletions src/ShellUI.Core/Models/NuGetDependency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ShellUI.Core.Models;

public class NuGetDependency
{
public required string PackageId { get; init; }
public required string Version { get; init; }
}
51 changes: 51 additions & 0 deletions src/ShellUI.Templates/ComponentRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ public static class ComponentRegistry
{ "collapsible-trigger", CollapsibleTriggerTemplate.Metadata },
{ "collapsible-content", CollapsibleContentTemplate.Metadata },
{ "data-table", DataTableTemplate.Metadata },
{ "data-table-models", DataTableModelsTemplate.Metadata },
{ "alert-dialog", AlertDialogTemplate.Metadata },
{ "calendar", CalendarTemplate.Metadata },
{ "loading", LoadingTemplate.Metadata },
Expand Down Expand Up @@ -303,6 +304,7 @@ public static class ComponentRegistry
"collapsible-trigger" => CollapsibleTriggerTemplate.Content,
"collapsible-content" => CollapsibleContentTemplate.Content,
"data-table" => DataTableTemplate.Content,
"data-table-models" => DataTableModelsTemplate.Content,
"alert-dialog" => AlertDialogTemplate.Content,
"calendar" => CalendarTemplate.Content,
"loading" => LoadingTemplate.Content,
Expand Down Expand Up @@ -364,5 +366,54 @@ public static bool Exists(string componentName)
{
return Components.ContainsKey(componentName.ToLower());
}

// Returns the closest installable component name to `query` within edit distance 3,
// or null if nothing is close enough. Used to power "did you mean …?" hints when a
// user mistypes (e.g. `datatable` → `data-table`). Excludes hidden sub-components.
public static string? FindClosestMatch(string query)
{
var lower = query.ToLowerInvariant();
string? best = null;
var bestDistance = int.MaxValue;

foreach (var (name, metadata) in Components)
{
if (!metadata.IsAvailable) continue;
var d = LevenshteinDistance(lower, name);
if (d == 0 || d > 3) continue;
if (d < bestDistance || (d == bestDistance && string.CompareOrdinal(name, best) < 0))
{
best = name;
bestDistance = d;
}
}

return best;
}

private static int LevenshteinDistance(string a, string b)
{
if (a.Length == 0) return b.Length;
if (b.Length == 0) return a.Length;

var prev = new int[b.Length + 1];
var curr = new int[b.Length + 1];
for (var j = 0; j <= b.Length; j++) prev[j] = j;

for (var i = 1; i <= a.Length; i++)
{
curr[0] = i;
for (var j = 1; j <= b.Length; j++)
{
var cost = a[i - 1] == b[j - 1] ? 0 : 1;
curr[j] = Math.Min(
Math.Min(curr[j - 1] + 1, prev[j] + 1),
prev[j - 1] + cost);
}
(prev, curr) = (curr, prev);
}

return prev[b.Length];
}
}

4 changes: 4 additions & 0 deletions src/ShellUI.Templates/Templates/ChartTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ public class ChartTemplate
Category = ComponentCategory.DataDisplay,
FilePath = "Chart.razor",
Dependencies = new List<string> { "chart-variants" },
NuGetDependencies = new List<NuGetDependency>
{
new() { PackageId = "Blazor-ApexCharts", Version = "6.0.2" }
},
Variants = new List<string> { "default", "colorful", "monochrome" },
Tags = new List<string> { "chart", "data", "visualization", "apexcharts" }
};
Expand Down
Loading
Loading