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
2 changes: 2 additions & 0 deletions NET10/BlazorInteractiveServer/wwwroot/input.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
@custom-variant dark (&:is(.dark *));

:root {
color-scheme: light;
--background: oklch(0.9900 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
Expand Down Expand Up @@ -58,6 +59,7 @@
}

.dark {
color-scheme: dark;
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.1400 0 0);
Expand Down
42 changes: 42 additions & 0 deletions ShellUI.Tests/ColorSchemeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.IO;
using ShellUI.CLI.Services;
using ShellUI.Templates;
using Xunit;

namespace ShellUI.Tests;

public class ColorSchemeTests
{
[Fact]
public void InitTheme_SetsColorSchemeForBothThemes()
{
Assert.Contains("color-scheme: light;", CssTemplates.InputCss);
Assert.Contains("color-scheme: dark;", CssTemplates.InputCss);
}

[Fact]
public void AppliedTheme_SetsColorSchemeForBothThemes()
{
var css = ThemeService.BuildThemeCss(new TweakcnTheme
{
Name = "t",
ThemeVars = new(),
LightVars = new() { ["background"] = "#fff" },
DarkVars = new() { ["background"] = "#000" },
});

Assert.Contains(":root {\n color-scheme: light;", css.Replace("\r\n", "\n"));
Assert.Contains(".dark {\n color-scheme: dark;", css.Replace("\r\n", "\n"));
}

[Fact]
public void PackageTheme_SetsColorSchemeForBothThemes()
{
var root = new DirectoryInfo(System.AppContext.BaseDirectory);
while (root != null && !File.Exists(Path.Combine(root.FullName, "ShellUI.slnx"))) root = root.Parent;
var css = File.ReadAllText(Path.Combine(root!.FullName, "src", "ShellUI.Components", "wwwroot", "shellui-theme.css"));

Assert.Contains("color-scheme: light;", css);
Assert.Contains("color-scheme: dark;", css);
}
}
10 changes: 10 additions & 0 deletions ShellUI.Tests/PackageBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,14 @@ public void Dropdown_ClosesOnEscapeButNotOnOtherKeys()
dropdown.Find("div.relative").KeyDown(new KeyboardEventArgs { Key = "Escape" });
Assert.Equal(new[] { false }, changes);
}

[Fact]
public void TimePicker_ShowsASetValue()
{
using var ctx = new BunitContext();

var picker = ctx.Render<TimePicker>(p => p.Add(c => c.Value, new TimeSpan(9, 5, 0)));

Assert.Contains("09:05", picker.Markup);
}
}
72 changes: 72 additions & 0 deletions ShellUI.Tests/PackageRenderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,78 @@ public void EveryPackageComponent_RendersWithoutParameterBindingErrors()
Assert.True(offenders.Count == 0, "Package components that fail parameter binding:\n " + string.Join("\n ", offenders));
}

// A component that renders empty but throws once it has a value fails here (the TimePicker "HH:mm" bug).
[Fact]
public void ValueBearingComponents_RenderWithASampleValue()
{
var types = typeof(Shell).Assembly.GetExportedTypes()
.Where(t => typeof(IComponent).IsAssignableFrom(t) && !t.IsAbstract && t.Namespace == "ShellUI.Components")
.Select(t => t.IsGenericTypeDefinition ? Close(t) : t)
.OfType<Type>()
.ToList();

var tried = 0;
var offenders = new List<string>();
foreach (var type in types)
{
var values = SampleValues(type);
if (values.Count == 0 || !Renders(type, new Dictionary<string, object>(), out _)) continue;

tried++;
if (!Renders(type, values, out var error))
offenders.Add($"{type.Name} ({string.Join(", ", values.Keys)}): {error}");
}

Assert.True(tried >= 10, $"Only {tried} components had a value to set; the sample types may need extending.");
Assert.True(offenders.Count == 0, "Package components that throw once a value is set:\n " + string.Join("\n ", offenders));
}

private static readonly System.Text.RegularExpressions.Regex ValueLike = new("Value|Date|Time|Selected");

private static Dictionary<string, object> SampleValues(Type type)
{
var values = new Dictionary<string, object>();
foreach (var p in type.GetProperties())
{
if (!p.IsDefined(typeof(ParameterAttribute), true) || !p.CanWrite || !ValueLike.IsMatch(p.Name)) continue;
var t = Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType;
object? sample =
t == typeof(string) ? "b" :
t == typeof(DateTime) ? new DateTime(2026, 3, 14) :
t == typeof(DateOnly) ? new DateOnly(2026, 3, 14) :
t == typeof(TimeSpan) ? new TimeSpan(9, 5, 0) :
t == typeof(TimeOnly) ? new TimeOnly(9, 5) :
t == typeof(int) ? 42 :
t == typeof(double) ? 42.5 :
t == typeof(decimal) ? 42.5m :
t == typeof(bool) ? true :
t == typeof(List<string>) ? new List<string> { "a", "b" } :
t == typeof(IEnumerable<string>) ? new List<string> { "a", "b" } :
null;
if (sample != null) values[p.Name] = sample;
}
return values;
}

private static bool Renders(Type type, Dictionary<string, object> parameters, out string error)
{
using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
ctx.Services.AddShellUISonner();
ctx.Services.AddScoped<IThemeService, ThemeService>();
try
{
ctx.Render<DynamicComponent>(p => p.Add(c => c.Type, type).Add(c => c.Parameters, parameters));
error = "";
return true;
}
catch (Exception ex)
{
error = Flatten(ex);
return false;
}
}

private static Type? Close(Type definition)
{
foreach (var candidate in new[] { typeof(Row), typeof(string), typeof(int) })
Expand Down
87 changes: 87 additions & 0 deletions ShellUI.Tests/UpdateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Collections.Generic;
using System.IO;
using ShellUI.CLI.Services;
using ShellUI.Core.Models;
using ShellUI.Templates;
using Xunit;

namespace ShellUI.Tests;

public class UpdateDependencyTests
{
[Fact]
public void MissingDependencies_FindsDependenciesAddedSinceInstall()
{
// A 0.3 install of tabs and breadcrumb, before tab-models and the breadcrumb parts existed.
var installed = new List<string> { "tabs", "tabs-list", "tabs-trigger", "tabs-content", "breadcrumb", "breadcrumb-item" };

var missing = ComponentManager.MissingDependencies(new[] { "tabs", "breadcrumb" }, installed);

Assert.Contains("tab-models", missing);
Assert.Contains("breadcrumb-list", missing);
Assert.Contains("breadcrumb-ellipsis", missing);
Assert.DoesNotContain("tabs-list", missing);
Assert.Equal(missing.Count, new HashSet<string>(missing).Count);
}

[Fact]
public void MissingDependencies_IsEmptyWhenEverythingIsInstalled()
{
var tabs = ComponentRegistry.GetMetadata("tabs")!;
var installed = new List<string>(tabs.Dependencies) { "tabs" };

Assert.Empty(ComponentManager.MissingDependencies(new[] { "tabs" }, installed));
}
}

public class UpdateLocalChangesTests
{
private const string Template = "<div class=\"p-4\">@ChildContent</div>\n";

private static InstalledComponent Entry(string name, string? hash) => new() { Name = name, Version = "0.4.0", Hash = hash };

[Fact]
public void FileMatchingTheRecordedHash_HasNoLocalChanges()
{
var entry = Entry("button", ComponentInstaller.ContentHash(Template));

Assert.False(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template, Template));
}

[Fact]
public void EditedFile_HasLocalChanges()
{
var entry = Entry("button", ComponentInstaller.ContentHash(Template));

Assert.True(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template + "<!-- mine -->", Template));
}

[Fact]
public void LineEndingChangesAreNotLocalChanges()
{
var entry = Entry("button", ComponentInstaller.ContentHash(Template));

Assert.False(ComponentManager.HasLocalChanges(entry, ComponentRegistry.GetMetadata("button")!, Template.Replace("\n", "\r\n"), Template));
}

[Theory]
[InlineData("app-sidebar")]
[InlineData("dashboard-02")]
[InlineData("auth-01")]
public void OlderInstall_EditableBlockThatDiffersFromTheTemplate_HasLocalChanges(string name)
{
Assert.True(ComponentManager.HasLocalChanges(Entry(name, null), ComponentRegistry.GetMetadata(name)!, "edited", Template));
}

[Fact]
public void OlderInstall_EditableBlockMatchingTheTemplate_HasNoLocalChanges()
{
Assert.False(ComponentManager.HasLocalChanges(Entry("app-sidebar", null), ComponentRegistry.GetMetadata("app-sidebar")!, Template, Template));
}

[Fact]
public void OlderInstall_OrdinaryComponentIsUpdatedAsBefore()
{
Assert.False(ComponentManager.HasLocalChanges(Entry("button", null), ComponentRegistry.GetMetadata("button")!, "edited", Template));
}
}
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ The implemented command tree is:
| `shellui add <components...>` | Install one or more direct targets and recursively install their registry dependencies. |
| `shellui list` | List direct targets, with installed/available filtering. |
| `shellui remove <components...>` | Remove selected installed source files and update `shellui.json`. |
| `shellui update [components...]` | Rewrite selected installed source files, or all installed components with `--all`. |
| `shellui update [components...]` | Rewrite selected installed source files, or all installed components with `--all`; skips files changed since install unless `--force`, and installs new dependencies. |
| `shellui theme init <url-or-id>` | Initialize a project and apply a fetched theme in one operation. |
| `shellui theme apply <url-or-id>` | Apply a theme to `wwwroot/input.css` or emit a standalone override. |
| `shellui theme update` | Re-fetch the theme recorded in `shellui.theme.lock`. |
Expand Down
12 changes: 9 additions & 3 deletions docs/CLI_SYNTAX.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ shellui init [--force] [--style <style>] [--tailwind standalone|npm] [--yes] [--
shellui add <name...> [--force] [--replace-layout]
shellui list [--installed|--available]
shellui remove <name...>
shellui update [name...] [--all]
shellui update [name...] [--all] [--force]
shellui theme init <url-or-id> [--force] [--style <style>] [--tailwind standalone|npm] [--yes] [--dashboard 01|02|none] [--replace-layout]
shellui theme apply <url-or-id> [--emit-override <path>]
shellui theme update
Expand Down Expand Up @@ -142,7 +142,7 @@ shellui remove button
shellui remove button card dialog
```

Pass names separated by spaces. `remove` has no force flag or other options, does not prompt, and does not perform reverse-dependency cleanup. If another component still references a removed file, update that usage or remove the dependent component yourself. Layout blocks such as `dashboard-01` and `dashboard-02` currently require manual removal from `Components/Layout` because remove is not yet layout-aware.
Pass names separated by spaces. `remove` has no force flag or other options, does not prompt, and does not perform reverse-dependency cleanup. If another component still references a removed file, update that usage or remove the dependent component yourself. Layout blocks such as `dashboard-02` and `auth-01` are removed from `Components/Layout`, and `remove` lists any files that still use the layout (usually `Routes.razor`) so you can point them at another one.

## `update`

Expand All @@ -153,9 +153,15 @@ shellui update button
shellui update button card
shellui update --all
shellui update
shellui update card --force
```

Names are separated by spaces. With no names, or with `--all`, every installed component is updated. `update` overwrites files directly; there is no diff, merge, confirmation, or force option. Commit or back up local changes before updating.
Names are separated by spaces. With no names, or with `--all`, every installed component is updated.

- **Your changes are kept.** `shellui.json` records a hash of each file as the CLI wrote it. If a file has changed since, `update` skips it and says so; `--force` overwrites it. Files installed by a CLI older than 0.4.0-alpha.2 have no hash, so for those `update` keeps the blocks apps are meant to edit (`app-sidebar`, `sidebar-account`, the `dashboard-*` and `auth-*` layouts) when they differ from the template, and rewrites everything else.
- **New dependencies are installed.** When a component gained dependencies since you installed it (for example `tabs` now needs `tab-models`), `update` adds them, with their NuGet packages and `_Imports.razor` usings, the same way `add` does.

`update` has no diff or merge. Commit before updating so you can review the changes.

## `theme`

Expand Down
6 changes: 3 additions & 3 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,19 @@ There are 93 direct targets. Use the command output as the authoritative name li

### What happens when I update a component?

`update` overwrites the selected source template without showing a diff, merging changes, or asking for a force flag:
`update` rewrites the selected components from the current templates and installs any dependencies they gained:

```bash
shellui update button
shellui update button card
shellui update --all
```

Running `shellui update` without names also updates all installed components. Commit or back up local changes first.
Running `shellui update` without names also updates all installed components. Files you have changed since the CLI wrote them are skipped; add `--force` to overwrite them. There is no diff or merge, so commit first.

### What happens when I remove a component?

`remove` deletes the named component files and updates `shellui.json`. It has no options and does not perform reverse-dependency cleanup. Layout blocks such as `dashboard-01` and `dashboard-02` currently require manual removal from `Components/Layout` because remove is not yet layout-aware:
`remove` deletes the named component files and updates `shellui.json`. It has no options and does not perform reverse-dependency cleanup. Layout blocks such as `dashboard-02` are removed from `Components/Layout`, and `remove` lists the files that still use the layout (usually `Routes.razor`):

```bash
shellui remove button
Expand Down
4 changes: 2 additions & 2 deletions docs/QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ shellui update --all
shellui remove button card
```

`update` overwrites the selected source files directly. It has no comparison, merge, or force step. Commit or back up custom changes before running it.
`update` rewrites the selected source files and skips any you have changed (use `--force` to overwrite those). Commit before running it.

`remove` accepts component names separated by spaces and has no options. It removes the named files, but it does not perform reverse-dependency cleanup. Layout blocks such as `dashboard-01` and `dashboard-02` currently require manual removal from `Components/Layout`. Check references before removing a component that another component may use.
`remove` accepts component names separated by spaces and has no options. It removes the named files, but it does not perform reverse-dependency cleanup. For layout blocks such as `dashboard-02`, `remove` lists the files that still use the layout. Check references before removing a component that another component may use.

## Apply a theme

Expand Down
11 changes: 8 additions & 3 deletions src/ShellUI.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -332,17 +332,22 @@ static Command CreateUpdateCommand()
"Update all installed components");
command.AddOption(allOption);

command.SetHandler((components, all) =>
var forceOption = new Option<bool>(
"--force",
"Also overwrite components you have changed");
command.AddOption(forceOption);

command.SetHandler(async (components, all, force) =>
{
try
{
ComponentManager.UpdateComponents(components, all);
await ComponentManager.UpdateComponents(components, all, force);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message.Replace("[", "[[").Replace("]", "]]")}");
}
}, componentsArg, allOption);
}, componentsArg, allOption, forceOption);

return command;
}
Expand Down
Loading
Loading